feat: add managed SSH execution spaces

Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI.

Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts.

Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
This commit is contained in:
mesalogo
2026-08-24 23:02:18 +08:00
parent 3829a871f5
commit 607bb33249
258 changed files with 99449 additions and 2427 deletions
+93
View File
@@ -0,0 +1,93 @@
export async function raceWithAbort<T>(
pending: Promise<T>,
signal: AbortSignal
): Promise<T> {
signal.throwIfAborted()
return await new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
cleanup()
reject(signal.reason)
}
const cleanup = (): void => {
signal.removeEventListener('abort', onAbort)
}
signal.addEventListener('abort', onAbort, { once: true })
void pending.then(
(value) => {
cleanup()
resolve(value)
},
(error: unknown) => {
cleanup()
reject(error)
}
)
})
}
export function closeServer(server: {
close(callback: (error?: Error) => void): unknown
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
try {
server.close((error) => {
if (error === undefined) {
resolve()
} else {
reject(error)
}
})
} catch (error) {
if (
isNodeError(error) &&
error.code === 'ERR_SERVER_NOT_RUNNING'
) {
resolve()
} else {
reject(error)
}
}
})
}
export async function settleWithin(
pending: Promise<unknown>,
timeoutMs: number
): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
pending,
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, timeoutMs)
timeout.unref?.()
})
])
} finally {
if (timeout !== undefined) {
clearTimeout(timeout)
}
}
}
export function boundedInteger(
value: number,
minimum: number,
maximum: number,
label: string
): number {
if (
!Number.isSafeInteger(value) ||
value < minimum ||
value > maximum
) {
throw new RangeError(`${label} is invalid`)
}
return value
}
export function isNodeError(
error: unknown
): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error
}
@@ -0,0 +1,63 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { open } from 'node:fs/promises'
import type {
AgentArchitecture
} from '../shared/agent-installation-contracts'
export async function sha256File(filePath: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(filePath)) {
hash.update(chunk as Buffer)
}
return hash.digest('hex')
}
export async function assertElfArchitecture(
filePath: string,
expected: AgentArchitecture,
label: string
): Promise<void> {
const handle = await open(filePath, 'r')
try {
const header = Buffer.alloc(64)
const { bytesRead } = await handle.read(
header,
0,
header.length,
0
)
const actual = detectElfArchitecture(
header.subarray(0, bytesRead)
)
if (actual !== expected) {
throw new Error(
`${label} architecture mismatch: expected ${expected}, received ${actual ?? 'unknown'}`
)
}
} finally {
await handle.close()
}
}
function detectElfArchitecture(
header: Buffer
): AgentArchitecture | undefined {
if (
header.length < 20 ||
header[0] !== 0x7f ||
header.toString('ascii', 1, 4) !== 'ELF' ||
(header[5] !== 1 && header[5] !== 2)
) {
return undefined
}
const machine =
header[5] === 2
? header.readUInt16BE(18)
: header.readUInt16LE(18)
return machine === 62
? 'x64'
: machine === 183
? 'arm64'
: undefined
}
+1
View File
@@ -0,0 +1 @@
export * from '../shared/agent-protocol/channel-state'
+566
View File
@@ -0,0 +1,566 @@
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_PROTOCOL_FAILURE_RECORD_NAME,
AGENT_PROTOCOL_FAILURE_STDERR_PREFIX,
AGENT_PROTOCOL_VERSION
} from '../shared/agent-protocol'
import {
buildFixedAgentCliArgv,
verifyAgentInstallationId
} from '../main/ssh/ssh-agent-command'
import {
deriveManagedInstallationPaths,
deriveManagedRuntimePaths,
runAgentCli,
type AgentCliDependencies,
type ManagedInstallationPaths
} from './cli'
import { InstallationRegistry } from './installation-registry'
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
import { RuntimeBundleRegistry } from './runtime-bundle-registry'
import type { VerifiedRuntimeBundle } from './runtime-bundle-verifier'
import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts'
const temporaryPaths: string[] = []
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { force: true, recursive: true })
}
})
describe('Agent CLI fixed command contract', () => {
it('accepts only the fixed model bridge helper argv', async () => {
const runModelBridgeHelper = vi.fn(async () => 7)
await expect(
runAgentCli(
[
'model-bridge-helper',
'--socket-path',
'/private/bridge/model-bridge.sock',
'--protocol',
'openai-responses',
'--model',
'private-model',
'--supports-image-input',
'false',
'--opencode-entrypoint',
'/runtime/bin/opencode'
],
{ runModelBridgeHelper, io: cliIo() }
)
).resolves.toBe(7)
expect(runModelBridgeHelper).toHaveBeenCalledWith({
socketPath: '/private/bridge/model-bridge.sock',
protocol: 'openai-responses',
model: 'private-model',
supportsImageInput: false,
opencodeEntrypoint: '/runtime/bin/opencode'
})
await expect(
runAgentCli(
[
'model-bridge-helper',
'--socket-path',
'/private/bridge/model-bridge.sock',
'--protocol',
'openai-responses',
'--model',
'private-model',
'--supports-image-input',
'false',
'--opencode-entrypoint',
'/runtime/bin/opencode',
'--api-key',
'secret'
],
{ runModelBridgeHelper, io: cliIo() }
)
).resolves.toBe(2)
expect(runModelBridgeHelper).toHaveBeenCalledOnce()
})
it('passes verified manifest version and protocol into the daemon', async () => {
const root = privateTemporaryDirectory()
const paths: ManagedInstallationPaths = {
executablePath: resolve(root, 'install-1', 'goodbuddy-agent'),
stateDirectory: resolve(root, 'state'),
socketPath: resolve(root, 'run', 'agent.sock')
}
const releaseKeyRegistry = {
formatVersion: 1 as const,
keys: [],
revocations: []
}
const verified = verifiedInstallation(paths, 'install-1')
const registry = new InstallationRegistry({
storagePath: resolve(root, 'registry.json')
})
registry.stageCandidate(verified)
const start = vi.fn(async () => undefined)
const stop = vi.fn(async () => undefined)
const createDaemon = vi.fn(() => ({
start,
stop,
status: () => ({ daemonBootId: 'daemon-boot-1' })
}))
const recordCurrentDaemonReady = vi.fn(async () => undefined)
const io = cliIo()
const result = await runAgentCli(
['daemon', '--installation-id', 'install-1'],
{
installationPaths: () => paths,
releaseKeyRegistry,
installationRegistry: registry,
verifyInstallation: async () => verified,
peerIdentityProvider: {
async getPeerIdentity() {
return { uid: 1 }
}
},
createDaemon:
createDaemon as unknown as NonNullable<
AgentCliDependencies['createDaemon']
>,
createLifecycle: (() => ({
recordCurrentDaemonReady
})) as unknown as NonNullable<
AgentCliDependencies['createLifecycle']
>,
waitForShutdown: async () => undefined,
io
}
)
expect(result, io.error.read()?.toString()).toBe(0)
expect(createDaemon).toHaveBeenCalledWith(
expect.objectContaining({
installationId: 'install-1',
binaryDigest: verified.binaryDigest,
agentVersion: verified.manifest.agentVersion,
protocol: verified.manifest.protocol
})
)
expect(start).toHaveBeenCalledOnce()
expect(stop).toHaveBeenCalledOnce()
expect(recordCurrentDaemonReady).toHaveBeenCalledWith(
'daemon-boot-1'
)
})
it('dispatches generated doctor and lifecycle argv through the real parser', async () => {
const installationId = verifyAgentInstallationId('install-1')
const registryRoot = privateTemporaryDirectory()
const installationPaths = {
executablePath: resolve(registryRoot, 'agent', 'goodbuddy-agent'),
stateDirectory: resolve(registryRoot, 'state', 'install-1'),
socketPath: resolve(registryRoot, 'run', 'install-1.sock')
}
const installationRegistry = new InstallationRegistry({
storagePath: resolve(registryRoot, 'registry.json')
})
const releaseKeyRegistry = {
formatVersion: 1 as const,
keys: [],
revocations: []
}
const verified = verifiedInstallation(
installationPaths,
installationId
)
installationRegistry.stageCandidate(verified)
const status = vi.fn(async () => ({ state: 'absent' as const }))
const bootstrap = vi.fn(async () => ({
state: 'ready' as const,
installationId
}))
const dependencies: AgentCliDependencies = {
installationPaths: () => installationPaths,
releaseKeyRegistry,
installationRegistry,
verifyInstallation: async () => verified,
createLifecycle: (() => ({
status,
bootstrap
})) as unknown as NonNullable<
AgentCliDependencies['createLifecycle']
>
}
const lifecycleIo = cliIo()
expect(
await runAgentCli(
buildFixedAgentCliArgv(installationId, { kind: 'doctor' }),
{ ...dependencies, io: cliIo() }
)
).toBe(0)
const lifecycleResult = await runAgentCli(
buildFixedAgentCliArgv(installationId, {
kind: 'lifecycle',
action: 'bootstrap'
}),
{
...dependencies,
io: lifecycleIo
}
)
expect(
lifecycleResult,
lifecycleIo.error.read()?.toString()
).toBe(0)
expect(status).toHaveBeenCalledOnce()
expect(bootstrap).toHaveBeenCalledOnce()
})
it('dispatches generated attach argv using only derived managed paths', async () => {
const root = privateTemporaryDirectory()
const paths: ManagedInstallationPaths = {
executablePath: resolve(root, 'goodbuddy-agent'),
stateDirectory: resolve(root, 'state'),
socketPath: resolve(root, 'run', 'agent.sock')
}
mkdirSync(paths.stateDirectory, { mode: 0o700 })
if (process.platform !== 'win32') {
chmodSync(paths.stateDirectory, 0o700)
}
writeFileSync(
resolve(paths.stateDirectory, 'installation-identity'),
Buffer.alloc(32, 7),
{ mode: 0o600 }
)
const attach = vi.fn(async (options) => {
const connectionId = 'connection-diagnostic'
writeFileSync(
resolve(
paths.stateDirectory,
AGENT_PROTOCOL_FAILURE_RECORD_NAME
),
`${JSON.stringify({
formatVersion: 1,
connectionId,
category: 'dispatch/process',
createdAt: 1
})}\n`,
{ mode: 0o600 }
)
options.onWelcome?.({
type: 'goodbuddy-agent-welcome',
protocol: AGENT_PROTOCOL_VERSION,
connectionId,
generation: 1,
installationId: 'install-1',
binaryDigest: `sha256:${'a'.repeat(64)}`,
daemonBootId: 'boot-1',
serverNonce: 'nonce-1'
})
})
const installationId = verifyAgentInstallationId('install-1')
const releaseKeyRegistry = {
formatVersion: 1 as const,
keys: [],
revocations: []
}
const verified = verifiedInstallation(paths, installationId)
const installationRegistry = new InstallationRegistry({
storagePath: resolve(root, 'registry.json')
})
installationRegistry.stageCandidate(verified)
const io = cliIo()
const attachResult = await runAgentCli(
buildFixedAgentCliArgv(installationId, { kind: 'attach' }),
{
attach,
installationPaths: () => paths,
releaseKeyRegistry,
installationRegistry,
verifyInstallation: async () => verified,
io
}
)
expect(attachResult).toBe(0)
expect(io.error.read()?.toString()).toBe(
`${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}dispatch/process\n`
)
expect(attach).toHaveBeenCalledWith(
expect.objectContaining({
socketPath: paths.socketPath,
secret: Buffer.alloc(32, 7)
})
)
})
it('derives fixed Linux paths and rejects untrusted installation IDs', () => {
expect(
deriveManagedInstallationPaths('install-1', {
platform: 'linux',
homeDirectory: '/home/tester',
uid: 1234
})
).toEqual({
executablePath:
'/home/tester/.goodbuddy/agent/installations/install-1/goodbuddy-agent',
stateDirectory: '/home/tester/.goodbuddy/state/install-1',
socketPath:
'/tmp/goodbuddy-1234-33847abc68e72629c30d/caab28ec6ec9597dfdd5bc3c2398a893.sock'
})
expect(() =>
deriveManagedInstallationPaths('../escape', {
platform: 'linux',
homeDirectory: '/home/tester',
uid: 1234
})
).toThrow('installation ID')
expect(() =>
deriveManagedInstallationPaths('install-1', {
platform: 'win32',
homeDirectory: 'C:\\Users\\tester',
uid: 1234
})
).toThrow('current Linux UID')
})
it('rejects injected installation IDs before lifecycle dispatch', async () => {
const createLifecycle = vi.fn()
expect(
await runAgentCli(
[
'stop',
'--installation-id',
'install; shutdown'
],
{ createLifecycle, io: cliIo() }
)
).toBe(2)
expect(createLifecycle).not.toHaveBeenCalled()
})
it('verifies and atomically registers the fixed Runtime activation', async () => {
const root = privateTemporaryDirectory()
const runtimeRoot = resolve(root, 'runtimes')
const bundleDigest = `sha256:${'f'.repeat(64)}`
const paths = {
runtimeRoot,
bundleDirectory: resolve(
runtimeRoot,
'opencode',
bundleDigest.slice('sha256:'.length)
),
releaseKeyRegistryPath: resolve(
runtimeRoot,
'release-keys.json'
),
runtimeLockPath: resolve(
runtimeRoot,
'remote-runtime-lock.json'
),
registryPath: resolve(runtimeRoot, 'registry.json')
}
const releaseKeyRegistry = {
formatVersion: 1 as const,
keys: [],
revocations: []
}
const registry = new RuntimeBundleRegistry({
runtimeRoot
})
const verifyRuntime = vi.fn(async () =>
verifiedRuntime(paths.bundleDirectory, bundleDigest)
)
const io = cliIo()
expect(
await runAgentCli(
buildFixedAgentCliArgv(
verifyAgentInstallationId('install-1'),
{
kind: 'runtime-activate',
runtimeId: 'opencode',
bundleDigest,
architecture: 'x64'
}
),
{
runtimePaths: () => paths,
runtimeReleaseKeyRegistry: releaseKeyRegistry,
runtimeLock: {} as RemoteRuntimeLock,
runtimeRegistry: registry,
verifyRuntime,
currentArchitecture: () => 'x64',
io
}
)
).toBe(0)
expect(verifyRuntime).toHaveBeenCalledWith(
paths.bundleDirectory,
expect.objectContaining({
architecture: 'x64',
verificationEnvironment: 'production'
})
)
expect(registry.current('x64')).toEqual([
expect.objectContaining({
runtimeId: 'opencode',
bundleDigest,
architecture: 'x64'
})
])
expect(JSON.parse(io.output.read()!.toString())).toEqual({
activated: true,
runtimeId: 'opencode',
bundleDigest,
architecture: 'x64',
runtimeVersion: '1.18.9'
})
})
it('derives Runtime paths and rejects activation argv injection', async () => {
const digest = `sha256:${'a'.repeat(64)}`
expect(
deriveManagedRuntimePaths('install-1', 'opencode', digest, {
homeDirectory: '/home/tester'
})
).toEqual({
runtimeRoot: '/home/tester/.goodbuddy/runtimes',
bundleDirectory:
`/home/tester/.goodbuddy/runtimes/opencode/${'a'.repeat(64)}`,
releaseKeyRegistryPath:
'/home/tester/.goodbuddy/runtimes/release-keys.json',
runtimeLockPath:
'/home/tester/.goodbuddy/runtimes/remote-runtime-lock.json',
registryPath:
'/home/tester/.goodbuddy/runtimes/registry.json'
})
const verifyRuntime = vi.fn()
expect(
await runAgentCli(
[
'runtime',
'activate',
'--installation-id',
'install-1',
'--runtime-id',
'opencode;id',
'--bundle-digest',
digest,
'--architecture',
'x64'
],
{ verifyRuntime, io: cliIo() }
)
).toBe(2)
expect(verifyRuntime).not.toHaveBeenCalled()
})
})
function verifiedInstallation(
paths: ManagedInstallationPaths,
installationId: string
): VerifiedInstalledAgentBundle {
const manifestSha256 = 'a'.repeat(64)
return {
installationId,
installationDirectory: resolve(paths.executablePath, '..'),
executablePath: paths.executablePath,
manifestSha256,
binaryDigest: `sha256:${manifestSha256}`,
manifest: {
formatVersion: 1,
product: 'GoodBuddy',
agentVersion: '0.11.0',
platform: 'linux',
arch: process.arch === 'arm64' ? 'arm64' : 'x64',
protocol: { major: 1, minor: 0 },
signingKeyId: 'test-key',
entrypoint: {
path: 'goodbuddy-agent',
runtimePath: 'node',
scriptPath: 'lib/agent.cjs'
},
files: [
{
path: 'goodbuddy-agent',
size: 1,
sha256: 'b'.repeat(64),
mode: '0755'
},
{
path: 'node',
size: 1,
sha256: 'c'.repeat(64),
mode: '0755'
},
{
path: 'lib/agent.cjs',
size: 1,
sha256: 'd'.repeat(64),
mode: '0644'
},
{
path: 'licenses/GoodBuddy-0BSD.txt',
size: 1,
sha256: 'e'.repeat(64),
mode: '0644'
}
],
licenses: [
{
package: 'GoodBuddy',
version: '0.11.0',
spdx: '0BSD',
path: 'licenses/GoodBuddy-0BSD.txt'
}
]
}
}
}
function cliIo(): {
input: PassThrough
output: PassThrough
error: PassThrough
} {
return {
input: new PassThrough(),
output: new PassThrough(),
error: new PassThrough()
}
}
function privateTemporaryDirectory(): string {
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-cli-'))
temporaryPaths.push(path)
if (process.platform !== 'win32') {
chmodSync(path, 0o700)
}
return resolve(path)
}
function verifiedRuntime(
bundleDirectory: string,
bundleDigest: string
): VerifiedRuntimeBundle {
return {
bundleDirectory,
executablePath: resolve(bundleDirectory, 'bin', 'opencode'),
manifestDigest: `sha256:${'e'.repeat(64)}`,
manifest: {
runtimeId: 'opencode',
provider: 'opencode',
runtimeVersion: '1.18.9',
architecture: 'x64',
signingKeyId: 'test-key',
bundleDigest,
acpCapabilitiesDigest: `sha256:${'d'.repeat(64)}`
}
} as VerifiedRuntimeBundle
}
+861
View File
@@ -0,0 +1,861 @@
import { createHash } from 'node:crypto'
import { homedir } from 'node:os'
import { dirname, posix, resolve } from 'node:path'
import type { Readable, Writable } from 'node:stream'
import {
AGENT_PROTOCOL_FAILURE_RECORD_NAME,
AGENT_PROTOCOL_FAILURE_STDERR_PREFIX,
agentProtocolFailureRecordSchema
} from '../shared/agent-protocol'
import type {
AgentArchitecture,
AgentReleaseKeyRegistry
} from '../shared/agent-installation-contracts'
import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts'
import { AgentDaemon } from './daemon'
import { AgentUnsupportedError } from './errors'
import {
derivePrivateTemporaryRoot,
ensurePrivateTemporaryRoot,
readPrivateFile,
readPrivateSecret,
ensurePrivateDirectory
} from './managed-paths'
import {
attachRelay,
probeAuthenticatedEndpoint,
type UnixPeerIdentityProvider
} from './private-endpoint'
import { createLinuxPeerIdentityProvider } from './peer-identity-provider'
import {
readManagedAgentReleaseKeyRegistry,
verifyInstalledAgentBundle,
type VerifiedInstalledAgentBundle
} from './installed-bundle-verifier'
import { InstallationRegistry } from './installation-registry'
import {
RuntimeBundleRegistry
} from './runtime-bundle-registry'
import {
readRemoteRuntimeLock,
verifyRuntimeBundle,
type VerifiedRuntimeBundle
} from './runtime-bundle-verifier'
import { createProductionRuntimeProtocol } from './runtime-composition'
import {
runOpenCodeModelBridgeHelper,
type ModelBridgeProtocol
} from './model-bridge-helper'
import {
DetachedAgentLifecycle,
type DetachedAgentLifecycleOptions
} from './detached-agent-lifecycle'
type CliIo = {
input: Readable
output: Writable
error: Writable
}
export type AgentCliDependencies = {
io?: CliIo
createLifecycle?: (
options: DetachedAgentLifecycleOptions
) => DetachedAgentLifecycle
peerIdentityProvider?: UnixPeerIdentityProvider
createDaemon?: (options: ConstructorParameters<typeof AgentDaemon>[0]) => AgentDaemon
waitForShutdown?: () => Promise<void>
installationPaths?: (
installationId: string
) => ManagedInstallationPaths
attach?: typeof attachRelay
releaseKeyRegistry?: AgentReleaseKeyRegistry
installationRegistry?: InstallationRegistry
verifyInstallation?: (
installationDirectory: string,
options: {
installationId: string
architecture: AgentArchitecture
releaseKeyRegistry: AgentReleaseKeyRegistry
}
) => Promise<VerifiedInstalledAgentBundle>
runtimePaths?: (
installationId: string,
runtimeId: 'opencode',
bundleDigest: string
) => ManagedRuntimePaths
runtimeReleaseKeyRegistry?: AgentReleaseKeyRegistry
runtimeLock?: RemoteRuntimeLock
runtimeRegistry?: RuntimeBundleRegistry
verifyRuntime?: typeof verifyRuntimeBundle
runtimeVerificationEnvironment?: 'production' | 'test'
currentArchitecture?: () => AgentArchitecture
runModelBridgeHelper?: typeof runOpenCodeModelBridgeHelper
}
export type ManagedInstallationPaths = {
executablePath: string
stateDirectory: string
socketPath: string
}
export type ManagedRuntimePaths = {
runtimeRoot: string
bundleDirectory: string
releaseKeyRegistryPath: string
runtimeLockPath: string
registryPath: string
}
const LIFECYCLE_ACTIONS = new Set([
'bootstrap',
'status',
'health',
'stop',
'retire'
])
export async function runAgentCli(
argv: readonly string[],
dependencies: AgentCliDependencies = {}
): Promise<number> {
const io = dependencies.io ?? {
input: process.stdin,
output: process.stdout,
error: process.stderr
}
try {
const [command, ...rest] = argv
switch (command) {
case 'daemon':
await runDaemon(rest, dependencies)
return 0
case 'attach-or-bootstrap':
await runAttach(rest, dependencies, io)
return 0
case 'doctor':
await runDoctor(rest, dependencies, io)
return 0
case 'runtime':
await runRuntime(rest, dependencies, io)
return 0
case 'model-bridge-helper':
return await runModelBridgeHelper(rest, dependencies)
default:
if (command !== undefined && LIFECYCLE_ACTIONS.has(command)) {
await runLifecycle([command, ...rest], dependencies, io)
return 0
}
throw new Error(
'Expected daemon, attach-or-bootstrap, doctor, a lifecycle action, runtime, or model-bridge-helper'
)
}
} catch (error) {
io.error.write(
`${error instanceof Error ? error.message : String(error)}\n`
)
return error instanceof AgentUnsupportedError ? 3 : 2
}
}
async function runModelBridgeHelper(
argv: readonly string[],
dependencies: AgentCliDependencies
): Promise<number> {
const options = parseOptions(argv, [
'socket-path',
'protocol',
'model',
'supports-image-input',
'opencode-entrypoint'
])
requireOptions(options, [
'socket-path',
'protocol',
'model',
'supports-image-input',
'opencode-entrypoint'
])
const protocol = options.protocol
if (
protocol !== 'anthropic-messages' &&
protocol !== 'openai-chat-completions' &&
protocol !== 'openai-responses'
) {
throw new Error('Invalid model bridge protocol')
}
const imageInput = options['supports-image-input']
if (imageInput !== 'true' && imageInput !== 'false') {
throw new Error('Invalid model bridge image-input option')
}
return await (
dependencies.runModelBridgeHelper ??
runOpenCodeModelBridgeHelper
)({
socketPath: options['socket-path']!,
protocol: protocol as ModelBridgeProtocol,
model: options.model!,
supportsImageInput: imageInput === 'true',
opencodeEntrypoint: options['opencode-entrypoint']!
})
}
async function runRuntime(
argv: readonly string[],
dependencies: AgentCliDependencies,
io: CliIo
): Promise<void> {
const [action, ...optionArguments] = argv
if (action !== 'activate') {
throw new Error('Invalid fixed Runtime action')
}
const options = parseOptions(optionArguments, [
'installation-id',
'runtime-id',
'bundle-digest',
'architecture'
])
requireOptions(options, [
'installation-id',
'runtime-id',
'bundle-digest',
'architecture'
])
const installationId = validateInstallationId(
options['installation-id']!
)
const runtimeId = validateRuntimeId(options['runtime-id']!)
const bundleDigest = validateBundleDigest(
options['bundle-digest']!
)
const architecture = validateRuntimeArchitecture(
options.architecture!
)
const hostArchitecture =
dependencies.currentArchitecture?.() ??
currentAgentArchitecture()
if (architecture !== hostArchitecture) {
throw new Error(
'Runtime activation architecture does not match the current Agent'
)
}
const paths = resolveRuntimePaths(
installationId,
runtimeId,
bundleDigest,
dependencies
)
const releaseKeyRegistry =
dependencies.runtimeReleaseKeyRegistry ??
await readManagedAgentReleaseKeyRegistry(
paths.releaseKeyRegistryPath
)
const runtimeLock =
dependencies.runtimeLock ??
await readRemoteRuntimeLock(paths.runtimeLockPath)
const registry =
dependencies.runtimeRegistry ??
new RuntimeBundleRegistry({
runtimeRoot: paths.runtimeRoot,
storagePath: paths.registryPath
})
const verified = await (
dependencies.verifyRuntime ?? verifyRuntimeBundle
)(paths.bundleDirectory, {
architecture,
releaseKeyRegistry,
runtimeLock,
verificationEnvironment:
dependencies.runtimeVerificationEnvironment ?? 'production'
})
assertActivatedRuntimeIdentity(
verified,
runtimeId,
bundleDigest,
architecture
)
const entry = registry.register(verified)
io.output.write(`${JSON.stringify({
activated: true,
runtimeId: entry.runtimeId,
bundleDigest: entry.bundleDigest,
architecture: entry.architecture,
runtimeVersion: entry.runtimeVersion
})}\n`)
}
async function runDaemon(
argv: readonly string[],
dependencies: AgentCliDependencies
): Promise<void> {
const options = parseOptions(argv, ['installation-id'])
requireOptions(options, ['installation-id'])
const installationId = validateInstallationId(
options['installation-id']!
)
const paths = resolveInstallationPaths(installationId, dependencies)
const { verified, registry } = await verifyManagedInstallation(
installationId,
paths,
dependencies
)
registry.assertVerifiedRole(verified, ['current', 'candidate'])
prepareManagedSocketDirectory(paths)
const daemonOptions: ConstructorParameters<typeof AgentDaemon>[0] = {
installationId,
binaryDigest: verified.binaryDigest,
agentVersion: verified.manifest.agentVersion,
protocol: verified.manifest.protocol,
stateDirectory: paths.stateDirectory,
socketPath: paths.socketPath,
peerIdentityProvider:
dependencies.peerIdentityProvider ??
await createLinuxPeerIdentityProvider(),
runtimeFactory: async ({
events,
workspaces,
outputSink,
blobSink
}) =>
await createProductionRuntimeProtocol({
runtimeRoot: resolve(
dirname(dirname(dirname(dirname(paths.executablePath)))),
'runtimes'
),
stateDirectory: paths.stateDirectory,
architecture: currentAgentArchitecture(),
events,
workspaces,
outputSink,
blobSink,
agentExecutablePath: paths.executablePath,
installationId
})
}
const daemon =
dependencies.createDaemon?.(daemonOptions) ??
new AgentDaemon(daemonOptions)
await daemon.start()
try {
await createDetachedLifecycle(
installationId,
paths,
dependencies
).recordCurrentDaemonReady(daemon.status().daemonBootId)
await (dependencies.waitForShutdown?.() ?? waitForShutdownSignal())
} finally {
await daemon.stop()
}
}
async function runAttach(
argv: readonly string[],
dependencies: AgentCliDependencies,
io: CliIo
): Promise<void> {
const options = parseOptions(argv, ['installation-id'])
requireOptions(options, ['installation-id'])
const installationId = validateInstallationId(
options['installation-id']!
)
const paths = resolveInstallationPaths(
installationId,
dependencies
)
const { verified, registry } = await verifyManagedInstallation(
installationId,
paths,
dependencies
)
registry.assertVerifiedRole(verified, ['current', 'candidate'])
prepareManagedSocketDirectory(paths)
const stateDirectory = paths.stateDirectory
ensurePrivateDirectory(stateDirectory, { create: false })
const secret = readPrivateSecret(
resolve(stateDirectory, 'installation-identity')
)
let connectionId: string | undefined
await (dependencies.attach ?? attachRelay)({
socketPath: paths.socketPath,
secret,
input: io.input,
output: io.output,
onWelcome: (welcome) => {
connectionId = welcome.connectionId
},
ensureEndpoint: async () => {
await createDetachedLifecycle(
installationId,
paths,
dependencies
).bootstrap()
}
})
if (connectionId !== undefined) {
const category = readProtocolFailureCategory(
stateDirectory,
connectionId
)
if (category !== undefined) {
io.error.write(
`${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}${category}\n`
)
}
}
}
function readProtocolFailureCategory(
stateDirectory: string,
connectionId: string
): string | undefined {
try {
const record = agentProtocolFailureRecordSchema.parse(
JSON.parse(
readPrivateFile(
resolve(
stateDirectory,
AGENT_PROTOCOL_FAILURE_RECORD_NAME
),
4096
).toString('utf8')
)
)
return record.connectionId === connectionId
? record.category
: undefined
} catch {
return undefined
}
}
async function runDoctor(
argv: readonly string[],
dependencies: AgentCliDependencies,
io: CliIo
): Promise<void> {
const options = parseOptions(argv, ['installation-id'])
requireOptions(options, ['installation-id'])
const installationId = validateInstallationId(
options['installation-id']!
)
const paths = resolveInstallationPaths(installationId, dependencies)
const status = await createDetachedLifecycle(
installationId,
paths,
dependencies
).status()
io.output.write(`${JSON.stringify({
platform: process.platform,
architecture: process.arch,
supervisor: 'detached-on-demand',
lifecycle: status
})}\n`)
}
async function runLifecycle(
argv: readonly string[],
dependencies: AgentCliDependencies,
io: CliIo
): Promise<void> {
const [action, ...optionArguments] = argv
if (action === undefined || !LIFECYCLE_ACTIONS.has(action)) {
throw new Error('Invalid fixed lifecycle action')
}
const options = parseOptions(optionArguments, ['installation-id'])
requireOptions(options, ['installation-id'])
const installationId = validateInstallationId(
options['installation-id']!
)
const paths = resolveInstallationPaths(installationId, dependencies)
const { verified, registry } = await verifyManagedInstallation(
installationId,
paths,
dependencies
)
const lifecycle = createDetachedLifecycle(
installationId,
paths,
dependencies
)
switch (action) {
case 'bootstrap': {
registry.stageCandidate(verified)
prepareManagedSocketDirectory(paths)
const status = await lifecycle.bootstrap()
io.output.write(`${JSON.stringify(status)}\n`)
break
}
case 'status': {
registry.assertVerifiedRole(verified, ['current', 'candidate'])
io.output.write(`${JSON.stringify(await lifecycle.status())}\n`)
break
}
case 'health': {
registry.assertVerifiedRole(verified, ['current', 'candidate'])
const status = await lifecycle.health()
if (
registry.snapshot().candidate?.installationId ===
installationId
) {
registry.promoteCandidate(installationId)
}
io.output.write(`${JSON.stringify(status)}\n`)
break
}
case 'stop': {
registry.assertVerifiedRole(verified, ['current', 'candidate'])
io.output.write(`${JSON.stringify(await lifecycle.stop())}\n`)
break
}
case 'retire': {
registry.assertVerifiedRole(verified, ['current', 'candidate'])
io.output.write(`${JSON.stringify(await lifecycle.retire())}\n`)
break
}
}
}
function createDetachedLifecycle(
installationId: string,
paths: ManagedInstallationPaths,
dependencies: AgentCliDependencies
): DetachedAgentLifecycle {
const options: DetachedAgentLifecycleOptions = {
installationId,
executablePath: paths.executablePath,
stateDirectory: paths.stateDirectory,
socketPath: paths.socketPath,
verifyInstallation: async () => {
const { verified, registry } = await verifyManagedInstallation(
installationId,
paths,
dependencies
)
registry.assertVerifiedRole(verified, ['current', 'candidate'])
return verified
},
probeEndpoint: async (verified) =>
await probeAuthenticatedEndpoint({
socketPath: paths.socketPath,
secret: readPrivateSecret(
resolve(paths.stateDirectory, 'installation-identity')
),
installationId,
binaryDigest: verified.binaryDigest,
protocol: verified.manifest.protocol
})
}
return (
dependencies.createLifecycle?.(options) ??
new DetachedAgentLifecycle(options)
)
}
function prepareManagedSocketDirectory(
paths: ManagedInstallationPaths
): void {
if (process.platform !== 'linux') {
ensurePrivateDirectory(dirname(paths.socketPath))
return
}
const expected = derivePrivateTemporaryRoot()
if (dirname(paths.socketPath) === expected.rootPath) {
ensurePrivateTemporaryRoot(expected)
return
}
ensurePrivateDirectory(dirname(paths.socketPath))
}
async function verifyManagedInstallation(
installationId: string,
paths: ManagedInstallationPaths,
dependencies: AgentCliDependencies
): Promise<{
verified: VerifiedInstalledAgentBundle
registry: InstallationRegistry
releaseKeyRegistry: AgentReleaseKeyRegistry
}> {
const architecture = currentAgentArchitecture()
const agentRoot = dirname(dirname(dirname(paths.executablePath)))
const releaseKeyRegistry =
dependencies.releaseKeyRegistry ??
await readManagedAgentReleaseKeyRegistry(
resolve(agentRoot, 'release-keys.json')
)
const registry =
dependencies.installationRegistry ??
new InstallationRegistry({
storagePath: resolve(agentRoot, 'registry.json')
})
const verified = await (
dependencies.verifyInstallation ?? verifyInstalledAgentBundle
)(dirname(paths.executablePath), {
installationId,
architecture,
releaseKeyRegistry
})
return { verified, registry, releaseKeyRegistry }
}
export function deriveManagedInstallationPaths(
installationIdInput: string,
options: {
platform?: NodeJS.Platform
homeDirectory?: string
uid?: number
} = {}
): ManagedInstallationPaths {
const installationId = validateInstallationId(installationIdInput)
const platform = options.platform ?? process.platform
const uid = options.uid ?? process.getuid?.()
if (
platform !== 'linux' ||
uid === undefined ||
!Number.isSafeInteger(uid) ||
uid < 0
) {
throw new AgentUnsupportedError(
'Managed Agent paths require a current Linux UID',
'platform-incompatible'
)
}
const homeDirectory = posix.resolve(
options.homeDirectory ?? homedir()
)
const homeHash = createHash('sha256')
.update(`${uid}\0${homeDirectory}`, 'utf8')
.digest('hex')
.slice(0, 20)
const socketRoot = posix.join(
'/tmp',
`goodbuddy-${uid}-${homeHash}`
)
const socketName = createHash('sha256')
.update(`goodbuddy-agent-socket-v1\0${installationId}`, 'utf8')
.digest('hex')
.slice(0, 32)
return {
executablePath: posix.join(
homeDirectory,
'.goodbuddy',
'agent',
'installations',
installationId,
'goodbuddy-agent'
),
stateDirectory: posix.join(
homeDirectory,
'.goodbuddy',
'state',
installationId
),
socketPath: posix.join(socketRoot, `${socketName}.sock`)
}
}
export function deriveManagedRuntimePaths(
installationIdInput: string,
runtimeIdInput: string,
bundleDigestInput: string,
options: {
homeDirectory?: string
} = {}
): ManagedRuntimePaths {
validateInstallationId(installationIdInput)
const runtimeId = validateRuntimeId(runtimeIdInput)
const bundleDigest = validateBundleDigest(bundleDigestInput)
const runtimeRoot = posix.join(
posix.resolve(options.homeDirectory ?? homedir()),
'.goodbuddy',
'runtimes'
)
return {
runtimeRoot,
bundleDirectory: posix.join(
runtimeRoot,
runtimeId,
bundleDigest.slice('sha256:'.length)
),
releaseKeyRegistryPath: posix.join(
runtimeRoot,
'release-keys.json'
),
runtimeLockPath: posix.join(
runtimeRoot,
'remote-runtime-lock.json'
),
registryPath: posix.join(runtimeRoot, 'registry.json')
}
}
function resolveInstallationPaths(
installationId: string,
dependencies: AgentCliDependencies
): ManagedInstallationPaths {
validateInstallationId(installationId)
const paths =
dependencies.installationPaths?.(installationId) ??
deriveManagedInstallationPaths(installationId)
return {
executablePath: resolve(paths.executablePath),
stateDirectory: resolve(paths.stateDirectory),
socketPath: resolve(paths.socketPath)
}
}
function resolveRuntimePaths(
installationId: string,
runtimeId: 'opencode',
bundleDigest: string,
dependencies: AgentCliDependencies
): ManagedRuntimePaths {
const paths =
dependencies.runtimePaths?.(
installationId,
runtimeId,
bundleDigest
) ??
deriveManagedRuntimePaths(
installationId,
runtimeId,
bundleDigest
)
const runtimeRoot = resolve(paths.runtimeRoot)
const expected = {
runtimeRoot,
bundleDirectory: resolve(
runtimeRoot,
runtimeId,
bundleDigest.slice('sha256:'.length)
),
releaseKeyRegistryPath: resolve(
runtimeRoot,
'release-keys.json'
),
runtimeLockPath: resolve(
runtimeRoot,
'remote-runtime-lock.json'
),
registryPath: resolve(runtimeRoot, 'registry.json')
}
if (
Object.entries(expected).some(
([key, value]) =>
resolve(paths[key as keyof ManagedRuntimePaths]) !== value
)
) {
throw new Error('Runtime activation paths are not GoodBuddy-managed')
}
return expected
}
function validateInstallationId(value: string): string {
if (
value.length > 128 ||
!/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u.test(value)
) {
throw new Error('Invalid Agent installation ID')
}
return value
}
function validateRuntimeId(value: string): 'opencode' {
if (value !== 'opencode') {
throw new Error('Invalid Runtime ID')
}
return value
}
function validateBundleDigest(value: string): string {
if (!/^sha256:[a-f0-9]{64}$/u.test(value)) {
throw new Error('Invalid Runtime bundle digest')
}
return value
}
function validateRuntimeArchitecture(
value: string
): AgentArchitecture {
if (value !== 'x64' && value !== 'arm64') {
throw new Error('Invalid Runtime architecture')
}
return value
}
function assertActivatedRuntimeIdentity(
verified: VerifiedRuntimeBundle,
runtimeId: 'opencode',
bundleDigest: string,
architecture: AgentArchitecture
): void {
if (
verified.manifest.runtimeId !== runtimeId ||
verified.manifest.provider !== 'opencode' ||
verified.manifest.bundleDigest !== bundleDigest ||
verified.manifest.architecture !== architecture
) {
throw new Error(
'Verified Runtime does not match the activation request'
)
}
}
function currentAgentArchitecture(): AgentArchitecture {
if (process.arch === 'x64' || process.arch === 'arm64') {
return process.arch
}
throw new AgentUnsupportedError(
`Managed Agent architecture is unsupported: ${process.arch}`,
'platform-incompatible'
)
}
function parseOptions(
argv: readonly string[],
allowed: readonly string[]
): Record<string, string> {
const allowedSet = new Set(allowed)
const output: Record<string, string> = {}
for (let index = 0; index < argv.length; index += 2) {
const flag = argv[index]
const value = argv[index + 1]
if (
flag === undefined ||
!flag.startsWith('--') ||
!allowedSet.has(flag.slice(2)) ||
value === undefined ||
value.startsWith('--')
) {
throw new Error('Invalid or unsupported command option')
}
const key = flag.slice(2)
if (output[key] !== undefined) {
throw new Error('Duplicate command option')
}
output[key] = value
}
return output
}
function requireOptions(
options: Readonly<Record<string, string>>,
names: readonly string[]
): void {
for (const name of names) {
if (options[name] === undefined) {
throw new Error(`Missing required --${name}`)
}
}
}
async function waitForShutdownSignal(): Promise<void> {
await new Promise<void>((resolveSignal) => {
const done = (): void => {
process.off('SIGINT', done)
process.off('SIGTERM', done)
resolveSignal()
}
process.once('SIGINT', done)
process.once('SIGTERM', done)
})
}
+371
View File
@@ -0,0 +1,371 @@
import { randomBytes } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { agentIdentifierSchema } from '../shared/agent-protocol/contracts'
import {
assertPrivateRegularFile,
writePrivateFileAtomic
} from './managed-paths'
export type ControllerLease = {
controllerId: string
connectionId: string
generation: number
leaseExpiresAt: number
capabilityGeneration: number
ownedObjects: Readonly<Record<string, readonly string[]>>
}
type MutableController = {
generation: number
leaseExpiresAt: number
capabilityGeneration: number
connectionId?: string
attached: boolean
previousGeneration?: number
previousConnectionId?: string
ownedObjects: Map<string, Set<string>>
}
export class ControllerRegistryError extends Error {
constructor(
message: string,
readonly code:
| 'stale-generation'
| 'not-owner'
| 'lease-expired'
| 'not-attached'
| 'takeover-mismatch'
) {
super(message)
this.name = 'ControllerRegistryError'
}
}
export class ControllerRegistry {
readonly #controllers = new Map<string, MutableController>()
readonly #now: () => number
readonly #leaseMs: number
readonly #storagePath?: string
constructor(options: {
now?: () => number
leaseMs?: number
storagePath?: string
} = {}) {
this.#now = options.now ?? Date.now
this.#leaseMs = options.leaseMs ?? 24 * 60 * 60 * 1000
this.#storagePath = options.storagePath
if (!Number.isSafeInteger(this.#leaseMs) || this.#leaseMs < 1) {
throw new RangeError('Invalid controller lease duration')
}
this.#load()
}
attach(controllerIdInput: string): ControllerLease {
const controllerId = agentIdentifierSchema.parse(controllerIdInput)
let record = this.#controllers.get(controllerId)
if (record === undefined) {
record = {
generation: 0,
leaseExpiresAt: 0,
capabilityGeneration: 1,
attached: false,
ownedObjects: new Map()
}
this.#controllers.set(controllerId, record)
}
const previousGeneration =
record.generation === 0 ? undefined : record.generation
const previousConnectionId = record.connectionId
record.generation += 1
if (record.generation > 0xffff_ffff) {
throw new RangeError('Controller generation exhausted')
}
record.connectionId = `conn-${randomBytes(18).toString('base64url')}`
record.attached = true
record.previousGeneration = previousGeneration
record.previousConnectionId = previousConnectionId
record.leaseExpiresAt = this.#now() + this.#leaseMs
this.#persist()
return snapshot(controllerId, record)
}
resume(
controllerId: string,
generation: number,
capabilityGeneration?: number
): ControllerLease {
const record = this.#assertCurrent(controllerId, generation)
if (
capabilityGeneration !== undefined &&
capabilityGeneration !== record.capabilityGeneration
) {
throw new ControllerRegistryError(
'Controller capability generation is stale',
'stale-generation'
)
}
record.leaseExpiresAt = this.#now() + this.#leaseMs
this.#persist()
return snapshot(controllerId, record)
}
takeover(
controllerId: string,
generation: number,
input: {
previousGeneration: number
previousConnectionId: string
capabilityGeneration: number
}
): ControllerLease {
const record = this.#assertCurrent(controllerId, generation)
if (
generation <= input.previousGeneration ||
record.previousGeneration !== input.previousGeneration ||
record.previousConnectionId !== input.previousConnectionId ||
record.capabilityGeneration !== input.capabilityGeneration
) {
throw new ControllerRegistryError(
'Controller takeover does not exactly match the recorded predecessor',
'takeover-mismatch'
)
}
record.leaseExpiresAt = this.#now() + this.#leaseMs
this.#persist()
return snapshot(controllerId, record)
}
assertCurrent(controllerId: string, generation: number): ControllerLease {
return snapshot(
controllerId,
this.#assertCurrent(controllerId, generation)
)
}
claim(
controllerId: string,
generation: number,
kind: string,
objectIdInput: string
): void {
const objectId = agentIdentifierSchema.parse(objectIdInput)
const record = this.#assertCurrent(controllerId, generation)
const objects = record.ownedObjects.get(kind) ?? new Set<string>()
objects.add(objectId)
record.ownedObjects.set(kind, objects)
this.#persist()
}
assertOwner(
controllerId: string,
generation: number,
kind: string,
objectId: string
): void {
const record = this.#assertCurrent(controllerId, generation)
if (!record.ownedObjects.get(kind)?.has(objectId)) {
throw new ControllerRegistryError(
'Object belongs to another controller',
'not-owner'
)
}
}
release(
controllerId: string,
generation: number,
kind: string,
objectId: string
): void {
this.assertOwner(controllerId, generation, kind, objectId)
this.#controllers.get(controllerId)?.ownedObjects.get(kind)?.delete(objectId)
this.#persist()
}
revokeCapabilities(controllerId: string): number {
const record = this.#controllers.get(controllerId)
if (record === undefined) {
throw new ControllerRegistryError(
'Controller has not attached',
'not-attached'
)
}
record.capabilityGeneration += 1
this.#persist()
return record.capabilityGeneration
}
disconnect(controllerId: string, generation: number): void {
const record = this.#assertCurrent(controllerId, generation)
record.attached = false
this.#persist()
}
#assertCurrent(controllerIdInput: string, generation: number): MutableController {
const controllerId = agentIdentifierSchema.parse(controllerIdInput)
const record = this.#controllers.get(controllerId)
if (record === undefined) {
throw new ControllerRegistryError(
'Controller has not attached',
'not-attached'
)
}
if (record.generation !== generation) {
throw new ControllerRegistryError(
'Connection generation is stale',
'stale-generation'
)
}
if (!record.attached) {
throw new ControllerRegistryError(
'Controller is not currently attached',
'not-attached'
)
}
if (record.leaseExpiresAt < this.#now()) {
throw new ControllerRegistryError(
'Controller lease has expired',
'lease-expired'
)
}
return record
}
#load(): void {
if (this.#storagePath === undefined) {
return
}
let parsed: unknown
try {
assertPrivateRegularFile(this.#storagePath)
parsed = JSON.parse(readFileSync(this.#storagePath, 'utf8'))
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
) {
this.#persist()
return
}
throw error
}
if (!Array.isArray(parsed)) {
throw new Error('Controller registry is corrupt')
}
for (const item of parsed) {
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
throw new Error('Controller registry is corrupt')
}
const value = item as Record<string, unknown>
const controllerId = agentIdentifierSchema.parse(value.controllerId)
if (
!Number.isInteger(value.generation) ||
(value.generation as number) < 0 ||
(value.generation as number) > 0xffff_ffff ||
!Number.isSafeInteger(value.leaseExpiresAt) ||
!Number.isInteger(value.capabilityGeneration) ||
(value.capabilityGeneration as number) < 1 ||
value.ownedObjects === null ||
typeof value.ownedObjects !== 'object' ||
Array.isArray(value.ownedObjects)
) {
throw new Error('Controller registry is corrupt')
}
const ownedObjects = new Map<string, Set<string>>()
for (const [kind, objectIds] of Object.entries(
value.ownedObjects as Record<string, unknown>
)) {
if (!Array.isArray(objectIds)) {
throw new Error('Controller registry is corrupt')
}
ownedObjects.set(
kind,
new Set(objectIds.map((objectId) => agentIdentifierSchema.parse(objectId)))
)
}
this.#controllers.set(controllerId, {
generation: value.generation as number,
leaseExpiresAt: value.leaseExpiresAt as number,
capabilityGeneration: value.capabilityGeneration as number,
connectionId:
value.connectionId === undefined
? undefined
: agentIdentifierSchema.parse(value.connectionId),
attached: false,
previousGeneration:
value.previousGeneration === undefined
? undefined
: generation(value.previousGeneration),
previousConnectionId:
value.previousConnectionId === undefined
? undefined
: agentIdentifierSchema.parse(value.previousConnectionId),
ownedObjects
})
}
}
#persist(): void {
if (this.#storagePath === undefined) {
return
}
writePrivateFileAtomic(
this.#storagePath,
JSON.stringify(
[...this.#controllers].map(([controllerId, record]) => ({
controllerId,
generation: record.generation,
leaseExpiresAt: record.leaseExpiresAt,
capabilityGeneration: record.capabilityGeneration,
connectionId: record.connectionId,
previousGeneration: record.previousGeneration,
previousConnectionId: record.previousConnectionId,
ownedObjects: Object.fromEntries(
[...record.ownedObjects].map(([kind, objects]) => [
kind,
[...objects]
])
)
}))
)
)
}
}
function snapshot(
controllerId: string,
record: MutableController
): ControllerLease {
if (record.connectionId === undefined || !record.attached) {
throw new ControllerRegistryError(
'Controller is not currently attached',
'not-attached'
)
}
return {
controllerId,
connectionId: record.connectionId,
generation: record.generation,
leaseExpiresAt: record.leaseExpiresAt,
capabilityGeneration: record.capabilityGeneration,
ownedObjects: Object.fromEntries(
[...record.ownedObjects].map(([kind, objects]) => [
kind,
[...objects]
])
)
}
}
function generation(value: unknown): number {
if (
!Number.isInteger(value) ||
(value as number) < 1 ||
(value as number) > 0xffff_ffff
) {
throw new Error('Controller registry is corrupt')
}
return value as number
}
+217
View File
@@ -0,0 +1,217 @@
import { chmodSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_PROTOCOL_VERSION,
type AgentFrame,
daemonStatusSchema
} from '../shared/agent-protocol'
import { AgentProtocolServer } from './protocol-server'
import { AgentDaemon } from './daemon'
import { EventJournal } from './event-journal'
import { PrivateEndpoint } from './private-endpoint'
import { WorkspaceRegistry } from './workspace-registry'
const digest = `sha256:${'a'.repeat(64)}`
const temporaryPaths: string[] = []
const nativePlatform = process.platform
let originalPlatform: PropertyDescriptor | undefined
beforeEach(() => {
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
let firstRead = true
Object.defineProperty(process, 'platform', {
configurable: true,
enumerable: true,
get() {
if (nativePlatform === 'linux') {
return nativePlatform
}
if (firstRead) {
firstRead = false
return 'linux'
}
return nativePlatform
}
})
})
afterEach(() => {
vi.restoreAllMocks()
if (originalPlatform !== undefined) {
Object.defineProperty(process, 'platform', originalPlatform)
}
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('Agent daemon lifecycle', () => {
it('returns the strict manifest-bound Linux daemon status', () => {
const daemon = daemonForTest()
const status = daemonStatusSchema.parse(daemon.status())
expect(status).toEqual({
state: 'offline',
installationId: 'installation-a',
binaryDigest: digest,
daemonBootId: expect.stringMatching(/^boot-/u),
agentVersion: '0.11.0',
protocol: AGENT_PROTOCOL_VERSION,
platform: 'linux',
architecture: process.arch === 'arm64' ? 'arm64' : 'x64',
supervisor: 'detached-on-demand',
remoteUserIdentity:
process.getuid === undefined
? 'uid:unavailable'
: `uid:${process.getuid()}`,
draining: false
})
expect(Object.keys(status).sort()).toEqual([
'agentVersion',
'architecture',
'binaryDigest',
'daemonBootId',
'draining',
'installationId',
'platform',
'protocol',
'remoteUserIdentity',
'state',
'supervisor'
])
})
it('does not schedule unused generic journal maintenance', async () => {
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue()
vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue()
const daemon = daemonForTest()
await daemon.start()
expect(setIntervalSpy).not.toHaveBeenCalled()
await daemon.stop()
})
it('retires workspace handles before closing journals', async () => {
vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue()
vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue()
const closeWorkspaces = vi.spyOn(
WorkspaceRegistry.prototype,
'closeAll'
)
const closeEvents = vi.spyOn(EventJournal.prototype, 'close')
const daemon = daemonForTest()
await daemon.start()
await daemon.stop()
expect(closeWorkspaces).toHaveBeenCalledOnce()
expect(closeEvents).toHaveBeenCalledOnce()
expect(closeWorkspaces.mock.invocationCallOrder[0]).toBeLessThan(
closeEvents.mock.invocationCallOrder[0]!
)
})
it('keeps injected Runtime methods in their protocol namespace', async () => {
vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue()
vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue()
const daemon = daemonForTest({
runtimeProtocol: {
runtimes: () => [],
methods: {
'agent/status': () => null
},
onAcpFrame: async () => {}
}
})
await expect(daemon.start()).rejects.toThrow(
/runtime\/ namespace/iu
)
expect(daemon.status().state).toBe('offline')
})
it('routes factory Runtime output and disposes Runtime before journals', async () => {
vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue()
vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue()
const sendAcpFrame = vi
.spyOn(AgentProtocolServer.prototype, 'sendAcpFrame')
.mockResolvedValue()
const dispose = vi.fn(async () => undefined)
let outputSink:
| Parameters<NonNullable<
ConstructorParameters<typeof AgentDaemon>[0]['runtimeFactory']
>>[0]['outputSink']
| undefined
const runtimeFactory = vi.fn(async (context) => {
outputSink = context.outputSink
return {
runtimes: () => [],
methods: Object.fromEntries(
[
'openAcpChannel',
'closeAcpChannel',
'preparePrompt',
'getAcpCursors',
'escalateCancellation',
'reconcilePrompt'
].map((method) => [`runtime/${method}`, () => null])
),
onAcpFrame: async () => {},
dispose
}
})
const daemon = daemonForTest({ runtimeFactory })
await daemon.start()
const frame = {
header: {
protocolMajor: 1,
protocolMinor: 0,
connectionId: 'connection-1',
generation: 1,
channelId: 'binding-1',
channelEpoch: '1',
direction: 'agent-to-main',
sequence: '1',
kind: 'acp',
payloadLength: 2
},
payload: Buffer.from('ok')
} satisfies AgentFrame
await outputSink!(frame, {
bindingId: 'binding-1',
controllerId: 'controller-1',
controllerGeneration: 1
})
expect(sendAcpFrame).toHaveBeenCalledWith(frame)
await daemon.stop()
expect(runtimeFactory).toHaveBeenCalledOnce()
expect(dispose).toHaveBeenCalledOnce()
})
})
function daemonForTest(
overrides: Partial<ConstructorParameters<typeof AgentDaemon>[0]> = {}
): AgentDaemon {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-daemon-'))
chmodSync(root, 0o700)
temporaryPaths.push(root)
return new AgentDaemon({
installationId: 'installation-a',
binaryDigest: digest,
agentVersion: '0.11.0',
protocol: AGENT_PROTOCOL_VERSION,
stateDirectory: resolve(root, 'state'),
socketPath: resolve(root, 'runtime', 'agent.sock'),
peerIdentityProvider: {
async getPeerIdentity() {
return { uid: 1 }
}
},
...overrides
})
}
+405
View File
@@ -0,0 +1,405 @@
import { randomBytes } from 'node:crypto'
import { dirname, resolve } from 'node:path'
import { arch } from 'node:os'
import {
daemonStatusSchema,
agentIdentifierSchema,
AGENT_PROTOCOL_FAILURE_RECORD_NAME,
agentProtocolFailureRecordSchema,
protocolVersionSchema,
sha256DigestSchema
} from '../shared/agent-protocol'
import type {
DaemonStatus,
AttachPreface,
DaemonCapabilities,
AgentFrame
} from '../shared/agent-protocol'
import { ControllerRegistry } from './controller-registry'
import { EventJournal } from './event-journal'
import { InstallationChallengeVerifier } from './installation-challenge'
import {
ensurePrivateDirectory,
ensurePrivateDirectoryTree,
readOrCreatePrivateSecret,
writePrivateFileAtomic
} from './managed-paths'
import {
PrivateEndpoint,
type UnixPeerIdentityProvider
} from './private-endpoint'
import {
AgentProtocolServer,
type ProtocolMethodHandler
} from './protocol-server'
import { AgentUnsupportedError } from './errors'
import { WorkspaceGitService } from './workspace-git-service'
import { createWorkspaceProtocolMethods } from './workspace-protocol-methods'
import { WorkspaceRegistry } from './workspace-registry'
export type AgentDaemonOptions = {
installationId: string
binaryDigest: string
agentVersion: string
protocol: AttachPreface['protocol']
stateDirectory: string
socketPath: string
peerIdentityProvider: UnixPeerIdentityProvider
now?: () => number
gitExecutable?: string
workspaceRequestTimeoutMs?: number
runtimeProtocol?: {
runtimes: () =>
| DaemonCapabilities['runtimes']
| Promise<DaemonCapabilities['runtimes']>
methods: Readonly<Record<string, ProtocolMethodHandler>>
onAcpFrame: NonNullable<ConstructorParameters<
typeof AgentProtocolServer
>[0]['onAcpFrame']>
onBlobFrame?: NonNullable<ConstructorParameters<
typeof AgentProtocolServer
>[0]['onBlobFrame']>
authorizeBlobFrame?: NonNullable<ConstructorParameters<
typeof AgentProtocolServer
>[0]['authorizeBlobFrame']>
dispose?: () => void | Promise<void>
}
runtimeFactory?: (context: {
events: EventJournal
workspaces: WorkspaceRegistry
outputSink: (
frame: AgentFrame,
context: {
bindingId: string
controllerId: string
controllerGeneration: number
}
) => void | Promise<void>
blobSink: (
frame: AgentFrame,
context: {
bindingId: string
controllerId: string
controllerGeneration: number
}
) => void | Promise<void>
}) =>
| AgentDaemonOptions['runtimeProtocol']
| undefined
| Promise<AgentDaemonOptions['runtimeProtocol'] | undefined>
reportError?: (message: string, error: unknown) => void
}
export type AgentDaemonStatus = DaemonStatus
export class AgentDaemon {
readonly #installationId: string
readonly #binaryDigest: string
readonly #agentVersion: string
readonly #protocol: AttachPreface['protocol']
readonly #architecture: 'x64' | 'arm64'
readonly #remoteUserIdentity: string
readonly #stateDirectory: string
readonly #socketPath: string
readonly #bootId = `boot-${randomBytes(18).toString('base64url')}`
readonly #peerIdentityProvider: UnixPeerIdentityProvider
readonly #now: () => number
readonly #gitExecutable?: string
readonly #workspaceRequestTimeoutMs?: number
readonly #runtimeProtocol?: AgentDaemonOptions['runtimeProtocol']
readonly #runtimeFactory?: AgentDaemonOptions['runtimeFactory']
readonly #reportError: (message: string, error: unknown) => void
#state: 'starting' | 'ready' | 'stopped' = 'stopped'
#endpoint?: PrivateEndpoint
#events?: EventJournal
#workspaces?: WorkspaceRegistry
#activeRuntimeProtocol?: AgentDaemonOptions['runtimeProtocol']
constructor(options: AgentDaemonOptions) {
this.#installationId = agentIdentifierSchema.parse(options.installationId)
this.#binaryDigest = sha256DigestSchema.parse(options.binaryDigest)
this.#agentVersion =
daemonStatusSchema.shape.agentVersion.parse(options.agentVersion)
this.#protocol = protocolVersionSchema.parse(options.protocol)
const hostArchitecture = arch()
if (hostArchitecture !== 'x64' && hostArchitecture !== 'arm64') {
throw new AgentUnsupportedError(
`GoodBuddy Agent architecture is unsupported: ${hostArchitecture}`,
'platform-incompatible'
)
}
this.#architecture = hostArchitecture
this.#remoteUserIdentity = agentIdentifierSchema.parse(
process.getuid === undefined
? 'uid:unavailable'
: `uid:${process.getuid()}`
)
this.#stateDirectory = resolve(options.stateDirectory)
this.#socketPath = resolve(options.socketPath)
this.#peerIdentityProvider = options.peerIdentityProvider
this.#now = options.now ?? Date.now
this.#gitExecutable = options.gitExecutable
this.#workspaceRequestTimeoutMs = options.workspaceRequestTimeoutMs
this.#runtimeProtocol = options.runtimeProtocol
this.#runtimeFactory = options.runtimeFactory
if (
this.#runtimeProtocol !== undefined &&
this.#runtimeFactory !== undefined
) {
throw new Error(
'Agent daemon accepts either Runtime protocol or Runtime factory'
)
}
this.#reportError =
options.reportError ??
((message, error) => {
console.error(message, error)
})
}
status(): AgentDaemonStatus {
return daemonStatusSchema.parse({
state: this.#state === 'stopped' ? 'offline' : this.#state,
installationId: this.#installationId,
binaryDigest: this.#binaryDigest,
daemonBootId: this.#bootId,
agentVersion: this.#agentVersion,
protocol: this.#protocol,
platform: 'linux',
architecture: this.#architecture,
supervisor: 'detached-on-demand',
remoteUserIdentity: this.#remoteUserIdentity,
draining: false
})
}
async start(): Promise<void> {
if (process.platform !== 'linux') {
throw new AgentUnsupportedError(
'GoodBuddy Agent daemon is certified only on Linux',
'platform-incompatible'
)
}
if (this.#state !== 'stopped') {
throw new Error('GoodBuddy Agent daemon is already started')
}
this.#state = 'starting'
try {
ensurePrivateDirectory(this.#stateDirectory)
ensurePrivateDirectoryTree(
resolve(this.#stateDirectory, 'journal'),
this.#stateDirectory
)
ensurePrivateDirectory(dirname(this.#socketPath))
const secret = readOrCreatePrivateSecret(
resolve(this.#stateDirectory, 'installation-identity')
)
const controllers = new ControllerRegistry({
now: this.#now,
storagePath: resolve(this.#stateDirectory, 'controllers.json')
})
this.#events = new EventJournal(
resolve(this.#stateDirectory, 'journal', 'events.sqlite'),
{ now: this.#now }
)
const git = new WorkspaceGitService({
...(this.#gitExecutable === undefined
? {}
: { gitExecutable: this.#gitExecutable })
})
this.#workspaces = new WorkspaceRegistry({
controllers,
now: this.#now,
storagePath: resolve(
this.#stateDirectory,
'workspaces.json'
),
inspectGit: async (access, io) => await git.inspect(access, io)
})
const workspaceMethods = createWorkspaceProtocolMethods({
workspaces: this.#workspaces,
git,
...(this.#workspaceRequestTimeoutMs === undefined
? {}
: {
requestTimeoutMs:
this.#workspaceRequestTimeoutMs
})
})
const protocolBox: {
current?: AgentProtocolServer
} = {}
this.#activeRuntimeProtocol =
this.#runtimeProtocol ??
await this.#runtimeFactory?.({
events: this.#events,
workspaces: this.#workspaces,
outputSink: async (frame) => {
if (protocolBox.current === undefined) {
throw new Error(
'Agent Runtime output sink is not ready'
)
}
await protocolBox.current.sendAcpFrame(frame)
},
blobSink: async (frame) => {
if (protocolBox.current === undefined) {
throw new Error(
'Agent Runtime blob sink is not ready'
)
}
await protocolBox.current.sendBlobFrame(frame)
}
})
const protocol = new AgentProtocolServer({
controllers,
events: this.#events,
status: () => this.status(),
...(this.#activeRuntimeProtocol === undefined
? {}
: {
runtimes: this.#activeRuntimeProtocol.runtimes,
onAcpFrame: this.#activeRuntimeProtocol.onAcpFrame,
...(this.#activeRuntimeProtocol.onBlobFrame === undefined
? {}
: {
onBlobFrame:
this.#activeRuntimeProtocol.onBlobFrame
}),
...(this.#activeRuntimeProtocol.authorizeBlobFrame === undefined
? {}
: {
authorizeBlobFrame:
this.#activeRuntimeProtocol.authorizeBlobFrame
})
}),
onProtocolFailure: ({
connectionId,
category
}) => {
try {
writePrivateFileAtomic(
resolve(
this.#stateDirectory,
AGENT_PROTOCOL_FAILURE_RECORD_NAME
),
`${JSON.stringify(
agentProtocolFailureRecordSchema.parse({
formatVersion: 1,
connectionId,
category,
createdAt: this.#now()
})
)}\n`
)
} catch (error) {
this.#reportError(
'Agent protocol failure category could not be persisted',
error
)
}
},
methods: mergeProtocolMethods(
workspaceMethods,
this.#activeRuntimeProtocol?.methods
)
})
protocolBox.current = protocol
this.#endpoint = new PrivateEndpoint({
socketPath: this.#socketPath,
peerIdentity: this.#peerIdentityProvider,
challenge: new InstallationChallengeVerifier(secret, {
now: this.#now
}),
controllers,
installationId: this.#installationId,
binaryDigest: this.#binaryDigest,
daemonBootId: this.#bootId,
protocol: this.#protocol,
onAttach: ({ socket, controller }) => {
protocol.accept(socket, controller)
}
})
await this.#endpoint.listen()
this.#state = 'ready'
} catch (error) {
await this.stop()
throw error
}
}
async stop(): Promise<void> {
const endpoint = this.#endpoint
const events = this.#events
const workspaces = this.#workspaces
const runtimeProtocol = this.#activeRuntimeProtocol
this.#endpoint = undefined
this.#events = undefined
this.#workspaces = undefined
this.#activeRuntimeProtocol = undefined
try {
if (endpoint !== undefined) {
await endpoint.close()
}
} finally {
if (runtimeProtocol?.dispose !== undefined) {
await Promise.resolve(runtimeProtocol.dispose()).catch(
(error: unknown) => {
this.#reportRuntimeShutdownError(error)
}
)
}
workspaces?.closeAll()
events?.close()
this.#state = 'stopped'
}
}
#reportRuntimeShutdownError(error: unknown): void {
try {
this.#reportError('Agent Runtime shutdown failed', error)
} catch (reportError) {
console.error(
'Agent Runtime shutdown failure reporting failed',
reportError,
error
)
}
}
}
function mergeProtocolMethods(
workspaceMethods: Readonly<Record<string, ProtocolMethodHandler>>,
runtimeMethods:
| Readonly<Record<string, ProtocolMethodHandler>>
| undefined
): Readonly<Record<string, ProtocolMethodHandler>> {
if (runtimeMethods === undefined) {
return workspaceMethods
}
for (const method of Object.keys(runtimeMethods)) {
if (!method.startsWith('runtime/')) {
throw new Error(
`Runtime protocol method must use the runtime/ namespace: ${method}`
)
}
if (workspaceMethods[method] !== undefined) {
throw new Error(
`Runtime protocol method conflicts with a Workspace method: ${method}`
)
}
}
return { ...workspaceMethods, ...runtimeMethods }
}
export class UnsupportedPeerIdentityProvider
implements UnixPeerIdentityProvider
{
async getPeerIdentity(): Promise<never> {
throw new AgentUnsupportedError(
'A certified SO_PEERCRED helper is unavailable',
'peer-identity-unavailable'
)
}
}
@@ -0,0 +1,447 @@
import {
chmodSync,
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
DetachedAgentLifecycle,
detachedAgentLifecycleRecordSchema,
type DetachedAgentLifecycleOptions,
type DetachedAgentLifecycleRecord,
type DetachedSocketInspector,
type DetachedSpawn
} from './detached-agent-lifecycle'
import {
LinuxProcessInspector,
type LinuxProcessIdentity
} from './linux-process-identity'
import { writePrivateFileAtomic } from './managed-paths'
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
const parentPid = 1101
const childPid = 2202
const temporaryPaths: string[] = []
afterEach(() => {
vi.restoreAllMocks()
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('detached Agent lifecycle', () => {
it('serializes concurrent bootstrap and records endpoint readiness', async () => {
const harness = lifecycleHarness()
harness.onSleep(async () => {
const starting = harness.readRecord()
if (starting?.daemonBootId === null) {
await harness.daemon.recordCurrentDaemonReady('daemon-boot-1')
}
})
const [first, second] = await Promise.all([
harness.lifecycle.bootstrap(),
harness.lifecycle.bootstrap()
])
expect(first.daemonBootId).toBe('daemon-boot-1')
expect(second).toEqual(first)
expect(harness.spawn).toHaveBeenCalledOnce()
expect(harness.spawn).toHaveBeenCalledWith(
harness.verified.executablePath,
['daemon', '--installation-id', 'install-1'],
{
detached: true,
shell: false,
stdio: 'ignore',
cwd: harness.verified.installationDirectory,
env: {
HOME: '/home/tester',
LANG: 'zh_CN.UTF-8',
PATH: '/usr/bin',
TMPDIR: '/tmp'
}
}
)
expect(harness.unref).toHaveBeenCalledOnce()
expect(harness.probe).toHaveBeenCalled()
})
it('creates the managed state parent before bootstrapping', async () => {
const harness = lifecycleHarness({
nestedStateDirectory: true
})
const stateRoot = resolve(harness.stateDirectory, '..')
expect(existsSync(stateRoot)).toBe(false)
await expect(harness.becomeReady()).resolves.toMatchObject({
daemonBootId: 'daemon-boot-1'
})
expect(existsSync(stateRoot)).toBe(true)
expect(existsSync(harness.stateDirectory)).toBe(true)
if (process.platform !== 'win32') {
expect(lstatSync(stateRoot).mode & 0o777).toBe(0o700)
expect(
lstatSync(harness.stateDirectory).mode & 0o777
).toBe(0o700)
}
})
it('recovers stale lifecycle state without signalling a reused PID', async () => {
const harness = lifecycleHarness()
await harness.becomeReady()
harness.signals.length = 0
harness.identities.set(
childPid,
harness.childIdentity({ starttime: '99999' })
)
harness.onSleep(async () => {
const starting = harness.readRecord()
if (starting?.daemonBootId === null) {
await harness.daemon.recordCurrentDaemonReady('daemon-after-reuse')
}
})
await expect(harness.lifecycle.status()).resolves.toMatchObject({
state: 'stale',
reason: 'process-identity-mismatch'
})
const recovered = await harness.lifecycle.bootstrap()
expect(recovered.daemonBootId).toBe('daemon-after-reuse')
expect(harness.spawn).toHaveBeenCalledTimes(2)
expect(harness.signals).toEqual([])
expect(harness.socket.removed).toBe(1)
})
it.each([
['start time', { starttime: '99999' }],
['executable', { executablePath: resolve('foreign', 'runtime') }]
])('never signals a PID with mismatched %s', async (_label, mismatch) => {
const harness = lifecycleHarness()
await harness.becomeReady()
harness.identities.set(childPid, harness.childIdentity(mismatch))
harness.signals.length = 0
await expect(harness.lifecycle.stop()).resolves.toEqual({
state: 'absent'
})
expect(harness.signals).toEqual([])
expect(harness.socket.removed).toBe(1)
})
it('reclaims a stale exclusive lock and preserves a live lock', async () => {
const harness = lifecycleHarness()
harness.writeLock(harness.childIdentity({ starttime: '1' }))
harness.onSleep(async () => {
if (harness.readRecord()?.daemonBootId === null) {
await harness.daemon.recordCurrentDaemonReady('after-stale-lock')
}
})
await expect(harness.lifecycle.bootstrap()).resolves.toMatchObject({
daemonBootId: 'after-stale-lock'
})
const live = lifecycleHarness({ readinessTimeoutMs: 100 })
live.writeLock(live.parentIdentity)
await expect(live.lifecycle.bootstrap()).rejects.toThrow(
'Timed out waiting'
)
expect(existsSync(live.lockPath)).toBe(true)
expect(live.spawn).not.toHaveBeenCalled()
})
it('rejects corrupt and permissive lifecycle state', async () => {
const corrupt = lifecycleHarness()
mkdirSync(corrupt.stateDirectory, { mode: 0o700 })
writeFileSync(corrupt.lifecyclePath, '{not-json\n', { mode: 0o600 })
await expect(corrupt.lifecycle.status()).rejects.toThrow('corrupt')
if (process.platform !== 'win32') {
const permissive = lifecycleHarness()
mkdirSync(permissive.stateDirectory, { mode: 0o700 })
writeFileSync(permissive.lifecyclePath, '{}\n', { mode: 0o644 })
await expect(permissive.lifecycle.status()).rejects.toThrow('corrupt')
}
})
it('uses TERM then KILL and cleans lifecycle/socket state', async () => {
const harness = lifecycleHarness({
readinessTimeoutMs: 100,
stopTimeoutMs: 100,
termExits: false,
killExits: true
})
await expect(harness.lifecycle.bootstrap()).rejects.toThrow(
'readiness timed out'
)
expect(harness.signals.map(({ signal }) => signal)).toEqual([
'SIGTERM',
'SIGKILL'
])
expect(existsSync(harness.lifecyclePath)).toBe(false)
expect(harness.socket.removed).toBe(1)
})
it('keeps lifecycle state when TERM and KILL cannot stop the process', async () => {
const harness = lifecycleHarness({
readinessTimeoutMs: 100,
stopTimeoutMs: 100,
termExits: false,
killExits: false
})
await expect(harness.lifecycle.bootstrap()).rejects.toThrow(
'did not stop'
)
expect(harness.signals.map(({ signal }) => signal)).toEqual([
'SIGTERM',
'SIGKILL'
])
expect(existsSync(harness.lifecyclePath)).toBe(true)
})
})
type HarnessOptions = {
readinessTimeoutMs?: number
stopTimeoutMs?: number
termExits?: boolean
killExits?: boolean
nestedStateDirectory?: boolean
}
function lifecycleHarness(options: HarnessOptions = {}) {
const root = privateTemporaryDirectory()
const installationDirectory = resolve(root, 'installation')
const stateDirectory = options.nestedStateDirectory
? resolve(root, 'state', 'install-1')
: resolve(root, 'state')
const socketPath = resolve(root, 'endpoint', 'agent.sock')
const lifecyclePath = resolve(stateDirectory, 'detached-lifecycle.json')
const lockPath = resolve(stateDirectory, 'detached-bootstrap.lock')
const verified = verifiedInstallation(installationDirectory)
const parentIdentity: LinuxProcessIdentity = {
pid: parentPid,
starttime: '101',
executablePath: resolve(root, 'host', 'node')
}
const expectedChildIdentity = (
changes: Partial<LinuxProcessIdentity> = {}
): LinuxProcessIdentity => ({
pid: childPid,
starttime: '202',
executablePath: resolve(installationDirectory, 'node'),
...changes
})
const identities = new Map<number, LinuxProcessIdentity>([
[parentPid, parentIdentity],
[childPid, expectedChildIdentity()]
])
const inspector = new LinuxProcessInspector()
vi.spyOn(inspector, 'inspect').mockImplementation((pid) =>
identities.get(pid)
)
vi.spyOn(inspector, 'matches').mockImplementation((expected) => {
const actual = identities.get(expected.pid)
return actual !== undefined && sameIdentity(actual, expected)
})
const signals: Array<{
identity: LinuxProcessIdentity
signal: NodeJS.Signals
}> = []
vi.spyOn(inspector, 'signal').mockImplementation((identity, signal) => {
const actual = identities.get(identity.pid)
if (actual === undefined || !sameIdentity(actual, identity)) {
return false
}
signals.push({ identity, signal })
if (
(signal === 'SIGTERM' && options.termExits !== false) ||
(signal === 'SIGKILL' && options.killExits !== false)
) {
identities.delete(identity.pid)
}
return true
})
const socket = new FakeSocketInspector()
const unref = vi.fn()
const spawn = vi.fn<DetachedSpawn>(() => {
identities.set(childPid, expectedChildIdentity())
return { pid: childPid, unref }
})
let time = 1_000
let sleepHook: (() => void | Promise<void>) | undefined
let hookActive = false
const sleep = async (milliseconds: number): Promise<void> => {
time += milliseconds
await Promise.resolve()
if (sleepHook !== undefined && !hookActive) {
hookActive = true
try {
await sleepHook()
} finally {
hookActive = false
}
}
}
const probe = vi.fn(async () => ({
daemonBootId:
detachedAgentLifecycleRecordSchema.parse(
JSON.parse(readFileSync(lifecyclePath, 'utf8'))
).daemonBootId ?? 'not-ready'
}))
const common: Omit<
DetachedAgentLifecycleOptions,
'currentPid' | 'spawnDetached'
> = {
installationId: 'install-1',
executablePath: verified.executablePath,
stateDirectory,
socketPath,
verifyInstallation: async () => verified,
processInspector: inspector,
socketInspector: socket,
now: () => time,
sleep,
readinessTimeoutMs: options.readinessTimeoutMs ?? 500,
stopTimeoutMs: options.stopTimeoutMs ?? 100,
environment: {
HOME: '/home/tester',
LANG: 'zh_CN.UTF-8',
PATH: '/usr/bin',
TMPDIR: '/tmp',
NODE_OPTIONS: '--inspect',
GOODBUDDY_SECRET: 'must-not-leak'
}
}
const lifecycle = new DetachedAgentLifecycle({
...common,
currentPid: parentPid,
spawnDetached: spawn,
probeEndpoint: probe
})
const daemon = new DetachedAgentLifecycle({
...common,
currentPid: childPid,
spawnDetached: () => {
throw new Error('Daemon must not spawn another daemon')
}
})
const readRecord = (): DetachedAgentLifecycleRecord | undefined => {
if (!existsSync(lifecyclePath)) {
return undefined
}
return detachedAgentLifecycleRecordSchema.parse(
JSON.parse(readFileSync(lifecyclePath, 'utf8'))
)
}
return {
lifecycle,
daemon,
verified,
stateDirectory,
lifecyclePath,
lockPath,
identities,
parentIdentity,
childIdentity: expectedChildIdentity,
socket,
spawn,
unref,
probe,
signals,
readRecord,
onSleep(hook: () => void | Promise<void>) {
sleepHook = hook
},
writeLock(processIdentity: LinuxProcessIdentity) {
mkdirSync(stateDirectory, { mode: 0o700, recursive: true })
writePrivateFileAtomic(
lockPath,
`${JSON.stringify({
formatVersion: 1,
installationId: 'install-1',
process: processIdentity
})}\n`
)
},
async becomeReady(): Promise<DetachedAgentLifecycleRecord> {
let completed = false
sleepHook = async () => {
if (readRecord()?.daemonBootId === null && !completed) {
completed = true
await daemon.recordCurrentDaemonReady('daemon-boot-1')
}
}
return await lifecycle.bootstrap()
}
}
}
class FakeSocketInspector implements DetachedSocketInspector {
removed = 0
removeStale(): boolean {
this.removed += 1
return true
}
}
function sameIdentity(
left: LinuxProcessIdentity,
right: LinuxProcessIdentity
): boolean {
return (
left.pid === right.pid &&
left.starttime === right.starttime &&
left.executablePath === right.executablePath
)
}
function privateTemporaryDirectory(): string {
const path = mkdtempSync(resolve(tmpdir(), 'goodbuddy-lifecycle-'))
temporaryPaths.push(path)
if (process.platform !== 'win32') {
chmodSync(path, 0o700)
}
return resolve(path)
}
function verifiedInstallation(
installationDirectory: string
): VerifiedInstalledAgentBundle {
const digest = 'a'.repeat(64)
return {
installationId: 'install-1',
installationDirectory,
executablePath: resolve(installationDirectory, 'goodbuddy-agent'),
manifestSha256: digest,
binaryDigest: `sha256:${digest}`,
manifest: {
formatVersion: 1,
product: 'GoodBuddy',
agentVersion: '0.11.0',
platform: 'linux',
arch: 'x64',
protocol: { major: 1, minor: 0 },
signingKeyId: 'test-key',
entrypoint: {
path: 'goodbuddy-agent',
runtimePath: 'node',
scriptPath: 'lib/agent.cjs'
},
files: [],
licenses: []
}
}
}
@@ -0,0 +1,706 @@
import { spawn } from 'node:child_process'
import {
closeSync,
constants,
fchmodSync,
lstatSync,
openSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { dirname, resolve } from 'node:path'
import { z } from 'zod'
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
import {
LinuxProcessInspector,
type LinuxProcessIdentity
} from './linux-process-identity'
import {
assertAbsoluteManagedPath,
assertPrivateRegularFile,
ensurePrivateDirectory,
readPrivateFile,
writePrivateFileAtomic
} from './managed-paths'
import type { AttachWelcome } from '../shared/agent-protocol'
const DEFAULT_READINESS_TIMEOUT_MS = 15_000
const DEFAULT_STOP_TIMEOUT_MS = 5_000
const POLL_INTERVAL_MS = 50
const processIdentitySchema = z
.object({
pid: z.number().int().positive().safe(),
starttime: z.string().regex(/^(?:0|[1-9][0-9]*)$/u),
executablePath: z.string().min(1)
})
.strict()
const bootstrapLockRecordSchema = z
.object({
formatVersion: z.literal(1),
installationId: z.string().min(1).max(128),
process: processIdentitySchema
})
.strict()
export const detachedAgentLifecycleRecordSchema = z
.object({
formatVersion: z.literal(1),
installationId: z.string().min(1).max(128),
binaryDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
protocol: z
.object({
major: z.number().int().min(0).max(65_535),
minor: z.number().int().min(0).max(65_535)
})
.strict(),
process: processIdentitySchema,
daemonBootId: z.string().min(1).max(128).nullable(),
createdAt: z.number().int().nonnegative().safe()
})
.strict()
export type DetachedAgentLifecycleRecord = z.infer<
typeof detachedAgentLifecycleRecordSchema
>
export type DetachedLifecycleStatus =
| { state: 'absent' }
| {
state: 'starting' | 'ready'
record: DetachedAgentLifecycleRecord
}
| {
state: 'stale'
reason: string
record: DetachedAgentLifecycleRecord
}
export type DetachedSpawn = (
executable: string,
argv: readonly string[],
options: {
env: Readonly<NodeJS.ProcessEnv>
detached: true
shell: false
stdio: 'ignore'
cwd: string
}
) => {
pid?: number
unref(): void
}
export interface DetachedSocketInspector {
removeStale(path: string): boolean
}
export type DetachedAgentLifecycleOptions = {
installationId: string
executablePath: string
stateDirectory: string
socketPath: string
verifyInstallation: () => Promise<VerifiedInstalledAgentBundle>
processInspector?: LinuxProcessInspector
socketInspector?: DetachedSocketInspector
spawnDetached?: DetachedSpawn
currentPid?: number
now?: () => number
sleep?: (milliseconds: number) => Promise<void>
readinessTimeoutMs?: number
stopTimeoutMs?: number
environment?: Readonly<NodeJS.ProcessEnv>
probeEndpoint?: (
verified: VerifiedInstalledAgentBundle
) => Promise<Pick<AttachWelcome, 'daemonBootId'>>
retireLegacyInstallation?: (
verified: VerifiedInstalledAgentBundle
) => void | Promise<void>
}
export class DetachedAgentLifecycle {
readonly #installationId: string
readonly #executablePath: string
readonly #stateDirectory: string
readonly #socketPath: string
readonly #lifecyclePath: string
readonly #lockPath: string
readonly #verifyInstallation: () => Promise<VerifiedInstalledAgentBundle>
readonly #processInspector: LinuxProcessInspector
readonly #socketInspector: DetachedSocketInspector
readonly #spawnDetached: DetachedSpawn
readonly #currentPid: number
readonly #now: () => number
readonly #sleep: (milliseconds: number) => Promise<void>
readonly #readinessTimeoutMs: number
readonly #stopTimeoutMs: number
readonly #environment: Readonly<NodeJS.ProcessEnv>
readonly #probeEndpoint?: DetachedAgentLifecycleOptions['probeEndpoint']
readonly #retireLegacyInstallation?: (
verified: VerifiedInstalledAgentBundle
) => void | Promise<void>
constructor(options: DetachedAgentLifecycleOptions) {
this.#installationId = validateInstallationId(options.installationId)
this.#executablePath = assertAbsoluteManagedPath(resolve(options.executablePath))
this.#stateDirectory = assertAbsoluteManagedPath(resolve(options.stateDirectory))
this.#socketPath = assertAbsoluteManagedPath(resolve(options.socketPath))
this.#lifecyclePath = resolve(this.#stateDirectory, 'detached-lifecycle.json')
this.#lockPath = resolve(this.#stateDirectory, 'detached-bootstrap.lock')
this.#verifyInstallation = options.verifyInstallation
this.#processInspector =
options.processInspector ?? new LinuxProcessInspector()
this.#socketInspector =
options.socketInspector ?? new FileSystemSocketInspector()
this.#spawnDetached = options.spawnDetached ?? spawnDetachedProcess
this.#currentPid = positivePid(options.currentPid ?? process.pid)
this.#now = options.now ?? Date.now
this.#sleep =
options.sleep ??
(async (milliseconds) =>
await new Promise<void>((resolveSleep) =>
setTimeout(resolveSleep, milliseconds)
))
this.#readinessTimeoutMs = boundedTimeout(
options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS
)
this.#stopTimeoutMs = boundedTimeout(
options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS
)
this.#environment = options.environment ?? process.env
this.#probeEndpoint = options.probeEndpoint
this.#retireLegacyInstallation = options.retireLegacyInstallation
}
async status(): Promise<DetachedLifecycleStatus> {
return this.#statusAfterVerification(await this.#verifyAndBind())
}
async health(): Promise<DetachedAgentLifecycleRecord> {
const verified = await this.#verifyAndBind()
const status = this.#statusAfterVerification(verified)
if (status.state !== 'ready') {
throw new Error(`Detached Agent is not healthy: ${status.state}`)
}
await this.#assertEndpointReady(status.record, verified)
return status.record
}
async bootstrap(): Promise<DetachedAgentLifecycleRecord> {
const verified = await this.#verifyAndBind()
ensurePrivateDirectory(dirname(this.#stateDirectory))
ensurePrivateDirectory(this.#stateDirectory)
ensurePrivateDirectory(dirname(this.#socketPath))
const releaseLock = await this.#acquireBootstrapLock()
try {
await this.#retireLegacyInstallation?.(verified)
const existing = this.#statusAfterVerification(verified)
if (existing.state === 'ready') {
try {
await this.#assertEndpointReady(existing.record, verified)
return existing.record
} catch {
await this.#retireLiveRecord(existing.record)
}
} else if (existing.state === 'starting') {
const ready = await this.#waitForReady(verified)
if (ready !== undefined) {
return ready
}
await this.#retireLiveRecord(existing.record)
} else if (existing.state === 'stale') {
this.#cleanupRecord(existing.record)
}
const launchArgv = ['daemon', '--installation-id', this.#installationId]
const runtimePath = expectedRuntimePath(verified)
const child = this.#spawnDetached(this.#executablePath, launchArgv, {
env: detachedEnvironment(this.#environment),
detached: true,
shell: false,
stdio: 'ignore',
cwd: dirname(this.#executablePath)
})
if (child.pid === undefined) {
throw new Error('Detached Agent spawn did not return a process ID')
}
child.unref()
const processIdentity = await this.#waitForProcessIdentity(
child.pid,
runtimePath
)
const starting = detachedAgentLifecycleRecordSchema.parse({
formatVersion: 1,
installationId: this.#installationId,
binaryDigest: verified.binaryDigest,
protocol: verified.manifest.protocol,
process: processIdentity,
daemonBootId: null,
createdAt: this.#now()
})
this.#writeRecord(starting)
const ready = await this.#waitForReady(verified)
if (ready === undefined) {
await this.#retireLiveRecord(starting)
throw new Error('Detached Agent readiness timed out')
}
return ready
} finally {
releaseLock()
}
}
async recordCurrentDaemonReady(
daemonBootId: string
): Promise<DetachedAgentLifecycleRecord> {
const verified = await this.#verifyAndBind()
const current = this.#processInspector.inspect(this.#currentPid)
if (current === undefined) {
throw new Error('Current detached Agent process identity is unavailable')
}
const deadline = this.#now() + this.#readinessTimeoutMs
while (this.#now() <= deadline) {
const record = this.#readRecord()
if (
record !== undefined &&
record.daemonBootId === null &&
sameProcessIdentity(record.process, current)
) {
this.#assertRecordBinding(record, verified)
const ready = detachedAgentLifecycleRecordSchema.parse({
...record,
daemonBootId
})
this.#writeRecord(ready)
return ready
}
await this.#sleep(POLL_INTERVAL_MS)
}
throw new Error('Detached Agent bootstrap record is unavailable')
}
async stop(): Promise<DetachedLifecycleStatus> {
const verified = await this.#verifyAndBind()
ensurePrivateDirectory(dirname(this.#stateDirectory))
ensurePrivateDirectory(this.#stateDirectory)
const releaseLock = await this.#acquireBootstrapLock()
try {
const status = this.#statusAfterVerification(verified)
if (status.state === 'absent') {
return status
}
if (status.state === 'stale') {
this.#cleanupRecord(status.record)
} else {
await this.#retireLiveRecord(status.record)
}
return { state: 'absent' }
} finally {
releaseLock()
}
}
async retire(): Promise<DetachedLifecycleStatus> {
return await this.stop()
}
#statusAfterVerification(
verified: VerifiedInstalledAgentBundle
): DetachedLifecycleStatus {
const record = this.#readRecord()
if (record === undefined) {
return { state: 'absent' }
}
this.#assertRecordBinding(record, verified)
if (!this.#processInspector.matches(record.process)) {
return {
state: 'stale',
reason: 'process-identity-mismatch',
record
}
}
return {
state: record.daemonBootId === null ? 'starting' : 'ready',
record
}
}
async #verifyAndBind(): Promise<VerifiedInstalledAgentBundle> {
const verified = await this.#verifyInstallation()
if (
verified.installationId !== this.#installationId ||
resolve(verified.executablePath) !== this.#executablePath
) {
throw new Error(
'Signed Agent verification does not match the lifecycle installation'
)
}
return verified
}
#assertRecordBinding(
record: DetachedAgentLifecycleRecord,
verified: VerifiedInstalledAgentBundle
): void {
if (
record.installationId !== this.#installationId ||
record.binaryDigest !== verified.binaryDigest ||
record.protocol.major !== verified.manifest.protocol.major ||
record.protocol.minor !== verified.manifest.protocol.minor ||
record.process.executablePath !== expectedRuntimePath(verified)
) {
throw new Error(
'Detached Agent lifecycle record does not match the signed installation'
)
}
}
#readRecord(): DetachedAgentLifecycleRecord | undefined {
try {
assertPrivateRegularFile(this.#lifecyclePath)
return detachedAgentLifecycleRecordSchema.parse(
JSON.parse(readBoundedPrivateFile(this.#lifecyclePath))
)
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return undefined
}
throw new Error('Detached Agent lifecycle record is corrupt', {
cause: error
})
}
}
#writeRecord(record: DetachedAgentLifecycleRecord): void {
writePrivateFileAtomic(
this.#lifecyclePath,
`${JSON.stringify(detachedAgentLifecycleRecordSchema.parse(record), null, 2)}\n`
)
}
async #waitForReady(
verified: VerifiedInstalledAgentBundle
): Promise<DetachedAgentLifecycleRecord | undefined> {
const deadline = this.#now() + this.#readinessTimeoutMs
while (this.#now() <= deadline) {
const status = this.#statusAfterVerification(verified)
if (status.state === 'ready') {
try {
await this.#assertEndpointReady(status.record, verified)
return status.record
} catch {
// The process may have written readiness just before listen completed.
}
} else if (status.state === 'absent' || status.state === 'stale') {
return undefined
}
await this.#sleep(POLL_INTERVAL_MS)
}
return undefined
}
async #assertEndpointReady(
record: DetachedAgentLifecycleRecord,
verified: VerifiedInstalledAgentBundle
): Promise<void> {
if (this.#probeEndpoint === undefined) {
return
}
const welcome = await this.#probeEndpoint(verified)
if (welcome.daemonBootId !== record.daemonBootId) {
throw new Error(
'Detached Agent endpoint boot identity does not match lifecycle state'
)
}
}
async #waitForProcessIdentity(
pid: number,
executablePath: string
): Promise<LinuxProcessIdentity> {
const deadline = this.#now() + this.#readinessTimeoutMs
while (this.#now() <= deadline) {
const identity = this.#processInspector.inspect(pid)
if (
identity !== undefined &&
identity.executablePath === executablePath
) {
return identity
}
await this.#sleep(POLL_INTERVAL_MS)
}
throw new Error('Detached Agent process identity could not be established')
}
async #waitForExit(
identity: LinuxProcessIdentity,
timeoutMs: number
): Promise<boolean> {
const deadline = this.#now() + timeoutMs
while (this.#now() <= deadline) {
if (!this.#processInspector.matches(identity)) {
return true
}
await this.#sleep(POLL_INTERVAL_MS)
}
return !this.#processInspector.matches(identity)
}
async #retireLiveRecord(
record: DetachedAgentLifecycleRecord
): Promise<void> {
if (this.#processInspector.matches(record.process)) {
this.#processInspector.signal(record.process, 'SIGTERM')
if (!(await this.#waitForExit(record.process, this.#stopTimeoutMs))) {
this.#processInspector.signal(record.process, 'SIGKILL')
await this.#waitForExit(record.process, this.#stopTimeoutMs)
}
}
if (this.#processInspector.matches(record.process)) {
throw new Error('Detached Agent did not stop')
}
this.#cleanupRecord(record)
}
#cleanupRecord(record: DetachedAgentLifecycleRecord): void {
if (this.#processInspector.matches(record.process)) {
throw new Error('Refusing to clean a live detached Agent lifecycle')
}
assertPrivateRegularFile(this.#lifecyclePath)
const current = detachedAgentLifecycleRecordSchema.parse(
JSON.parse(readBoundedPrivateFile(this.#lifecyclePath))
)
if (!sameLifecycleRecord(current, record)) {
throw new Error('Detached Agent lifecycle changed during cleanup')
}
this.#socketInspector.removeStale(this.#socketPath)
unlinkSync(this.#lifecyclePath)
}
async #acquireBootstrapLock(): Promise<() => void> {
const deadline = this.#now() + this.#readinessTimeoutMs
while (this.#now() <= deadline) {
const descriptor = tryCreateLock(this.#lockPath)
if (descriptor !== undefined) {
const owner = this.#processInspector.inspect(this.#currentPid)
if (owner === undefined) {
closeSync(descriptor)
unlinkSync(this.#lockPath)
throw new Error(
'Current bootstrap lock process identity is unavailable'
)
}
try {
writeFileSync(
descriptor,
`${JSON.stringify({
formatVersion: 1,
installationId: this.#installationId,
process: owner
})}\n`,
'utf8'
)
if (process.platform !== 'win32') {
fchmodSync(descriptor, 0o600)
}
} finally {
closeSync(descriptor)
}
return () => this.#releaseBootstrapLock(owner)
}
const lock = this.#readLock()
if (lock === undefined) {
continue
}
if (!this.#processInspector.matches(lock.process)) {
const current = this.#readLock()
if (
current !== undefined &&
sameProcessIdentity(current.process, lock.process)
) {
unlinkSync(this.#lockPath)
}
continue
}
await this.#sleep(POLL_INTERVAL_MS)
}
throw new Error('Timed out waiting for detached Agent bootstrap lock')
}
#readLock(): z.infer<typeof bootstrapLockRecordSchema> | undefined {
try {
assertPrivateRegularFile(this.#lockPath)
const lock = bootstrapLockRecordSchema.parse(
JSON.parse(readBoundedPrivateFile(this.#lockPath))
)
if (lock.installationId !== this.#installationId) {
throw new Error(
'Detached Agent bootstrap lock belongs to another installation'
)
}
return lock
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return undefined
}
throw new Error('Detached Agent bootstrap lock is corrupt', {
cause: error
})
}
}
#releaseBootstrapLock(owner: LinuxProcessIdentity): void {
const lock = this.#readLock()
if (
lock !== undefined &&
sameProcessIdentity(lock.process, owner)
) {
unlinkSync(this.#lockPath)
}
}
}
function spawnDetachedProcess(
executable: string,
argv: readonly string[],
options: {
env: Readonly<NodeJS.ProcessEnv>
detached: true
shell: false
stdio: 'ignore'
cwd: string
}
): ReturnType<DetachedSpawn> {
return spawn(executable, [...argv], options)
}
function detachedEnvironment(
source: Readonly<NodeJS.ProcessEnv>
): Readonly<NodeJS.ProcessEnv> {
const allowed = [
'HOME',
'LANG',
'LC_ALL',
'LC_CTYPE',
'LOGNAME',
'PATH',
'TMPDIR',
'TZ',
'USER'
] as const
const env: NodeJS.ProcessEnv = {}
for (const key of allowed) {
const value = source[key]
if (value !== undefined && !value.includes('\0')) {
env[key] = value
}
}
return env
}
function expectedRuntimePath(
verified: VerifiedInstalledAgentBundle
): string {
return assertAbsoluteManagedPath(
resolve(
verified.installationDirectory,
verified.manifest.entrypoint.runtimePath
)
)
}
class FileSystemSocketInspector implements DetachedSocketInspector {
removeStale(path: string): boolean {
try {
const stat = lstatSync(path)
const uid = process.getuid?.()
if (
!stat.isSocket() ||
stat.isSymbolicLink() ||
(uid !== undefined && stat.uid !== uid)
) {
throw new Error('Refusing to remove an unrelated endpoint path')
}
unlinkSync(path)
return true
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return false
}
throw error
}
}
}
function tryCreateLock(path: string): number | undefined {
try {
return openSync(
path,
constants.O_CREAT |
constants.O_EXCL |
constants.O_WRONLY |
(constants.O_NOFOLLOW ?? 0),
0o600
)
} catch (error) {
if (isNodeError(error) && error.code === 'EEXIST') {
return undefined
}
throw error
}
}
function readBoundedPrivateFile(path: string): string {
return readPrivateFile(path, 64 * 1024).toString('utf8')
}
function sameProcessIdentity(
left: LinuxProcessIdentity,
right: LinuxProcessIdentity
): boolean {
return (
left.pid === right.pid &&
left.starttime === right.starttime &&
left.executablePath === right.executablePath
)
}
function sameLifecycleRecord(
left: DetachedAgentLifecycleRecord,
right: DetachedAgentLifecycleRecord
): boolean {
return (
sameProcessIdentity(left.process, right.process) &&
left.daemonBootId === right.daemonBootId &&
left.createdAt === right.createdAt
)
}
function validateInstallationId(value: string): string {
if (
value.length > 128 ||
!/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u.test(value)
) {
throw new Error('Invalid Agent installation ID')
}
return value
}
function boundedTimeout(value: number): number {
if (!Number.isSafeInteger(value) || value < 100 || value > 120_000) {
throw new Error('Detached Agent timeout is invalid')
}
return value
}
function positivePid(value: number): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error('Detached Agent process ID is invalid')
}
return value
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error
}
@@ -0,0 +1,345 @@
import { EventEmitter } from 'node:events'
import { chmodSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
import {
launchDirectLinuxStdioProcessOwner,
reconcileOrphanedDirectLinuxStdioProcesses,
type DirectLinuxStdioChild,
type DirectLinuxStdioSpawn
} from './direct-linux-stdio-process-owner'
import { RuntimeOwnerRegistry } from './runtime-owner-registry'
import type { LinuxRuntimeProcessIdentity } from './runtime-process-identity'
const paths: string[] = []
afterEach(() => {
vi.restoreAllMocks()
for (const path of paths.splice(0)) rmSync(path, { recursive: true, force: true })
})
describe('direct Linux stdio Runtime ownership', () => {
it('spawns exact bubblewrap argv 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(args.join('\0')).not.toMatch(/systemd-run|systemctl|DBUS/iu)
expect(options).toEqual({
shell: false,
detached: true,
windowsHide: true,
cwd: '/state/scratch',
stdio: ['pipe', 'pipe', 'pipe'],
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }
})
launchedIdentity = identity()
queueMicrotask(() => child.emit('spawn'))
return child
})
const owner = await launchDirectLinuxStdioProcessOwner({
manifest: manifest(),
profile: profile(),
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: async () => launchedIdentity
})
expect(spawn).toHaveBeenCalledOnce()
expect(owner.processIdentity).toEqual(launchedIdentity)
expect(registry.get(owner.ownerId)).toMatchObject({
state: 'running',
installationId: 'installation-1',
ownerToken: 'a'.repeat(32)
})
child.exitCode = 0
child.emit('close', 0, null)
expect(registry.get(owner.ownerId)).toBeUndefined()
registry.close()
})
it('directly launches and verifies the packaged Node bridge helper', async () => {
const registry = createRegistry()
const child = fakeChild()
const processIdentity = {
...identity(),
executablePath: '/agent/node'
}
const spawn = vi.fn<DirectLinuxStdioSpawn>(
(executable, args) => {
expect(executable).toBe('/agent/node')
expect(args).toEqual([
'/agent/lib/agent.cjs',
'model-bridge-helper'
])
queueMicrotask(() => child.emit('spawn'))
return child
}
)
const owner = await launchDirectLinuxStdioProcessOwner({
manifest: manifest(),
profile: {
executable: '/agent/node',
processExecutable: '/agent/node',
args: ['/agent/lib/agent.cjs', 'model-bridge-helper'],
cwd: '/workspace',
env: { PATH: '/usr/bin:/bin' },
workMode: 'execute'
},
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: async () => processIdentity
})
expect(owner.processIdentity.executablePath).toBe('/agent/node')
expect(registry.get(owner.ownerId)).toMatchObject({
state: 'running',
processIdentity
})
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()
const original = identity()
const spawn: DirectLinuxStdioSpawn = () => {
queueMicrotask(() => child.emit('spawn'))
return child
}
let reads = 0
const sendSignal = vi.fn<typeof process.kill>(() => true)
const owner = await launchDirectLinuxStdioProcessOwner({
manifest: manifest(),
profile: profile(),
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: async () => {
reads += 1
return reads === 1
? original
: { ...original, startTimeTicks: 999n }
},
sendSignal
})
await expect(
owner.stop({
reason: 'user-cancelled',
deadlineAt: '2030-01-01T00:00:00.000Z'
})
).rejects.toThrow(/identity changed/iu)
expect(sendSignal).not.toHaveBeenCalled()
expect(registry.get(owner.ownerId)).toBeUndefined()
registry.close()
})
it('models verified process-group TERM then KILL and requires an empty tree', async () => {
const registry = createRegistry()
const child = fakeChild()
let launched!: LinuxRuntimeProcessIdentity
const spawn: DirectLinuxStdioSpawn = () => {
launched = identity()
queueMicrotask(() => child.emit('spawn'))
return child
}
const signals: Array<[number, string | number | undefined]> = []
let scans = 0
let now = Date.now()
const owner = await launchDirectLinuxStdioProcessOwner({
manifest: manifest(),
profile: profile(),
identity: { launchId: 'launch-1', processId: 'process-1' },
installationId: 'installation-1',
registry,
deadlineAt: new Date(now + 30_000).toISOString(),
maximumInputBytes: 1024,
platform: 'linux',
spawn,
randomOwnerToken: () => 'a'.repeat(32),
readProcessIdentity: async () => launched,
now: () => {
now += 1_000
return now
},
listPidNamespaceMembers: async () => {
scans += 1
return scans < 4 ? [launched] : []
},
sendSignal: ((pid, signal) => {
signals.push([pid, signal])
return true
}) as typeof process.kill
})
await owner.stop({
reason: 'user-cancelled',
deadlineAt: new Date(now + 20_000).toISOString()
})
expect(signals).toEqual([
[-launched.processGroupId, 'SIGTERM'],
[-launched.processGroupId, 'SIGKILL']
])
expect(registry.get(owner.ownerId)).toBeUndefined()
registry.close()
})
it('startup reconciles only owners from the current installation', async () => {
const registry = createRegistry()
const reservation = {
ownerId: 'owner-current',
launchId: 'launch-current',
processId: 'process-current',
installationId: 'installation-current',
ownerToken: 'a'.repeat(32)
}
registry.reserve(reservation)
registry.reserve({
...reservation,
ownerId: 'owner-other',
launchId: 'launch-other',
processId: 'process-other',
installationId: 'installation-other'
})
await expect(
reconcileOrphanedDirectLinuxStdioProcesses({
installationId: 'installation-current',
registry
})
).resolves.toEqual({
inspected: 1,
stopped: 1,
conflicts: 0,
unknown: 0
})
expect(registry.get('owner-current')).toBeUndefined()
expect(registry.get('owner-other')?.state).toBe('reserved')
registry.close()
})
})
function createRegistry(): RuntimeOwnerRegistry {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-direct-owner-'))
paths.push(root)
if (process.platform !== 'win32') chmodSync(root, 0o700)
return new RuntimeOwnerRegistry(join(root, 'owners.sqlite'))
}
function fakeChild(): DirectLinuxStdioChild & EventEmitter {
const child = new EventEmitter() as DirectLinuxStdioChild & EventEmitter
child.pid = 42
child.stdin = new PassThrough()
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.exitCode = null
child.signalCode = null
child.kill = vi.fn(() => true)
return child
}
function profile() {
return {
executable: '/usr/bin/bwrap' as const,
processExecutable: '/usr/bin/bwrap' as const,
cwd: '/state/scratch',
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'
]
}
}
function identity(): LinuxRuntimeProcessIdentity {
return {
bootId: '11111111-1111-1111-1111-111111111111',
pid: 42,
startTimeTicks: 100n,
processGroupId: 42,
executablePath: '/usr/bin/bwrap'
}
}
function manifest(): RemoteRuntimeBundleManifest {
return {
formatVersion: 2,
product: 'GoodBuddy',
runtimeId: 'opencode',
runtimeVersion: '1.18.9',
provider: 'opencode',
platform: 'linux',
architecture: 'x64',
signingKeyId: 'runtime-test',
bundleDigest: `sha256:${'a'.repeat(64)}`,
adapterDigest: `sha256:${'b'.repeat(64)}`,
sourcePackage: {
name: 'opencode-linux-x64-baseline',
integrity:
'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ=='
},
entrypoint: {
identity: 'opencode-acp',
path: 'bin/opencode',
sha256: 'd'.repeat(64),
argvPrefix: ['acp']
},
files: [
{ path: 'bin/opencode', size: 64, sha256: 'd'.repeat(64), mode: '0755' },
{ path: 'licenses/opencode.txt', size: 4, sha256: 'e'.repeat(64), mode: '0644' }
],
licenses: [
{
package: 'opencode-ai',
version: '1.18.9',
spdx: 'MIT',
path: 'licenses/opencode.txt'
}
],
allowedEnvironmentNames: [
'HOME', 'LANG', 'LC_ALL', 'PATH', 'TMPDIR', 'XDG_CACHE_HOME',
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME'
],
protocol: { major: 1, minor: 0 },
acpCapabilitiesDigest: `sha256:${'f'.repeat(64)}`,
limits: {
maximumPromptRuntimeMilliseconds: 60_000,
maximumPromptInputBytes: 4096,
maximumPromptOutputBytes: 1024 * 1024
}
}
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
import { pathToFileURL } from 'node:url'
import { resolve } from 'node:path'
import { runAgentCli } from './cli'
export async function main(argv = process.argv.slice(2)): Promise<number> {
return await runAgentCli(argv)
}
const invokedPath = process.argv[1]
if (
invokedPath !== undefined &&
import.meta.url === pathToFileURL(resolve(invokedPath)).href
) {
process.exitCode = await main()
}
+12
View File
@@ -0,0 +1,12 @@
export class AgentUnsupportedError extends Error {
constructor(
message: string,
readonly code:
| 'platform-incompatible'
| 'supervisor-unavailable'
| 'peer-identity-unavailable'
) {
super(message)
this.name = 'AgentUnsupportedError'
}
}
+842
View File
@@ -0,0 +1,842 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import {
EventJournal,
EventJournalCapacityError,
EventJournalSequenceError
} from './event-journal'
const directories: string[] = []
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function createJournal(
overrides: ConstructorParameters<typeof EventJournal>[1] = {}
): Promise<{ journal: EventJournal; path: string }> {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-event-journal-')
)
directories.push(directory)
const path = join(directory, 'events.sqlite')
const journal = new EventJournal(path, {
defaultStreamLimits: {
maximumBytes: 100,
maximumEvents: 10,
terminalReserveBytes: 20,
terminalReserveEvents: 2,
maximumDurationMs: 60_000
},
maximumControllerBytes: 500,
maximumDaemonBytes: 1_000,
terminalReserveBytes: 50,
...overrides
})
return { journal, path }
}
function event(sequence: string) {
return {
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
sequence
}
}
describe('EventJournal event streams', () => {
it('persists before replay and survives reopening', async () => {
const { journal, path } = await createJournal()
journal.appendEvent({
identity: event('1'),
eventClass: 'data',
payload: new Uint8Array([1, 2, 3])
})
journal.close()
const reopened = new EventJournal(path, {
maximumControllerBytes: 500,
maximumDaemonBytes: 1_000
})
expect(
reopened.replayEvents({
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
afterSequence: '0'
})
).toEqual([
expect.objectContaining({
identity: event('1'),
payload: new Uint8Array([1, 2, 3])
})
])
reopened.close()
})
it('requires contiguous identity and idempotently accepts exact duplicates', async () => {
const { journal } = await createJournal()
const input = {
identity: event('1'),
eventClass: 'control' as const,
payload: new Uint8Array([4])
}
expect(journal.appendEvent(input).created).toBe(true)
expect(journal.appendEvent(input).created).toBe(false)
expect(() =>
journal.appendEvent({
...input,
payload: new Uint8Array([5])
})
).toThrow(EventJournalSequenceError)
expect(() =>
journal.appendEvent({
...input,
identity: event('3')
})
).toThrow(/contiguous/iu)
journal.close()
})
it('reserves bytes for truncation/cancellation terminal events', async () => {
const { journal } = await createJournal({
defaultStreamLimits: {
maximumBytes: 10,
maximumEvents: 10,
terminalReserveBytes: 4,
terminalReserveEvents: 2,
maximumDurationMs: 60_000
},
maximumControllerBytes: 20,
maximumDaemonBytes: 30,
terminalReserveBytes: 5
})
journal.appendEvent({
identity: event('1'),
eventClass: 'data',
payload: new Uint8Array(6)
})
try {
journal.appendEvent({
identity: event('2'),
eventClass: 'data',
payload: new Uint8Array(1)
})
} catch (error) {
expect(error).toMatchObject({
level: 'stream',
terminalWriteAllowed: true
})
}
expect(
journal.appendEvent({
identity: event('2'),
eventClass: 'terminal',
payload: new Uint8Array(4)
}).created
).toBe(true)
expect(() =>
journal.appendEvent({
identity: event('3'),
eventClass: 'terminal',
payload: new Uint8Array(1)
})
).toThrow(EventJournalCapacityError)
journal.close()
})
it('ACK-prunes data immediately but retains terminal records for the floor', async () => {
let now = 1_000
const { journal } = await createJournal({
now: () => now,
minimumUnacknowledgedTerminalRetentionMs: 100
})
journal.appendEvent({
identity: event('1'),
eventClass: 'data',
payload: new Uint8Array([1])
})
journal.appendEvent({
identity: event('2'),
eventClass: 'terminal',
payload: new Uint8Array([2])
})
journal.acknowledgeEvents({
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
sequence: '2'
})
expect(
journal
.replayEvents({
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
afterSequence: '0'
})
.map((item) => item.identity.sequence)
).toEqual(['2'])
now += 101
expect(journal.pruneAcknowledged()).toBe(1)
expect(
journal.replayEvents({
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
afterSequence: '0'
})
).toEqual([])
journal.close()
})
it('rejects ACKs beyond durable sequence or moving backwards', async () => {
const { journal } = await createJournal()
journal.appendEvent({
identity: event('1'),
eventClass: 'data',
payload: new Uint8Array([1])
})
expect(() =>
journal.acknowledgeEvents({
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
sequence: '2'
})
).toThrow(/exceeds/iu)
journal.acknowledgeEvents({
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
sequence: '1'
})
expect(() =>
journal.acknowledgeEvents({
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
sequence: '0'
})
).toThrow()
journal.close()
})
it('migrates legacy streams and reconstructs durable usage counters', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-event-journal-legacy-')
)
directories.push(directory)
const path = join(directory, 'events.sqlite')
const legacy = new DatabaseSync(path)
legacy.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE event_streams (
controller_id TEXT NOT NULL,
stream_id TEXT NOT NULL,
stream_epoch INTEGER NOT NULL,
last_sequence INTEGER NOT NULL DEFAULT 0,
ack_sequence INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
maximum_bytes INTEGER NOT NULL,
maximum_events INTEGER NOT NULL,
terminal_reserve_bytes INTEGER NOT NULL,
terminal_reserve_events INTEGER NOT NULL,
maximum_duration_ms INTEGER NOT NULL,
PRIMARY KEY (controller_id, stream_id, stream_epoch)
) STRICT;
CREATE TABLE journal_events (
controller_id TEXT NOT NULL,
stream_id TEXT NOT NULL,
stream_epoch INTEGER NOT NULL,
sequence INTEGER NOT NULL,
event_class TEXT NOT NULL,
payload BLOB NOT NULL,
byte_length INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (
controller_id, stream_id, stream_epoch, sequence
),
FOREIGN KEY (controller_id, stream_id, stream_epoch)
REFERENCES event_streams(
controller_id, stream_id, stream_epoch
) ON DELETE CASCADE
) STRICT;
`)
legacy
.prepare(
`INSERT INTO event_streams VALUES (
'controller-1', 'run-1', 1, 1, 0, 1000,
10, 10, 2, 2, 60000
)`
)
.run()
legacy
.prepare(
`INSERT INTO journal_events VALUES (
'controller-1', 'run-1', 1, 1, 'data', ?, 6, 1000
)`
)
.run(new Uint8Array(6))
legacy.close()
const migrated = new EventJournal(path, {
defaultStreamLimits: {
maximumBytes: 10,
maximumEvents: 10,
terminalReserveBytes: 2,
terminalReserveEvents: 2,
maximumDurationMs: 60_000
},
maximumControllerBytes: 20,
maximumDaemonBytes: 30,
terminalReserveBytes: 2
})
expect(() =>
migrated.appendEvent({
identity: event('2'),
eventClass: 'data',
payload: new Uint8Array(3)
})
).toThrow(EventJournalCapacityError)
migrated.close()
})
it('repairs corrupted usage counters before enforcing quotas', async () => {
const { journal, path } = await createJournal({
defaultStreamLimits: {
maximumBytes: 10,
maximumEvents: 10,
terminalReserveBytes: 2,
terminalReserveEvents: 2,
maximumDurationMs: 60_000
}
})
journal.appendEvent({
identity: event('1'),
eventClass: 'data',
payload: new Uint8Array(6)
})
journal.close()
const corrupt = new DatabaseSync(path)
corrupt.exec(`
UPDATE event_streams
SET stored_bytes = 0, stored_events = 0;
UPDATE event_journal_usage SET total_bytes = 0;
DELETE FROM event_controller_usage;
`)
corrupt.close()
const repaired = new EventJournal(path, {
maximumControllerBytes: 500,
maximumDaemonBytes: 1_000
})
expect(() =>
repaired.appendEvent({
identity: event('2'),
eventClass: 'data',
payload: new Uint8Array(3)
})
).toThrow(EventJournalCapacityError)
repaired.close()
})
it('accounts for event and ACP bytes together and releases pruned quota', async () => {
const { journal } = await createJournal({
maximumControllerBytes: 6,
maximumDaemonBytes: 8,
terminalReserveBytes: 0
})
journal.appendEvent({
identity: event('1'),
eventClass: 'data',
payload: new Uint8Array(4)
})
expect(() =>
journal.appendAcpFrame({
controllerId: 'controller-1',
bindingId: 'binding-controller-limit',
channelEpoch: '1',
direction: 'runtime-to-main',
sequence: '1',
payload: new Uint8Array(3)
})
).toThrow(
expect.objectContaining({ level: 'controller' })
)
journal.appendAcpFrame({
controllerId: 'controller-2',
bindingId: 'binding-daemon-limit',
channelEpoch: '1',
direction: 'runtime-to-main',
sequence: '1',
payload: new Uint8Array(4)
})
expect(() =>
journal.appendAcpFrame({
controllerId: 'controller-2',
bindingId: 'binding-daemon-limit',
channelEpoch: '1',
direction: 'runtime-to-main',
sequence: '2',
payload: new Uint8Array(1)
})
).toThrow(expect.objectContaining({ level: 'daemon' }))
journal.acknowledgeEvents({
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
sequence: '1'
})
expect(
journal.appendAcpFrame({
controllerId: 'controller-2',
bindingId: 'binding-daemon-limit',
channelEpoch: '1',
direction: 'runtime-to-main',
sequence: '2',
payload: new Uint8Array(1)
}).created
).toBe(true)
journal.close()
})
it('retires fully ACKed terminal stream metadata without permitting reuse', async () => {
const { journal, path } = await createJournal({
minimumUnacknowledgedTerminalRetentionMs: 0
})
journal.appendEvent({
identity: event('1'),
eventClass: 'terminal',
payload: new Uint8Array([1])
})
journal.acknowledgeEvents({
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
sequence: '1'
})
expect(() =>
journal.acknowledgeEvents({
controllerId: 'controller-1',
streamId: 'run-1',
streamEpoch: '1',
sequence: '1'
})
).not.toThrow()
expect(() =>
journal.appendEvent({
identity: event('1'),
eventClass: 'terminal',
payload: new Uint8Array([1])
})
).toThrow(EventJournalSequenceError)
journal.close()
const check = new DatabaseSync(path)
expect(
(
check
.prepare('SELECT COUNT(*) AS count FROM event_streams')
.get() as { count: number }
).count
).toBe(0)
expect(
(
check
.prepare(
'SELECT COUNT(*) AS count FROM retired_event_streams'
)
.get() as { count: number }
).count
).toBe(1)
check.close()
})
})
describe('EventJournal ACP directional journals', () => {
it('enforces ACP frame caps before insert without advancing the journal cursor', async () => {
const { journal } = await createJournal({
maximumAcpChannelFrames: 1,
maximumAcpControllerFrames: 2,
maximumAcpDaemonFrames: 3
})
const append = (
controllerId: string,
bindingId: string,
sequence = '1'
) =>
journal.appendAcpFrame({
controllerId,
bindingId,
channelEpoch: '1',
direction: 'runtime-to-main',
sequence,
payload: Uint8Array.of(1)
})
append('controller-1', 'binding-1')
expect(() => append('controller-1', 'binding-1', '2')).toThrow(
expect.objectContaining({ level: 'stream' })
)
expect(
journal.getAcpCursor(
'binding-1',
'1',
'runtime-to-main'
)?.journaledSequence
).toBe('1')
append('controller-1', 'binding-2')
expect(() => append('controller-1', 'binding-3')).toThrow(
expect.objectContaining({ level: 'controller' })
)
append('controller-2', 'binding-4')
expect(() => append('controller-2', 'binding-5')).toThrow(
expect.objectContaining({ level: 'daemon' })
)
expect(
journal.getAcpCursor(
'binding-5',
'1',
'runtime-to-main'
)
).toBeUndefined()
journal.close()
})
it('transactionally prunes delivered outbound frames without retiring cursor history', async () => {
const { journal } = await createJournal()
journal.appendAcpFrame({
controllerId: 'controller-1',
bindingId: 'binding-1',
channelEpoch: '9',
direction: 'main-to-runtime',
sequence: '1',
payload: new TextEncoder().encode('{"method":"session/prompt"}\n')
})
expect(
journal.getAcpCursor(
'binding-1',
'9',
'main-to-runtime'
)
).toEqual({
bindingId: 'binding-1',
channelEpoch: '9',
direction: 'main-to-runtime',
journaledSequence: '1',
deliveredSequence: '0',
mainAckSequence: '0'
})
journal.markAcpDelivered({
bindingId: 'binding-1',
channelEpoch: '9',
sequence: '1'
})
expect(
journal.getAcpCursor(
'binding-1',
'9',
'main-to-runtime'
)?.deliveredSequence
).toBe('1')
expect(
journal.replayAcpFrames({
bindingId: 'binding-1',
channelEpoch: '9',
direction: 'main-to-runtime',
afterSequence: '0'
})
).toHaveLength(0)
expect(
journal.pruneReconciledAcpOutbound({
bindingId: 'binding-1',
channelEpoch: '9',
throughSequence: '1'
})
).toBe(0)
expect(
journal.appendAcpFrame({
controllerId: 'controller-1',
bindingId: 'binding-1',
channelEpoch: '9',
direction: 'main-to-runtime',
sequence: '2',
payload: new TextEncoder().encode('{"method":"session/load"}\n')
}).created
).toBe(true)
journal.close()
})
it('releases outbound frame capacity as each non-replayable input is delivered', async () => {
const { journal } = await createJournal({
maximumAcpChannelFrames: 1,
maximumAcpControllerFrames: 1,
maximumAcpDaemonFrames: 1
})
const identity = {
controllerId: 'controller-1',
bindingId: 'binding-input-capacity',
channelEpoch: '7',
direction: 'main-to-runtime' as const
}
journal.appendAcpFrame({
...identity,
sequence: '1',
payload: Uint8Array.of(1)
})
journal.markAcpDelivered({
bindingId: identity.bindingId,
channelEpoch: identity.channelEpoch,
sequence: '1'
})
expect(
journal.appendAcpFrame({
...identity,
sequence: '2',
payload: Uint8Array.of(2)
}).created
).toBe(true)
expect(
journal.getAcpCursor(
identity.bindingId,
identity.channelEpoch,
identity.direction
)
).toMatchObject({
journaledSequence: '2',
deliveredSequence: '1'
})
journal.close()
})
it('tracks inbound journaled and Main ACK cursors then prunes ACKed bytes', async () => {
const { journal } = await createJournal()
for (const sequence of ['1', '2']) {
journal.appendAcpFrame({
controllerId: 'controller-1',
bindingId: 'binding-1',
channelEpoch: '1',
direction: 'runtime-to-main',
sequence,
payload: new Uint8Array([Number(sequence)])
})
}
journal.acknowledgeAcpFromMain({
bindingId: 'binding-1',
channelEpoch: '1',
sequence: '1'
})
expect(
journal.getAcpCursor(
'binding-1',
'1',
'runtime-to-main'
)?.mainAckSequence
).toBe('1')
expect(
journal.replayAcpFrames({
bindingId: 'binding-1',
channelEpoch: '1',
direction: 'runtime-to-main',
afterSequence: '0'
})
).toEqual([
expect.objectContaining({
sequence: '2',
payload: new Uint8Array([2])
})
])
expect(() =>
journal.acknowledgeAcpFromMain({
bindingId: 'binding-1',
channelEpoch: '1',
sequence: '3'
})
).toThrow(/exceeds/iu)
journal.close()
})
it('keeps an active inbound channel appendable after ACK reaches its current tail', async () => {
const { journal } = await createJournal()
const frame = {
controllerId: 'controller-1',
bindingId: 'binding-streaming',
channelEpoch: '4',
direction: 'runtime-to-main' as const
}
journal.appendAcpFrame({
...frame,
sequence: '1',
payload: new Uint8Array([1])
})
journal.acknowledgeAcpFromMain({
bindingId: frame.bindingId,
channelEpoch: frame.channelEpoch,
sequence: '1'
})
expect(
journal.getAcpCursor(
frame.bindingId,
frame.channelEpoch,
frame.direction
)
).toEqual({
bindingId: frame.bindingId,
channelEpoch: frame.channelEpoch,
direction: frame.direction,
journaledSequence: '1',
deliveredSequence: '0',
mainAckSequence: '1'
})
expect(
journal.appendAcpFrame({
...frame,
sequence: '2',
payload: new Uint8Array([2])
}).created
).toBe(true)
expect(
journal.replayAcpFrames({
bindingId: frame.bindingId,
channelEpoch: frame.channelEpoch,
direction: frame.direction,
afterSequence: '1'
})
).toEqual([
expect.objectContaining({
sequence: '2',
payload: new Uint8Array([2])
})
])
journal.close()
})
it('never reuses an ACP epoch/direction sequence with other bytes', async () => {
const { journal } = await createJournal()
const input = {
controllerId: 'controller-1',
bindingId: 'binding-1',
channelEpoch: '2',
direction: 'runtime-to-main' as const,
sequence: '1',
payload: new Uint8Array([1])
}
expect(journal.appendAcpFrame(input).created).toBe(true)
expect(journal.appendAcpFrame(input).created).toBe(false)
expect(() =>
journal.appendAcpFrame({
...input,
payload: new Uint8Array([2])
})
).toThrow(EventJournalSequenceError)
journal.close()
})
it('retires a binding atomically, discards terminal frames, and preserves epoch safety', async () => {
const { journal, path } = await createJournal()
const input = {
controllerId: 'controller-1',
bindingId: 'binding-retired',
channelEpoch: '3',
direction: 'runtime-to-main' as const,
sequence: '1',
payload: new Uint8Array([1])
}
journal.appendAcpFrame(input)
journal.appendAcpFrame({
...input,
direction: 'main-to-runtime',
payload: new Uint8Array([2])
})
expect(journal.retireAcpBinding({
controllerId: input.controllerId,
bindingId: input.bindingId,
channelEpoch: input.channelEpoch
})).toBe(2)
expect(
journal.getAcpCursor(
input.bindingId,
input.channelEpoch,
input.direction
)
).toBeUndefined()
expect(() =>
journal.appendAcpFrame({
...input,
sequence: '2',
payload: new Uint8Array([2])
})
).toThrow(EventJournalSequenceError)
expect(journal.retireAcpBinding({
controllerId: input.controllerId,
bindingId: input.bindingId,
channelEpoch: input.channelEpoch
})).toBe(0)
expect(() => journal.appendAcpFrame(input)).toThrow(
EventJournalSequenceError
)
expect(() =>
journal.appendAcpFrame({
...input,
direction: 'main-to-runtime',
sequence: '1'
})
).toThrow(EventJournalSequenceError)
journal.close()
const check = new DatabaseSync(path)
expect(
(
check
.prepare('SELECT COUNT(*) AS count FROM acp_channels')
.get() as { count: number }
).count
).toBe(0)
expect(
(
check
.prepare(
'SELECT COUNT(*) AS count FROM retired_acp_channels'
)
.get() as { count: number }
).count
).toBe(2)
expect(
(
check
.prepare('SELECT COUNT(*) AS count FROM acp_frames')
.get() as { count: number }
).count
).toBe(0)
expect(
(
check
.prepare(
'SELECT total_bytes FROM event_journal_usage WHERE id = 1'
)
.get() as { total_bytes: number }
).total_bytes
).toBe(0)
expect(
(
check
.prepare(
`SELECT total_bytes FROM event_controller_usage
WHERE controller_id = 'controller-1'`
)
.get() as { total_bytes: number }
).total_bytes
).toBe(0)
check.close()
})
})
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
export * from './bundle-file-verification'
export * from './channel-state'
export * from './cli'
export * from './controller-registry'
export * from './daemon'
export * from './detached-agent-lifecycle'
export * from './direct-linux-stdio-process-owner'
export * from './errors'
export * from './entry'
export * from './event-journal'
export * from './installation-challenge'
export * from './installation-registry'
export * from './installed-bundle-verifier'
export * from './managed-paths'
export * from './linux-process-identity'
export * from './model-bridge-broker'
export * from './model-bridge-client'
export * from './model-bridge-helper'
export * from './opencode-runtime-profile'
export * from './private-endpoint'
export * from './protocol-server'
export * from './runtime-bundle-registry'
export * from './runtime-bundle-verifier'
export * from './runtime-composition'
export * from './runtime-acp-backend'
export * from './runtime-owner-registry'
export * from './runtime-process-identity'
export * from './structured-command-runner'
export * from './workspace-git-service'
export * from './workspace-path-access'
export * from './workspace-protocol-methods'
export * from './workspace-registry'
+113
View File
@@ -0,0 +1,113 @@
import {
createHmac,
randomBytes,
timingSafeEqual
} from 'node:crypto'
const CHALLENGE_DOMAIN = 'goodbuddy-agent-installation-challenge-v1\0'
export type InstallationChallenge = {
serverNonce: string
expiresAt: number
}
export class InstallationChallengeVerifier {
readonly #secret: Buffer
readonly #now: () => number
readonly #lifetimeMs: number
readonly #pending = new Map<string, number>()
constructor(
secret: Uint8Array,
options: { now?: () => number; lifetimeMs?: number } = {}
) {
if (secret.byteLength < 32) {
throw new RangeError('Installation challenge secret is too short')
}
this.#secret = Buffer.from(secret)
this.#now = options.now ?? Date.now
this.#lifetimeMs = options.lifetimeMs ?? 30_000
if (!Number.isSafeInteger(this.#lifetimeMs) || this.#lifetimeMs < 1) {
throw new RangeError('Invalid installation challenge lifetime')
}
}
issue(): InstallationChallenge {
this.#purgeExpired()
const serverNonce = randomBytes(32).toString('base64url')
const expiresAt = this.#now() + this.#lifetimeMs
this.#pending.set(serverNonce, expiresAt)
return { serverNonce, expiresAt }
}
createResponse(input: {
serverNonce: string
clientNonce: string
controllerId: string
}): string {
return calculateResponse(this.#secret, input)
}
verify(input: {
serverNonce: string
clientNonce: string
controllerId: string
response: string
}): boolean {
const expiresAt = this.#pending.get(input.serverNonce)
this.#pending.delete(input.serverNonce)
if (expiresAt === undefined || expiresAt < this.#now()) {
return false
}
const expected = Buffer.from(
calculateResponse(this.#secret, input),
'base64url'
)
let received: Buffer
try {
received = Buffer.from(input.response, 'base64url')
} catch {
return false
}
return (
expected.byteLength === received.byteLength &&
timingSafeEqual(expected, received)
)
}
#purgeExpired(): void {
const now = this.#now()
for (const [nonce, expiresAt] of this.#pending) {
if (expiresAt < now) {
this.#pending.delete(nonce)
}
}
}
}
function calculateResponse(
secret: Uint8Array,
input: {
serverNonce: string
clientNonce: string
controllerId: string
}
): string {
for (const value of [
input.serverNonce,
input.clientNonce,
input.controllerId
]) {
if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(value)) {
throw new Error('Invalid installation challenge field')
}
}
return createHmac('sha256', secret)
.update(CHALLENGE_DOMAIN)
.update(input.serverNonce)
.update('\0')
.update(input.clientNonce)
.update('\0')
.update(input.controllerId)
.digest('base64url')
}
@@ -0,0 +1,231 @@
import {
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { InstallationRegistry } from './installation-registry'
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
const temporaryPaths: string[] = []
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('Agent installation registry', () => {
it('stages and atomically promotes an exact signed installation', () => {
const { registry, storagePath } = createRegistry()
const first = verified('install-a', '0.11.0', 'a')
const second = verified('install-b', '0.11.1', 'b')
registry.stageCandidate(first)
expect(registry.snapshot()).toMatchObject({
candidate: { installationId: 'install-a' }
})
registry.promoteCandidate('install-a')
expect(registry.snapshot()).toMatchObject({
current: { installationId: 'install-a' }
})
expect(registry.snapshot().candidate).toBeUndefined()
registry.stageCandidate(second)
registry.promoteCandidate('install-b')
expect(registry.snapshot()).toEqual({
formatVersion: 1,
current: expect.objectContaining({
installationId: 'install-b',
agentVersion: '0.11.1'
})
})
expect(
new InstallationRegistry({ storagePath }).snapshot()
).toEqual(registry.snapshot())
})
it('reuses the exact current identity without staging it again', () => {
const { registry } = createRegistry()
const current = verified('install-a', '0.11.1', 'a')
registry.stageCandidate(current)
registry.promoteCandidate('install-a')
expect(registry.stageCandidate(current)).toEqual(
registry.snapshot().current
)
expect(registry.snapshot().candidate).toBeUndefined()
expect(() =>
registry.assertVerifiedRole(current, ['current'])
).not.toThrow()
})
it('replaces a stale candidate with the requested signed identity', () => {
const { registry } = createRegistry()
registry.stageCandidate(verified('install-a', '0.11.0', 'a'))
registry.stageCandidate(verified('install-b', '0.11.1', 'b'))
expect(registry.snapshot().candidate?.installationId).toBe(
'install-b'
)
})
it('preserves legacy bytes until the registry is mutated', () => {
const root = privateTemporaryDirectory()
const storagePath = resolve(root, 'registry.json')
const current = {
installationId: 'install-a',
productVersion: '0.11.0',
agentVersion: '0.11.0',
releaseSequence: 4,
manifestSha256: 'a'.repeat(64),
binaryDigest: `sha256:${'a'.repeat(64)}`,
arch: 'x64',
protocol: { major: 1, minor: 0 },
signingKeyId: 'test-key',
previouslyVerified: true
}
const legacyBytes = `${JSON.stringify({
formatVersion: 1,
minimumTrustedReleaseSequence: 1,
current,
draining: []
}, null, 2)}\n`
writeFileSync(storagePath, legacyBytes, { mode: 0o600 })
const registry = new InstallationRegistry({ storagePath })
expect(registry.snapshot()).toEqual({
formatVersion: 1,
current: {
installationId: 'install-a',
agentVersion: '0.11.0',
manifestSha256: 'a'.repeat(64),
arch: 'x64'
}
})
expect(readFileSync(storagePath, 'utf8')).toBe(legacyBytes)
registry.stageCandidate(
verified('install-b', '0.11.1', 'b')
)
expect(readFileSync(storagePath, 'utf8')).not.toContain(
'releaseSequence'
)
})
it('fails closed on corrupt and concurrent state', () => {
const root = privateTemporaryDirectory()
const storagePath = resolve(root, 'registry.json')
writeFileSync(
storagePath,
'{"formatVersion":1,"unknown":true}',
{ mode: 0o600 }
)
expect(() => new InstallationRegistry({ storagePath })).toThrow(
'corrupt'
)
writeFileSync(storagePath, '{}', { mode: 0o644 })
expect(() => new InstallationRegistry({ storagePath })).toThrow(
'corrupt'
)
rmSync(storagePath)
const first = new InstallationRegistry({ storagePath })
const concurrent = new InstallationRegistry({ storagePath })
first.stageCandidate(verified('install-a', '0.11.0', 'a'))
expect(() =>
concurrent.stageCandidate(
verified('install-b', '0.11.1', 'b')
)
).toThrow('changed concurrently')
expect(readFileSync(storagePath, 'utf8')).toContain('install-a')
})
})
function createRegistry(): {
registry: InstallationRegistry
storagePath: string
} {
const root = privateTemporaryDirectory()
const storagePath = resolve(root, 'registry.json')
return {
registry: new InstallationRegistry({ storagePath }),
storagePath
}
}
function privateTemporaryDirectory(): string {
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-registry-'))
temporaryPaths.push(path)
if (process.platform !== 'win32') {
chmodSync(path, 0o700)
}
return resolve(path)
}
function verified(
installationId: string,
agentVersion: string,
digestCharacter: string
): VerifiedInstalledAgentBundle {
const manifestSha256 = digestCharacter.repeat(64)
return {
installationId,
installationDirectory: `/agent/${installationId}`,
executablePath: `/agent/${installationId}/goodbuddy-agent`,
manifestSha256,
binaryDigest: `sha256:${manifestSha256}`,
manifest: {
formatVersion: 1,
product: 'GoodBuddy',
agentVersion,
platform: 'linux',
arch: 'x64',
protocol: { major: 1, minor: 0 },
signingKeyId: 'test-key',
entrypoint: {
path: 'goodbuddy-agent',
runtimePath: 'node',
scriptPath: 'lib/agent.cjs'
},
files: [
{
path: 'goodbuddy-agent',
size: 1,
sha256: 'a'.repeat(64),
mode: '0755'
},
{
path: 'node',
size: 1,
sha256: 'b'.repeat(64),
mode: '0755'
},
{
path: 'lib/agent.cjs',
size: 1,
sha256: 'c'.repeat(64),
mode: '0644'
},
{
path: 'licenses/license.txt',
size: 1,
sha256: 'd'.repeat(64),
mode: '0644'
}
],
licenses: [
{
package: 'GoodBuddy',
version: agentVersion,
spdx: '0BSD',
path: 'licenses/license.txt'
}
]
}
}
}
+234
View File
@@ -0,0 +1,234 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import {
installationRegistryEntrySchema,
installationRegistryIdSchema,
installationRegistryStateSchema,
parseInstallationRegistryState,
type InstallationRegistryEntry,
type InstallationRegistryState
} from '../shared/remote-environment-registry-contracts'
import {
ensurePrivateDirectory,
assertPrivateRegularFile,
writePrivateFileAtomic
} from './managed-paths'
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
export {
installationRegistryEntrySchema,
installationRegistryIdSchema,
installationRegistryStateSchema
}
export type {
InstallationRegistryEntry,
InstallationRegistryState
}
export class InstallationRegistry {
readonly #storagePath: string
#state: InstallationRegistryState
#needsCanonicalization = false
constructor(options: { storagePath: string }) {
this.#storagePath = resolve(options.storagePath)
ensurePrivateDirectory(dirname(this.#storagePath))
this.#state = this.#load()
}
snapshot(): InstallationRegistryState {
return structuredClone(this.#state)
}
stageCandidate(
verified: VerifiedInstalledAgentBundle
): InstallationRegistryEntry {
const entry = entryFromVerified(verified)
if (
this.#state.current !== undefined &&
entryEquals(this.#state.current, entry)
) {
this.#canonicalizeIfNeeded()
return this.#state.current
}
if (
this.#state.candidate !== undefined &&
entryEquals(this.#state.candidate, entry)
) {
this.#canonicalizeIfNeeded()
return this.#state.candidate
}
try {
this.#commit({ ...this.#state, candidate: entry })
} catch (error) {
if (!isConcurrentRegistryError(error)) {
throw error
}
this.#state = this.#load()
const converged = [
this.#state.current,
this.#state.candidate
].find(
(candidate) =>
candidate !== undefined && entryEquals(candidate, entry)
)
if (converged === undefined) {
throw error
}
return converged
}
return entry
}
promoteCandidate(
installationIdInput: string
): InstallationRegistryState {
const installationId = installationRegistryIdSchema.parse(
installationIdInput
)
const candidate = this.#state.candidate
if (
candidate === undefined ||
candidate.installationId !== installationId
) {
throw new Error('Agent candidate is not staged for promotion')
}
this.#commit({
formatVersion: 1,
current: candidate
})
return this.snapshot()
}
assertVerifiedRole(
verified: VerifiedInstalledAgentBundle,
roles: readonly ('current' | 'candidate')[]
): InstallationRegistryEntry {
const entry = entryFromVerified(verified)
const registered = roles
.map((role) => this.#state[role])
.find(
(candidate) =>
candidate !== undefined &&
candidate.installationId === entry.installationId
)
if (
registered === undefined ||
!entryEquals(registered, entry)
) {
throw new Error(
'Verified Agent installation does not match its authorized registry role'
)
}
return registered
}
#load(): InstallationRegistryState {
let value: unknown
try {
assertPrivateRegularFile(this.#storagePath)
value = JSON.parse(readFileSync(this.#storagePath, 'utf8'))
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
const initial: InstallationRegistryState = {
formatVersion: 1
}
writePrivateFileAtomic(
this.#storagePath,
`${JSON.stringify(initial, null, 2)}\n`
)
return initial
}
throw new Error('Agent installation registry is corrupt', {
cause: error
})
}
let parsed: InstallationRegistryState
try {
parsed = parseInstallationRegistryState(value)
} catch (error) {
throw new Error(
'Agent installation registry is corrupt',
{ cause: error }
)
}
this.#needsCanonicalization =
JSON.stringify(parsed) !== JSON.stringify(value)
return parsed
}
#commit(stateInput: InstallationRegistryState): void {
const state = installationRegistryStateSchema.parse(stateInput)
let persisted: unknown
try {
assertPrivateRegularFile(this.#storagePath)
persisted = JSON.parse(readFileSync(this.#storagePath, 'utf8'))
} catch (error) {
throw new Error(
'Agent installation registry changed or became corrupt before commit',
{ cause: error }
)
}
let parsedPersisted: InstallationRegistryState
try {
parsedPersisted = parseInstallationRegistryState(persisted)
} catch {
throw new Error(
'Agent installation registry changed or became corrupt before commit'
)
}
if (
JSON.stringify(parsedPersisted) !== JSON.stringify(this.#state)
) {
throw new Error(
'Agent installation registry changed concurrently; refusing to overwrite it'
)
}
writePrivateFileAtomic(
this.#storagePath,
`${JSON.stringify(state, null, 2)}\n`
)
this.#state = state
this.#needsCanonicalization = false
}
#canonicalizeIfNeeded(): void {
if (this.#needsCanonicalization) {
this.#commit(this.#state)
}
}
}
function entryFromVerified(
verified: VerifiedInstalledAgentBundle
): InstallationRegistryEntry {
return installationRegistryEntrySchema.parse({
installationId: verified.installationId,
agentVersion: verified.manifest.agentVersion,
manifestSha256: verified.manifestSha256,
arch: verified.manifest.arch
})
}
function entryEquals(
left: InstallationRegistryEntry,
right: InstallationRegistryEntry
): boolean {
return (
left.installationId === right.installationId &&
left.agentVersion === right.agentVersion &&
left.manifestSha256 === right.manifestSha256 &&
left.arch === right.arch
)
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error
}
function isConcurrentRegistryError(error: unknown): boolean {
return (
error instanceof Error &&
error.message.includes('changed concurrently')
)
}
@@ -0,0 +1,223 @@
import {
createHash,
generateKeyPairSync,
sign,
type KeyObject
} from 'node:crypto'
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
type AgentBundleManifest,
type AgentReleaseKeyRegistry
} from '../shared/agent-installation-contracts'
import {
canonicalInstalledAgentManifestBytes,
installedAgentManifestSignaturePayload,
verifyInstalledAgentBundle
} from './installed-bundle-verifier'
let root: string
let installationDirectory: string
let privateKey: KeyObject
let releaseKeyRegistry: AgentReleaseKeyRegistry
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'goodbuddy-installed-agent-'))
installationDirectory = join(root, 'install-10')
mkdirSync(installationDirectory, { mode: 0o700 })
const generated = generateKeyPairSync('ed25519')
privateKey = generated.privateKey
releaseKeyRegistry = {
formatVersion: 1,
keys: [
{
keyId: 'installed-test-key',
publicKeySpkiBase64: generated.publicKey
.export({ format: 'der', type: 'spki' })
.toString('base64'),
environment: 'test'
}
],
revocations: []
}
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
describe('installed Agent bundle verifier', () => {
it('binds the installation identity to a signed complete payload', async () => {
writeBundle()
await expect(verifyBundle()).resolves.toMatchObject({
installationId: 'install-10',
binaryDigest: `sha256:${createHash('sha256')
.update(readFileSync(join(installationDirectory, 'manifest.json')))
.digest('hex')}`,
manifest: {
agentVersion: '0.11.0',
protocol: { major: 1, minor: 0 }
}
})
await expect(
verifyInstalledAgentBundle(installationDirectory, {
installationId: 'different-id',
architecture: 'x64',
releaseKeyRegistry,
verificationEnvironment: 'test'
})
).rejects.toThrow('does not match')
})
it('rejects tampering, undeclared files, and symlinks', async () => {
writeBundle()
writeFileSync(
join(installationDirectory, 'lib', 'agent.cjs'),
'tampered'
)
await expect(verifyBundle()).rejects.toThrow(
/size mismatch|hash mismatch/u
)
resetInstallation()
writeBundle()
writeFileSync(join(installationDirectory, 'undeclared'), 'no')
await expect(verifyBundle()).rejects.toThrow('undeclared')
if (process.platform !== 'win32') {
resetInstallation()
writeBundle()
rmSync(join(installationDirectory, 'lib', 'agent.cjs'))
symlinkSync(
join(installationDirectory, 'goodbuddy-agent'),
join(installationDirectory, 'lib', 'agent.cjs')
)
await expect(verifyBundle()).rejects.toThrow(/regular file|symlink/u)
}
})
it('rejects mode, architecture, and signature violations', async () => {
if (process.platform !== 'win32') {
writeBundle()
chmodSync(join(installationDirectory, 'goodbuddy-agent'), 0o777)
await expect(verifyBundle()).rejects.toThrow('mode mismatch')
resetInstallation()
}
writeBundle('arm64')
await expect(verifyBundle()).rejects.toThrow(
'current host'
)
resetInstallation()
writeBundle()
writeFileSync(
join(installationDirectory, 'manifest.sig'),
`${Buffer.alloc(64).toString('base64')}\n`,
{ mode: 0o644 }
)
await expect(verifyBundle()).rejects.toThrow(
'signature verification failed'
)
})
})
function resetInstallation(): void {
rmSync(installationDirectory, { recursive: true, force: true })
mkdirSync(installationDirectory, { mode: 0o700 })
}
function writeBundle(arch: 'x64' | 'arm64' = 'x64'): void {
for (const directory of ['lib', 'licenses']) {
mkdirSync(join(installationDirectory, directory), { mode: 0o700 })
}
const contents = new Map<string, Buffer>([
['node', elf(arch)],
['goodbuddy-agent', Buffer.from('#!/bin/sh\n')],
['lib/agent.cjs', Buffer.from('module.exports = {}\n')],
['licenses/GoodBuddy-0BSD.txt', Buffer.from('0BSD')]
])
for (const [path, bytes] of contents) {
writeFileSync(
join(installationDirectory, ...path.split('/')),
bytes,
{ mode: path === 'node' || path === 'goodbuddy-agent' ? 0o755 : 0o644 }
)
}
const manifest: AgentBundleManifest = {
formatVersion: 1,
product: 'GoodBuddy',
agentVersion: '0.11.0',
platform: 'linux',
arch,
protocol: { major: 1, minor: 0 },
signingKeyId: 'installed-test-key',
entrypoint: {
path: 'goodbuddy-agent',
runtimePath: 'node',
scriptPath: 'lib/agent.cjs'
},
files: [...contents].map(([path, bytes]) => ({
path,
size: bytes.byteLength,
sha256: createHash('sha256').update(bytes).digest('hex'),
mode:
path === 'node' || path === 'goodbuddy-agent'
? '0755'
: '0644'
})),
licenses: [
{
package: 'GoodBuddy',
version: '0.11.0',
spdx: '0BSD',
path: 'licenses/GoodBuddy-0BSD.txt'
}
]
}
const bytes = canonicalInstalledAgentManifestBytes(manifest)
writeFileSync(
join(installationDirectory, 'manifest.json'),
bytes,
{ mode: 0o644 }
)
writeFileSync(
join(installationDirectory, 'manifest.sig'),
`${sign(
null,
installedAgentManifestSignaturePayload(bytes),
privateKey
).toString('base64')}\n`,
{ mode: 0o644 }
)
}
function verifyBundle() {
return verifyInstalledAgentBundle(installationDirectory, {
installationId: 'install-10',
architecture: 'x64',
releaseKeyRegistry,
verificationEnvironment: 'test',
enforceFilesystemMode: process.platform !== 'win32'
})
}
function elf(architecture: 'x64' | 'arm64'): Buffer {
const header = Buffer.alloc(64)
header.set([0x7f, 0x45, 0x4c, 0x46])
header[5] = 1
header.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18)
return header
}
@@ -0,0 +1,488 @@
import {
createHash,
createPublicKey,
verify
} from 'node:crypto'
import {
lstat,
readFile,
readdir
} from 'node:fs/promises'
import { basename, join, relative, resolve, sep } from 'node:path'
import {
agentBundleManifestSchema,
agentReleaseKeyRegistrySchema,
type AgentArchitecture,
type AgentBundleManifest,
type AgentReleaseKey,
type AgentReleaseKeyRegistry
} from '../shared/agent-installation-contracts'
import { assertAbsoluteManagedPath } from './managed-paths'
import {
assertElfArchitecture,
sha256File
} from './bundle-file-verification'
const SIGNATURE_DOMAIN = Buffer.from(
'GoodBuddy Agent Bundle Manifest Signature v1\0',
'utf8'
)
const MAXIMUM_METADATA_BYTES = 1024 * 1024
const MANIFEST_FILE_NAME = 'manifest.json'
const SIGNATURE_FILE_NAME = 'manifest.sig'
const installationIdPattern =
/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u
export type InstalledBundleVerificationEnvironment =
| 'production'
| 'test'
export type VerifiedInstalledAgentBundle = {
installationId: string
installationDirectory: string
executablePath: string
manifest: AgentBundleManifest
manifestSha256: string
binaryDigest: string
}
export type VerifyInstalledAgentBundleOptions = {
installationId: string
architecture: AgentArchitecture
releaseKeyRegistry: AgentReleaseKeyRegistry
verificationEnvironment?: InstalledBundleVerificationEnvironment
enforceFilesystemMode?: boolean
filesystemPlatform?: NodeJS.Platform
uid?: number
}
export async function readManagedAgentReleaseKeyRegistry(
filePath: string,
options: { uid?: number; filesystemPlatform?: NodeJS.Platform } = {}
): Promise<AgentReleaseKeyRegistry> {
const normalizedPath = assertAbsoluteManagedPath(resolve(filePath))
const stat = await lstat(normalizedPath)
assertOwnedRegularFile(stat, normalizedPath, options.uid)
if (
shouldEnforceMode(options) &&
modeString(stat.mode) !== '0600'
) {
throw new Error(
'Managed Agent release-key registry permissions must be 0600'
)
}
if (stat.size > MAXIMUM_METADATA_BYTES) {
throw new Error('Managed Agent release-key registry exceeds its size limit')
}
let value: unknown
try {
value = JSON.parse(
(await readFile(normalizedPath)).toString('utf8')
)
} catch (error) {
throw new Error('Managed Agent release-key registry is invalid JSON', {
cause: error
})
}
return parseReleaseKeyRegistry(value)
}
export async function verifyInstalledAgentBundle(
installationDirectoryInput: string,
options: VerifyInstalledAgentBundleOptions
): Promise<VerifiedInstalledAgentBundle> {
const installationId = validateInstallationId(options.installationId)
const installationDirectory = resolve(installationDirectoryInput)
assertAbsoluteManagedPath(installationDirectory)
if (basename(installationDirectory) !== installationId) {
throw new Error(
'Agent installation ID does not match its managed directory'
)
}
const releaseKeyRegistry = parseReleaseKeyRegistry(
options.releaseKeyRegistry
)
const verificationEnvironment =
options.verificationEnvironment ?? 'production'
assertRegistryAvailable(releaseKeyRegistry, verificationEnvironment)
const rootStat = await lstat(installationDirectory)
assertOwnedDirectory(
rootStat,
'Agent installation directory',
options.uid
)
if (
shouldEnforceMode(options) &&
modeString(rootStat.mode) !== '0700'
) {
throw new Error(
'Agent installation directory permissions must be 0700'
)
}
const manifestBytes = await readMetadataFile(
join(installationDirectory, MANIFEST_FILE_NAME),
'Agent manifest',
options
)
const signatureBytes = await readSignature(
join(installationDirectory, SIGNATURE_FILE_NAME),
options
)
const manifest = verifyInstalledAgentManifestSignature(
manifestBytes,
signatureBytes,
releaseKeyRegistry,
verificationEnvironment
)
if (manifest.arch !== options.architecture) {
throw new Error(
'Agent manifest architecture does not match the current host'
)
}
const declaredPaths = new Set(
manifest.files.map((file) => file.path)
)
for (const file of manifest.files) {
const filePath = join(
installationDirectory,
...file.path.split('/')
)
const stat = await lstat(filePath)
assertOwnedRegularFile(stat, file.path, options.uid)
if (stat.size !== file.size) {
throw new Error(`Agent payload size mismatch: ${file.path}`)
}
if ((await sha256File(filePath)) !== file.sha256) {
throw new Error(`Agent payload hash mismatch: ${file.path}`)
}
if (
shouldEnforceMode(options) &&
modeString(stat.mode) !== file.mode
) {
throw new Error(`Agent payload mode mismatch: ${file.path}`)
}
}
const actualPaths = new Set(
await listInstallationFiles(installationDirectory, options)
)
const expectedPaths = new Set([
...declaredPaths,
MANIFEST_FILE_NAME,
SIGNATURE_FILE_NAME
])
if (
actualPaths.size !== expectedPaths.size ||
[...actualPaths].some((path) => !expectedPaths.has(path))
) {
throw new Error(
'Agent installation contains undeclared or missing files'
)
}
await assertElfArchitecture(
join(installationDirectory, manifest.entrypoint.runtimePath),
manifest.arch,
'Agent Node runtime'
)
const manifestSha256 = sha256Bytes(manifestBytes)
return {
installationId,
installationDirectory,
executablePath: join(
installationDirectory,
manifest.entrypoint.path
),
manifest,
manifestSha256,
binaryDigest: `sha256:${manifestSha256}`
}
}
export function canonicalInstalledAgentManifestBytes(
manifest: AgentBundleManifest
): Buffer {
return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
}
export function installedAgentManifestSignaturePayload(
manifestBytes: Uint8Array
): Buffer {
return Buffer.concat([
SIGNATURE_DOMAIN,
Buffer.from(manifestBytes)
])
}
export function verifyInstalledAgentManifestSignature(
manifestBytes: Buffer,
signatureBytes: Buffer,
registryInput: AgentReleaseKeyRegistry,
verificationEnvironment: InstalledBundleVerificationEnvironment =
'production'
): AgentBundleManifest {
const registry = parseReleaseKeyRegistry(registryInput)
assertRegistryAvailable(registry, verificationEnvironment)
let untrusted: unknown
try {
untrusted = JSON.parse(manifestBytes.toString('utf8'))
} catch (error) {
throw new Error('Agent manifest JSON is invalid', { cause: error })
}
const parsed = agentBundleManifestSchema.safeParse(untrusted)
if (!parsed.success) {
throw new Error(
`Agent manifest contract is invalid: ${parsed.error.message}`
)
}
const manifest = parsed.data
if (
!canonicalInstalledAgentManifestBytes(manifest).equals(manifestBytes)
) {
throw new Error(
'Agent manifest is not in canonical deterministic form'
)
}
const key = trustedKeyForManifest(
manifest,
registry,
verificationEnvironment
)
const publicKey = importPublicKey(key)
if (
signatureBytes.length !== 64 ||
!verify(
null,
installedAgentManifestSignaturePayload(manifestBytes),
publicKey,
signatureBytes
)
) {
throw new Error('Agent manifest signature verification failed')
}
return manifest
}
function validateInstallationId(value: string): string {
if (value.length > 128 || !installationIdPattern.test(value)) {
throw new Error('Invalid Agent installation ID')
}
return value
}
function parseReleaseKeyRegistry(
value: unknown
): AgentReleaseKeyRegistry {
const parsed = agentReleaseKeyRegistrySchema.safeParse(value)
if (!parsed.success) {
throw new Error(
`Agent trusted key registry contract is invalid: ${parsed.error.message}`
)
}
for (const key of parsed.data.keys) {
importPublicKey(key)
}
return parsed.data
}
function assertRegistryAvailable(
registry: AgentReleaseKeyRegistry,
environment: InstalledBundleVerificationEnvironment
): void {
if (
environment === 'production' &&
!registry.keys.some((key) => key.environment === 'production')
) {
throw new Error(
'Agent production key registry is empty; installed Agent verification is unavailable'
)
}
}
function trustedKeyForManifest(
manifest: AgentBundleManifest,
registry: AgentReleaseKeyRegistry,
environment: InstalledBundleVerificationEnvironment
): AgentReleaseKey {
const key = registry.keys.find(
(candidate) => candidate.keyId === manifest.signingKeyId
)
if (key === undefined) {
throw new Error(
`Agent manifest uses an unknown signing key: ${manifest.signingKeyId}`
)
}
if (environment === 'production' && key.environment !== 'production') {
throw new Error(
`Production Agent verification rejects non-production key: ${key.keyId}`
)
}
if (
registry.revocations.some(
(revocation) => revocation.keyId === key.keyId
)
) {
throw new Error(`Agent signing key is revoked: ${key.keyId}`)
}
return key
}
function importPublicKey(key: AgentReleaseKey) {
let publicKey
try {
publicKey = createPublicKey({
key: Buffer.from(key.publicKeySpkiBase64, 'base64'),
format: 'der',
type: 'spki'
})
} catch (error) {
throw new Error(`Agent trusted public key is invalid: ${key.keyId}`, {
cause: error
})
}
if (publicKey.asymmetricKeyType !== 'ed25519') {
throw new Error(
`Agent trusted public key is not Ed25519: ${key.keyId}`
)
}
return publicKey
}
async function readMetadataFile(
filePath: string,
label: string,
options: Pick<
VerifyInstalledAgentBundleOptions,
'uid' | 'filesystemPlatform' | 'enforceFilesystemMode'
>
): Promise<Buffer> {
const stat = await lstat(filePath)
assertOwnedRegularFile(stat, label, options.uid)
if (
shouldEnforceMode(options) &&
modeString(stat.mode) !== '0644'
) {
throw new Error(`Agent payload mode mismatch: ${basename(filePath)}`)
}
if (stat.size > MAXIMUM_METADATA_BYTES) {
throw new Error(`${label} exceeds its size limit`)
}
return readFile(filePath)
}
async function readSignature(
filePath: string,
options: Pick<
VerifyInstalledAgentBundleOptions,
'uid' | 'filesystemPlatform' | 'enforceFilesystemMode'
>
): Promise<Buffer> {
const text = (await readMetadataFile(
filePath,
'Agent detached signature',
options
)).toString('utf8')
if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(text)) {
throw new Error('Agent detached signature encoding is invalid')
}
return Buffer.from(text.trim(), 'base64')
}
async function listInstallationFiles(
installationDirectory: string,
options: Pick<
VerifyInstalledAgentBundleOptions,
'uid' | 'filesystemPlatform' | 'enforceFilesystemMode'
>
): Promise<string[]> {
const files: string[] = []
const pending = [installationDirectory]
while (pending.length > 0) {
const directory = pending.pop()
if (directory === undefined) {
continue
}
const entries = await readdir(directory, { withFileTypes: true })
for (const entry of entries) {
const absolutePath = join(directory, entry.name)
if (entry.isSymbolicLink()) {
throw new Error(
`Agent installation cannot contain a symlink: ${absolutePath}`
)
}
if (entry.isDirectory()) {
const stat = await lstat(absolutePath)
assertOwnedDirectory(stat, absolutePath, options.uid)
if (
shouldEnforceMode(options) &&
modeString(stat.mode) !== '0700'
) {
throw new Error(
`Agent installation directory permissions must be 0700: ${absolutePath}`
)
}
pending.push(absolutePath)
} else if (entry.isFile()) {
files.push(
relative(installationDirectory, absolutePath)
.split(sep)
.join('/')
)
} else {
throw new Error(
`Agent installation contains an unsupported file: ${absolutePath}`
)
}
}
}
return files
}
function assertOwnedDirectory(
stat: Awaited<ReturnType<typeof lstat>>,
label: string,
uid = process.getuid?.()
): void {
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(`${label} is not a real directory`)
}
if (uid !== undefined && stat.uid !== uid) {
throw new Error(`${label} is not owned by the current user`)
}
}
function assertOwnedRegularFile(
stat: Awaited<ReturnType<typeof lstat>>,
label: string,
uid = process.getuid?.()
): void {
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(`${label} is not a regular file`)
}
if (uid !== undefined && stat.uid !== uid) {
throw new Error(`${label} is not owned by the current user`)
}
}
function shouldEnforceMode(
options: {
enforceFilesystemMode?: boolean
filesystemPlatform?: NodeJS.Platform
}
): boolean {
return (
options.enforceFilesystemMode !== false &&
(options.filesystemPlatform ?? process.platform) !== 'win32'
)
}
function modeString(mode: number): string {
return (mode & 0o777).toString(8).padStart(4, '0')
}
function sha256Bytes(contents: Uint8Array): string {
return createHash('sha256').update(contents).digest('hex')
}
+300
View File
@@ -0,0 +1,300 @@
import {
chmodSync,
closeSync,
fstatSync,
mkdirSync,
mkdtempSync,
openSync,
rmSync,
symlinkSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createConnection, createServer, type Socket } from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
import { AGENT_PROTOCOL_VERSION } from '../shared/agent-protocol/contracts'
import {
decodeAgentFrame,
encodeAgentFrame
} from '../shared/agent-protocol/frame'
import {
ControllerRegistry,
ControllerRegistryError
} from './controller-registry'
import { EventJournal } from './event-journal'
import { InstallationChallengeVerifier } from './installation-challenge'
import {
ensurePrivateDirectory,
ManagedPathError
} from './managed-paths'
import { AgentProtocolServer } from './protocol-server'
const temporaryPaths: string[] = []
const digest = `sha256:${'a'.repeat(64)}`
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('managed daemon lifecycle', () => {
it('creates private directories and rejects symlink-managed paths', () => {
const root = temporaryDirectory()
const privateDirectory = join(root, 'private')
ensurePrivateDirectory(privateDirectory)
if (process.platform !== 'win32') {
expect(chmodMode(privateDirectory)).toBe(0o700)
}
const target = join(root, 'target')
mkdirSync(target)
const link = join(root, 'link')
symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir')
expect(() => ensurePrivateDirectory(resolve(link))).toThrow(ManagedPathError)
})
it('uses one-shot installation HMAC challenges', () => {
let now = 10
const verifier = new InstallationChallengeVerifier(Buffer.alloc(32, 7), {
now: () => now,
lifetimeMs: 100
})
const challenge = verifier.issue()
const response = verifier.createResponse({
serverNonce: challenge.serverNonce,
clientNonce: 'client-1',
controllerId: 'controller-1'
})
expect(
verifier.verify({
serverNonce: challenge.serverNonce,
clientNonce: 'client-1',
controllerId: 'controller-1',
response
})
).toBe(true)
expect(
verifier.verify({
serverNonce: challenge.serverNonce,
clientNonce: 'client-1',
controllerId: 'controller-1',
response
})
).toBe(false)
const expired = verifier.issue()
now = 111
expect(
verifier.verify({
serverNonce: expired.serverNonce,
clientNonce: 'client-1',
controllerId: 'controller-1',
response: verifier.createResponse({
serverNonce: expired.serverNonce,
clientNonce: 'client-1',
controllerId: 'controller-1'
})
})
).toBe(false)
})
it('persists generations and enforces controller ownership', () => {
const root = privateTemporaryDirectory()
const registryPath = resolve(root, 'controllers.json')
const first = new ControllerRegistry({ storagePath: registryPath })
const attached = first.attach('controller-a')
first.claim(
attached.controllerId,
attached.generation,
'process',
'process-a'
)
const second = new ControllerRegistry({ storagePath: registryPath })
const resumed = second.attach('controller-a')
expect(resumed.generation).toBe(attached.generation + 1)
expect(() =>
second.assertCurrent('controller-a', attached.generation)
).toThrow(ControllerRegistryError)
expect(() =>
second.assertOwner(
'controller-b',
resumed.generation,
'process',
'process-a'
)
).toThrow(ControllerRegistryError)
expect(() =>
second.assertOwner(
'controller-a',
resumed.generation,
'process',
'process-a'
)
).not.toThrow()
})
it('frames attach control requests with stale-generation rejection', async () => {
const root = privateTemporaryDirectory()
const events = new EventJournal(resolve(root, 'events.sqlite'))
const controllers = new ControllerRegistry()
const controller = controllers.attach('controller-a')
const protocol = new AgentProtocolServer({
controllers,
events,
status: () => ({
state: 'ready',
installationId: 'install-1',
binaryDigest: digest,
daemonBootId: 'boot-1',
agentVersion: '1.0.0',
protocol: AGENT_PROTOCOL_VERSION,
platform: 'linux',
architecture: 'x64',
supervisor: 'detached-on-demand',
remoteUserIdentity: 'uid:1000',
draining: false
})
})
const server = createServer((socket) => protocol.accept(socket, controller))
await listen(server)
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('Test server did not open a TCP endpoint')
}
const client = createConnection(address.port, '127.0.0.1')
try {
await onceConnected(client)
const payload = Buffer.from(
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'agent/status',
params: {}
})
)
client.write(
encodeAgentFrame({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: controller.connectionId,
generation: controller.generation,
channelId: 'control',
channelEpoch: '1',
direction: 'main-to-agent',
sequence: '1',
kind: 'control',
payloadLength: payload.byteLength
},
payload
})
)
const replies = await readFrames(client, 2)
expect(
JSON.parse(Buffer.from(replies[0]!.payload).toString('utf8'))
).toMatchObject({
id: 1,
result: {
state: 'ready',
installationId: 'install-1',
binaryDigest: digest
}
})
controllers.attach('controller-a')
client.write(
encodeAgentFrame({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: controller.connectionId,
generation: controller.generation,
channelId: 'control',
channelEpoch: '1',
direction: 'main-to-agent',
sequence: '2',
kind: 'control',
payloadLength: payload.byteLength
},
payload
})
)
await onceClosed(client)
} finally {
client.destroy()
await new Promise<void>((resolveClose) =>
server.close(() => resolveClose())
)
events.close()
}
})
})
function temporaryDirectory(): string {
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-agent-'))
temporaryPaths.push(path)
return path
}
function privateTemporaryDirectory(): string {
const path = temporaryDirectory()
if (process.platform !== 'win32') {
chmodSync(path, 0o700)
}
return path
}
function chmodMode(path: string): number {
const descriptor = openSync(path, 'r')
try {
return fstatSync(descriptor).mode & 0o777
} finally {
closeSync(descriptor)
}
}
async function listen(server: ReturnType<typeof createServer>): Promise<void> {
await new Promise<void>((resolveListen, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolveListen)
})
}
async function onceConnected(socket: Socket): Promise<void> {
await new Promise<void>((resolveConnect, reject) => {
socket.once('connect', resolveConnect)
socket.once('error', reject)
})
}
async function onceClosed(socket: Socket): Promise<void> {
await new Promise<void>((resolveClose) => socket.once('close', resolveClose))
}
async function readFrames(
socket: Socket,
count: number
): Promise<ReturnType<typeof decodeAgentFrame>[]> {
return await new Promise((resolveFrames, reject) => {
let buffer = Buffer.alloc(0)
const frames: ReturnType<typeof decodeAgentFrame>[] = []
socket.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk])
while (buffer.byteLength >= 40) {
const length =
40 +
buffer.readUInt16BE(32) +
buffer.readUInt16BE(34) +
buffer.readUInt32BE(36)
if (buffer.byteLength < length) {
break
}
frames.push(decodeAgentFrame(buffer.subarray(0, length)))
buffer = buffer.subarray(length)
}
if (frames.length >= count) {
resolveFrames(frames)
}
})
socket.once('error', reject)
})
}
@@ -0,0 +1,141 @@
import { resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
LinuxProcessInspector,
type LinuxProcFileSystem,
type LinuxProcessIdentity
} from './linux-process-identity'
describe('Linux process identity', () => {
it('reads only PID, start time, and canonical executable', () => {
const executablePath = resolve('fixtures', 'runtime', 'node')
const inspector = new LinuxProcessInspector({
procRoot: resolve('fixtures', 'proc'),
fileSystem: procFileSystem({
executablePath,
stat: procStat('98765', 'worker ) name')
})
})
expect(inspector.inspect(4242)).toEqual({
pid: 4242,
starttime: '98765',
executablePath
})
})
it('returns absent only for a missing process and rejects malformed proc data', () => {
expect(
new LinuxProcessInspector({
procRoot: resolve('fixtures', 'proc'),
fileSystem: procFileSystem({
executablePath: resolve('fixtures', 'node'),
missingPid: true
})
}).inspect(4242)
).toBeUndefined()
expect(() =>
new LinuxProcessInspector({
procRoot: resolve('fixtures', 'proc'),
fileSystem: procFileSystem({
executablePath: resolve('fixtures', 'node'),
stat: '42 malformed'
})
}).inspect(4242)
).toThrow()
})
it.each([
['start time', { starttime: '98766' }],
['executable', { executablePath: resolve('fixtures', 'other-node') }]
])('refuses an identity with mismatched %s', (_label, change) => {
const { inspector, identity } = fixtureInspector()
expect(inspector.matches({ ...identity, ...change })).toBe(false)
})
it('signals only an exact process identity and handles ESRCH', () => {
const { inspector, identity, kill } = fixtureInspector()
expect(inspector.signal(identity, 'SIGTERM')).toBe(true)
expect(kill).toHaveBeenCalledWith(identity.pid, 'SIGTERM')
kill.mockClear()
expect(
inspector.signal({ ...identity, starttime: '98766' }, 'SIGKILL')
).toBe(false)
expect(kill).not.toHaveBeenCalled()
const gone = new LinuxProcessInspector({
procRoot: resolve('fixtures', 'proc'),
fileSystem: procFileSystem({
executablePath: identity.executablePath
}),
kill: () => {
throw nodeError('ESRCH')
}
})
expect(gone.signal(identity, 'SIGTERM')).toBe(false)
})
})
function fixtureInspector(): {
inspector: LinuxProcessInspector
identity: LinuxProcessIdentity
kill: ReturnType<typeof vi.fn<(pid: number, signal: NodeJS.Signals | 0) => void>>
} {
const executablePath = resolve('fixtures', 'runtime', 'node')
const kill = vi.fn<(pid: number, signal: NodeJS.Signals | 0) => void>()
return {
inspector: new LinuxProcessInspector({
procRoot: resolve('fixtures', 'proc'),
fileSystem: procFileSystem({ executablePath }),
kill
}),
identity: {
pid: 4242,
starttime: '98765',
executablePath
},
kill
}
}
function procFileSystem(options: {
executablePath: string
stat?: string
missingPid?: boolean
}): LinuxProcFileSystem {
return {
readText(path) {
if (!path.endsWith('4242\\stat') && !path.endsWith('4242/stat')) {
throw new Error(`Unexpected proc text read: ${path}`)
}
if (options.missingPid) {
throw nodeError('ENOENT')
}
return options.stat ?? procStat('98765')
},
readLink(path) {
if (!path.endsWith('4242\\exe') && !path.endsWith('4242/exe')) {
throw new Error(`Unexpected proc link read: ${path}`)
}
return options.executablePath
},
realpath(path) {
return path
}
}
}
function procStat(starttime: string, name = 'agent'): string {
const fields = ['S']
while (fields.length < 19) {
fields.push('0')
}
fields.push(starttime, '0')
return `4242 (${name}) ${fields.join(' ')}\n`
}
function nodeError(code: string): NodeJS.ErrnoException {
return Object.assign(new Error(code), { code })
}
+168
View File
@@ -0,0 +1,168 @@
import {
lstatSync,
readFileSync,
readlinkSync,
realpathSync
} from 'node:fs'
import { isAbsolute, join, resolve } from 'node:path'
import { z } from 'zod'
import { assertAbsoluteManagedPath } from './managed-paths'
const linuxProcessIdentitySchema = z
.object({
pid: z.number().int().positive().safe(),
starttime: z.string().regex(/^(?:0|[1-9][0-9]*)$/u),
executablePath: z.string().min(1)
})
.strict()
export type LinuxProcessIdentity = z.infer<
typeof linuxProcessIdentitySchema
>
export type LinuxProcessInspectorOptions = {
procRoot?: string
kill?: (pid: number, signal: NodeJS.Signals | 0) => void
fileSystem?: LinuxProcFileSystem
}
export interface LinuxProcFileSystem {
readText(path: string): string
readLink(path: string): string
realpath(path: string): string
}
export class LinuxProcessInspector {
readonly #procRoot: string
readonly #kill: (pid: number, signal: NodeJS.Signals | 0) => void
readonly #fileSystem: LinuxProcFileSystem
constructor(options: LinuxProcessInspectorOptions = {}) {
this.#procRoot = resolve(options.procRoot ?? '/proc')
this.#kill = options.kill ?? process.kill
this.#fileSystem = options.fileSystem ?? nodeProcFileSystem
}
inspect(pidInput: number): LinuxProcessIdentity | undefined {
const pid = positivePid(pidInput)
try {
const starttime = parseStarttime(
this.#fileSystem.readText(
join(this.#procRoot, String(pid), 'stat')
)
)
const executableLink = this.#fileSystem.readLink(
join(this.#procRoot, String(pid), 'exe')
)
if (
executableLink.includes('\0') ||
!isAbsolute(executableLink) ||
resolve(executableLink) !== executableLink
) {
throw new Error('Linux process executable link is malformed')
}
return linuxProcessIdentitySchema.parse({
pid,
starttime,
executablePath: assertAbsoluteManagedPath(
this.#fileSystem.realpath(executableLink)
)
})
} catch (error) {
if (isMissingProcessError(error)) {
return undefined
}
throw error
}
}
matches(expectedInput: LinuxProcessIdentity): boolean {
const expected = linuxProcessIdentitySchema.parse(expectedInput)
const actual = this.inspect(expected.pid)
return (
actual !== undefined &&
actual.starttime === expected.starttime &&
actual.executablePath === expected.executablePath
)
}
signal(
expectedInput: LinuxProcessIdentity,
signal: NodeJS.Signals
): boolean {
const expected = linuxProcessIdentitySchema.parse(expectedInput)
if (!this.matches(expected)) {
return false
}
try {
this.#kill(expected.pid, signal)
return true
} catch (error) {
if (isNodeError(error) && error.code === 'ESRCH') {
return false
}
throw error
}
}
}
export function currentLinuxProcessIdentity(
options: LinuxProcessInspectorOptions = {}
): LinuxProcessIdentity {
const identity = new LinuxProcessInspector(options).inspect(process.pid)
if (identity === undefined) {
throw new Error('Current Linux process identity is unavailable')
}
return identity
}
export function canonicalExecutablePath(path: string): string {
const normalized = assertAbsoluteManagedPath(resolve(path))
const stat = lstatSync(normalized)
if (stat.isSymbolicLink() || !stat.isFile()) {
throw new Error('Agent executable must be a regular file')
}
return assertAbsoluteManagedPath(realpathSync(normalized))
}
function parseStarttime(contents: string): string {
const close = contents.lastIndexOf(')')
if (close < 2 || contents[close + 1] !== ' ') {
throw new Error('Linux process stat is malformed')
}
const starttime = contents
.slice(close + 2)
.trim()
.split(/\s+/u)[19]
if (
starttime === undefined ||
!/^(?:0|[1-9][0-9]*)$/u.test(starttime)
) {
throw new Error('Linux process stat identity is malformed')
}
return starttime
}
const nodeProcFileSystem: LinuxProcFileSystem = {
readText: (path) => readFileSync(path, 'utf8'),
readLink: (path) => readlinkSync(path),
realpath: (path) => realpathSync(path)
}
function positivePid(value: number): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error('Linux process ID is invalid')
}
return value
}
function isMissingProcessError(error: unknown): boolean {
return (
isNodeError(error) &&
(error.code === 'ENOENT' || error.code === 'ESRCH')
)
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error
}
+189
View File
@@ -0,0 +1,189 @@
import {
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertAbsoluteManagedPath,
derivePrivateTemporaryRoot,
ensurePrivateDirectory,
ensurePrivateDirectoryTree,
ensurePrivateTemporaryRoot,
readPrivateFile,
writePrivateFileAtomic
} from './managed-paths'
const temporaryPaths: string[] = []
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('deterministic private temporary root', () => {
it('binds a 0700 root marker to the UID and canonical home', () => {
const temporaryDirectory = privateTemporaryDirectory()
const identity = derivePrivateTemporaryRoot({
platform: 'linux',
uid: 1234,
homeDirectory: resolve(temporaryDirectory, 'home'),
temporaryDirectory
})
expect(identity.rootPath).toContain('goodbuddy-1234-')
expect(ensurePrivateTemporaryRoot(identity)).toBe(identity.rootPath)
const marker = readFileSync(
resolve(identity.rootPath, '.goodbuddy-private-root-v1'),
'utf8'
)
expect(JSON.parse(marker)).toEqual({
formatVersion: 1,
uid: 1234,
canonicalHomeDirectory: resolve(temporaryDirectory, 'home')
})
})
it('rejects an existing marker for another home', () => {
const temporaryDirectory = privateTemporaryDirectory()
const identity = derivePrivateTemporaryRoot({
platform: 'linux',
uid: 1234,
homeDirectory: resolve(temporaryDirectory, 'home'),
temporaryDirectory
})
ensurePrivateTemporaryRoot(identity)
writeFileSync(
resolve(identity.rootPath, '.goodbuddy-private-root-v1'),
'{"formatVersion":1,"uid":1234,"canonicalHomeDirectory":"/foreign"}\n',
{ mode: 0o600 }
)
expect(() => ensurePrivateTemporaryRoot(identity)).toThrow(
'does not match this user'
)
})
it('rejects a corrupt or permissive marker without replacing it', () => {
const temporaryDirectory = privateTemporaryDirectory()
const identity = derivePrivateTemporaryRoot({
platform: 'linux',
uid: 1234,
homeDirectory: resolve(temporaryDirectory, 'home'),
temporaryDirectory
})
ensurePrivateTemporaryRoot(identity)
const markerPath = resolve(
identity.rootPath,
'.goodbuddy-private-root-v1'
)
writeFileSync(markerPath, '{broken-json\n')
expect(() => ensurePrivateTemporaryRoot(identity)).toThrow(
'does not match this user'
)
expect(readFileSync(markerPath, 'utf8')).toBe('{broken-json\n')
if (process.platform !== 'win32') {
writeFileSync(
markerPath,
`${JSON.stringify({
formatVersion: 1,
uid: identity.uid,
canonicalHomeDirectory: identity.canonicalHomeDirectory
}, null, 2)}\n`
)
chmodSync(markerPath, 0o644)
expect(() => ensurePrivateTemporaryRoot(identity)).toThrow(
'permissions must be 0600'
)
}
})
})
describe('managed path containment', () => {
it('requires normalized absolute paths and rejects anchor escapes', () => {
const root = privateTemporaryDirectory()
expect(() => assertAbsoluteManagedPath('relative')).toThrow(
'normalized absolute'
)
expect(() =>
assertAbsoluteManagedPath(`${resolve(root, 'entry')}\0tail`)
).toThrow('cannot contain NUL')
expect(() =>
ensurePrivateDirectoryTree(resolve(root, '..', 'escape'), root)
).toThrow('escapes its configured anchor')
})
it('rejects files, permissive directories, and symlink traversal', () => {
const root = privateTemporaryDirectory()
const filePath = resolve(root, 'file')
writeFileSync(filePath, 'not a directory')
expect(() => ensurePrivateDirectory(filePath)).toThrow(
'not a real directory'
)
if (process.platform !== 'win32') {
const permissive = resolve(root, 'permissive')
mkdirSync(permissive, { mode: 0o755 })
chmodSync(permissive, 0o755)
expect(() => ensurePrivateDirectory(permissive)).toThrow(
'permissions must be 0700'
)
}
const outside = privateTemporaryDirectory()
const junction = resolve(root, 'junction')
symlinkSync(outside, junction, 'junction')
expect(() =>
assertAbsoluteManagedPath(resolve(junction, 'managed.json'))
).toThrow('cannot traverse a symlink')
})
it('atomically writes only private regular targets and reads bounded bytes', () => {
const root = privateTemporaryDirectory()
const target = resolve(root, 'state.json')
writePrivateFileAtomic(target, '{"state":"ready"}\n')
expect(readPrivateFile(target, 64).toString('utf8')).toBe(
'{"state":"ready"}\n'
)
expect(() => readPrivateFile(target, 4)).toThrow('size limit')
if (process.platform !== 'win32') {
chmodSync(target, 0o644)
expect(() => writePrivateFileAtomic(target, 'replacement')).toThrow(
'permissions must be 0600'
)
expect(readFileSync(target, 'utf8')).toBe('{"state":"ready"}\n')
}
})
it('never writes through a symlinked component outside its root', () => {
const root = privateTemporaryDirectory()
const outside = privateTemporaryDirectory()
const sentinel = resolve(outside, 'sentinel.txt')
writeFileSync(sentinel, 'user-owned')
const junction = resolve(root, 'junction')
symlinkSync(outside, junction, 'junction')
expect(() =>
writePrivateFileAtomic(resolve(junction, 'sentinel.txt'), 'changed')
).toThrow('cannot traverse a symlink')
expect(readFileSync(sentinel, 'utf8')).toBe('user-owned')
expect(existsSync(resolve(root, 'sentinel.txt'))).toBe(false)
})
})
function privateTemporaryDirectory(): string {
const path = mkdtempSync(resolve(tmpdir(), 'goodbuddy-paths-'))
temporaryPaths.push(path)
if (process.platform !== 'win32') {
chmodSync(path, 0o700)
}
return resolve(path)
}
+373
View File
@@ -0,0 +1,373 @@
import {
chmodSync,
closeSync,
constants,
fchmodSync,
fstatSync,
lstatSync,
mkdirSync,
openSync,
readSync,
renameSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import type { Stats } from 'node:fs'
import {
dirname,
isAbsolute,
parse,
relative,
resolve,
sep
} from 'node:path'
import { createHash, randomBytes } from 'node:crypto'
import { homedir } from 'node:os'
export class ManagedPathError extends Error {
constructor(message: string) {
super(message)
this.name = 'ManagedPathError'
}
}
function assertOwner(stat: Stats, label: string): void {
const uid = process.getuid?.()
if (uid !== undefined && stat.uid !== uid) {
throw new ManagedPathError(`${label} is not owned by the current user`)
}
}
export function assertAbsoluteManagedPath(path: string): string {
if (path.includes('\0')) {
throw new ManagedPathError('Managed paths cannot contain NUL')
}
if (!isAbsolute(path) || resolve(path) !== path) {
throw new ManagedPathError('Managed paths must be normalized absolute paths')
}
assertNoSymlinkComponents(path)
return path
}
export function ensurePrivateDirectory(
path: string,
options: { create?: boolean } = {}
): void {
assertAbsoluteManagedPath(path)
let created = false
try {
const stat = lstatSync(path)
if (stat.isSymbolicLink() || !stat.isDirectory()) {
throw new ManagedPathError('Managed directory is not a real directory')
}
assertOwner(stat, 'Managed directory')
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
throw new ManagedPathError('Managed directory permissions must be 0700')
}
} catch (error) {
if (
error instanceof ManagedPathError ||
!isNodeError(error) ||
error.code !== 'ENOENT' ||
options.create === false
) {
throw error
}
mkdirSync(path, { mode: 0o700 })
created = true
}
if (created && process.platform !== 'win32') {
chmodSync(path, 0o700)
}
}
function assertNoSymlinkComponents(path: string): void {
const root = parse(path).root
const components = relative(root, path).split(sep).filter(Boolean)
let current = root
for (const component of components) {
current = resolve(current, component)
try {
if (lstatSync(current).isSymbolicLink()) {
throw new ManagedPathError('Managed paths cannot traverse a symlink')
}
} catch (error) {
if (
error instanceof ManagedPathError ||
!isNodeError(error) ||
error.code !== 'ENOENT'
) {
throw error
}
return
}
}
}
export function ensurePrivateDirectoryTree(path: string, anchor: string): void {
const normalizedPath = assertAbsoluteManagedPath(path)
const normalizedAnchor = assertAbsoluteManagedPath(anchor)
if (
normalizedPath !== normalizedAnchor &&
!normalizedPath.startsWith(`${normalizedAnchor}${process.platform === 'win32' ? '\\' : '/'}`)
) {
throw new ManagedPathError('Managed path escapes its configured anchor')
}
const relative = normalizedPath.slice(normalizedAnchor.length)
ensurePrivateDirectory(normalizedAnchor)
let current = normalizedAnchor
for (const segment of relative.split(/[\\/]/u).filter(Boolean)) {
current = resolve(current, segment)
ensurePrivateDirectory(current)
}
}
export function assertPrivateRegularFile(path: string): void {
assertAbsoluteManagedPath(path)
const stat = lstatSync(path)
assertPrivateRegularFileStat(stat)
}
function assertPrivateRegularFileStat(stat: Stats): void {
if (stat.isSymbolicLink() || !stat.isFile()) {
throw new ManagedPathError('Managed file is not a regular file')
}
assertOwner(stat, 'Managed file')
if (process.platform !== 'win32' && (stat.mode & 0o177) !== 0) {
throw new ManagedPathError('Managed file permissions must be 0600')
}
}
export function readOrCreatePrivateSecret(path: string): Buffer {
assertAbsoluteManagedPath(path)
ensurePrivateDirectory(dirname(path), { create: false })
const noFollow = constants.O_NOFOLLOW ?? 0
let descriptor: number
try {
descriptor = openSync(
path,
constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | noFollow,
0o600
)
const secret = randomBytes(32)
try {
writeFileSync(descriptor, secret)
if (process.platform !== 'win32') {
fchmodSync(descriptor, 0o600)
}
} finally {
closeSync(descriptor)
}
return secret
} catch (error) {
if (!isNodeError(error) || error.code !== 'EEXIST') {
throw error
}
}
assertPrivateRegularFile(path)
descriptor = openSync(path, constants.O_RDONLY | noFollow)
try {
const stat = fstatSync(descriptor)
assertPrivateRegularFileStat(stat)
if (stat.size !== 32) {
throw new ManagedPathError('Installation identity must contain 32 bytes')
}
const output = Buffer.alloc(32)
if (readSync(descriptor, output, 0, output.length, 0) !== output.length) {
throw new ManagedPathError('Installation identity could not be read')
}
return output
} finally {
closeSync(descriptor)
}
}
export function readPrivateSecret(path: string): Buffer {
const output = readPrivateFile(path, 32)
if (output.byteLength !== 32) {
throw new ManagedPathError('Installation identity must contain 32 bytes')
}
return output
}
export function readPrivateFile(path: string, maximumBytes: number): Buffer {
assertAbsoluteManagedPath(path)
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) {
throw new ManagedPathError('Managed file size limit is invalid')
}
const noFollow = constants.O_NOFOLLOW ?? 0
const descriptor = openSync(path, constants.O_RDONLY | noFollow)
try {
const stat = fstatSync(descriptor)
assertPrivateRegularFileStat(stat)
if (stat.size > maximumBytes) {
throw new ManagedPathError('Managed file exceeds its size limit')
}
const output = Buffer.alloc(stat.size)
if (
stat.size > 0 &&
readSync(descriptor, output, 0, output.length, 0) !== output.length
) {
throw new ManagedPathError('Managed file could not be read')
}
return output
} finally {
closeSync(descriptor)
}
}
export function writePrivateFileAtomic(path: string, contents: string): void {
assertAbsoluteManagedPath(path)
ensurePrivateDirectory(dirname(path), { create: false })
assertPrivateTargetIfPresent(path)
const temporary = `${path}.tmp-${randomBytes(12).toString('hex')}`
const noFollow = constants.O_NOFOLLOW ?? 0
const descriptor = openSync(
temporary,
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow,
0o600
)
let descriptorOpen = true
let renamed = false
let operationError: unknown
try {
writeFileSync(descriptor, contents, { encoding: 'utf8' })
if (process.platform !== 'win32') {
fchmodSync(descriptor, 0o600)
}
closeSync(descriptor)
descriptorOpen = false
assertPrivateTargetIfPresent(path)
renameSync(temporary, path)
renamed = true
assertPrivateRegularFile(path)
} catch (error) {
operationError = error
}
if (descriptorOpen) {
try {
closeSync(descriptor)
} catch (error) {
operationError ??= error
}
}
if (!renamed) {
try {
unlinkSync(temporary)
} catch (error) {
if (!isNodeError(error) || error.code !== 'ENOENT') {
operationError ??= error
}
}
}
if (operationError !== undefined) {
throw operationError
}
}
export function unlinkOwnedPrivateFile(path: string): void {
assertPrivateRegularFile(path)
unlinkSync(path)
}
const TEMP_ROOT_MARKER = '.goodbuddy-private-root-v1'
export type PrivateTemporaryRootIdentity = {
rootPath: string
uid: number
canonicalHomeDirectory: string
}
export function derivePrivateTemporaryRoot(options: {
uid?: number
homeDirectory?: string
temporaryDirectory?: string
platform?: NodeJS.Platform
} = {}): PrivateTemporaryRootIdentity {
const platform = options.platform ?? process.platform
const uid = options.uid ?? process.getuid?.()
if (
platform !== 'linux' ||
uid === undefined ||
!Number.isSafeInteger(uid) ||
uid < 0
) {
throw new ManagedPathError(
'Private Agent endpoint root requires a current Linux UID'
)
}
const canonicalHomeDirectory = resolve(
options.homeDirectory ?? homedir()
)
const temporaryDirectory = resolve(
options.temporaryDirectory ?? '/tmp'
)
assertAbsoluteManagedPath(canonicalHomeDirectory)
assertAbsoluteManagedPath(temporaryDirectory)
const homeHash = createHash('sha256')
.update(`${uid}\0${canonicalHomeDirectory}`, 'utf8')
.digest('hex')
.slice(0, 20)
return {
rootPath: resolve(
temporaryDirectory,
`goodbuddy-${uid}-${homeHash}`
),
uid,
canonicalHomeDirectory
}
}
export function ensurePrivateTemporaryRoot(
identityInput: PrivateTemporaryRootIdentity
): string {
const identity = {
rootPath: assertAbsoluteManagedPath(identityInput.rootPath),
uid: identityInput.uid,
canonicalHomeDirectory: assertAbsoluteManagedPath(
identityInput.canonicalHomeDirectory
)
}
if (!Number.isSafeInteger(identity.uid) || identity.uid < 0) {
throw new ManagedPathError('Private Agent endpoint root UID is invalid')
}
ensurePrivateDirectory(identity.rootPath)
const markerPath = resolve(identity.rootPath, TEMP_ROOT_MARKER)
const expected = `${JSON.stringify({
formatVersion: 1,
uid: identity.uid,
canonicalHomeDirectory: identity.canonicalHomeDirectory
}, null, 2)}\n`
try {
assertPrivateRegularFile(markerPath)
if (readPrivateText(markerPath) !== expected) {
throw new ManagedPathError(
'Private Agent endpoint root marker does not match this user'
)
}
} catch (error) {
if (!isNodeError(error) || error.code !== 'ENOENT') {
throw error
}
writePrivateFileAtomic(markerPath, expected)
}
return identity.rootPath
}
function readPrivateText(path: string): string {
return readPrivateFile(path, 16 * 1024).toString('utf8')
}
function assertPrivateTargetIfPresent(path: string): void {
try {
assertPrivateRegularFile(path)
} catch (error) {
if (!isNodeError(error) || error.code !== 'ENOENT') {
throw error
}
}
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error
}
+805
View File
@@ -0,0 +1,805 @@
import { randomUUID } from 'node:crypto'
import {
chmodSync,
lstatSync,
type Stats,
unlinkSync
} from 'node:fs'
import {
createConnection,
createServer,
type Server,
type Socket
} from 'node:net'
import { dirname, join } from 'node:path'
import { TextDecoder } from 'node:util'
import { z } from 'zod'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
remoteModelGatewayRequestSchema,
remoteModelGatewayResponseSchema,
type RemoteModelGatewayRequest,
type RemoteModelGatewayResponse
} from '../shared/remote-model-gateway-contracts'
import {
assertAbsoluteManagedPath,
ensurePrivateDirectory
} from './managed-paths'
import { AgentUnsupportedError } from './errors'
import {
boundedInteger,
closeServer,
isNodeError,
raceWithAbort,
settleWithin
} from './async-utils'
export const MODEL_BRIDGE_BROKER_SOCKET_NAME =
'model-bridge.sock'
const BROKER_PROTOCOL_VERSION = 1
const DEFAULT_MAXIMUM_CONNECTIONS = 8
const DEFAULT_REQUEST_TIMEOUT_MS = 150_000
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
const CLOSE_TIMEOUT_MS = 2_000
const MAXIMUM_UNIX_SOCKET_PATH_BYTES = 107
const MAXIMUM_FRAME_BYTES =
Math.ceil(
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes / 3
) *
4 +
64 * 1_024
const requestIdSchema = z
.string()
.min(1)
.max(128)
.regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u)
const brokerRequestPacketSchema = z
.object({
version: z.literal(BROKER_PROTOCOL_VERSION),
type: z.literal('model-request'),
requestId: requestIdSchema,
request: remoteModelGatewayRequestSchema
})
.strict()
const brokerSuccessPacketSchema = z
.object({
version: z.literal(BROKER_PROTOCOL_VERSION),
type: z.literal('model-response'),
requestId: requestIdSchema,
ok: z.literal(true),
response: remoteModelGatewayResponseSchema
})
.strict()
const brokerFailureCodeSchema = z.enum([
'request-invalid',
'request-timeout',
'request-cancelled',
'dispatch-failed'
])
const brokerFailurePacketSchema = z
.object({
version: z.literal(BROKER_PROTOCOL_VERSION),
type: z.literal('model-response'),
requestId: requestIdSchema,
ok: z.literal(false),
error: z
.object({
code: brokerFailureCodeSchema
})
.strict()
})
.strict()
const brokerResponsePacketSchema = z.discriminatedUnion('ok', [
brokerSuccessPacketSchema,
brokerFailurePacketSchema
])
const brokerDeliveryPacketSchema = z
.object({
version: z.literal(BROKER_PROTOCOL_VERSION),
type: z.literal('response-delivered'),
requestId: requestIdSchema
})
.strict()
const brokerDeliveryAcceptedPacketSchema = z
.object({
version: z.literal(BROKER_PROTOCOL_VERSION),
type: z.literal('delivery-accepted'),
requestId: requestIdSchema
})
.strict()
export type ModelBridgeDispatchContext = {
requestId: string
signal: AbortSignal
}
export type ModelBridgeExchangeResult = {
response: RemoteModelGatewayResponse
acknowledgeDelivery(): Promise<void>
failDelivery(reason?: unknown): void | Promise<void>
}
export type ModelBridgeBrokerDispatch = (
request: RemoteModelGatewayRequest,
context: ModelBridgeDispatchContext
) => Promise<ModelBridgeExchangeResult | RemoteModelGatewayResponse>
export type ModelBridgeExchange = (
request: RemoteModelGatewayRequest,
context: ModelBridgeDispatchContext
) => Promise<ModelBridgeExchangeResult | RemoteModelGatewayResponse>
export class ModelBridgeBrokerError extends Error {
readonly code:
| 'request-invalid'
| 'request-timeout'
| 'request-cancelled'
| 'dispatch-failed'
| 'transport-failed'
constructor(code: ModelBridgeBrokerError['code']) {
super(modelBridgeBrokerErrorMessage(code))
this.name = 'ModelBridgeBrokerError'
this.code = code
}
}
export class ModelBridgeBrokerServer {
readonly socketPath: string
readonly #dispatch: ModelBridgeBrokerDispatch
readonly #maximumConnections: number
readonly #requestTimeoutMs: number
readonly #connections = new Set<Socket>()
#server?: Server
#socketIdentity?: FileIdentity
#closing = false
#closePromise?: Promise<void>
constructor(options: {
scratchDirectory: string
dispatch: ModelBridgeBrokerDispatch
maximumConnections?: number
requestTimeoutMs?: number
}) {
const scratchDirectory = assertAbsoluteManagedPath(
options.scratchDirectory
)
this.socketPath = assertAbsoluteManagedPath(
join(scratchDirectory, MODEL_BRIDGE_BROKER_SOCKET_NAME)
)
if (
process.platform !== 'win32' &&
Buffer.byteLength(this.socketPath, 'utf8') >
MAXIMUM_UNIX_SOCKET_PATH_BYTES
) {
throw new Error('Model bridge endpoint path is too long')
}
this.#dispatch = options.dispatch
this.#maximumConnections = boundedInteger(
options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS,
1,
64,
'Model bridge connection limit'
)
this.#requestTimeoutMs = boundedInteger(
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
1,
300_000,
'Model bridge request timeout'
)
}
async listen(): Promise<void> {
if (process.platform === 'win32') {
throw new AgentUnsupportedError(
'Unix model bridge brokers are unavailable on Windows',
'platform-incompatible'
)
}
if (this.#server !== undefined || this.#closing) {
throw new Error('Model bridge broker is already active')
}
ensurePrivateDirectory(dirname(this.socketPath), {
create: false
})
removeOwnedStaleSocket(this.socketPath)
const server = createServer(
{ pauseOnConnect: true },
(socket) => {
if (
this.#closing ||
this.#connections.size >= this.#maximumConnections
) {
socket.destroy()
return
}
this.#connections.add(socket)
socket.once('close', () => {
this.#connections.delete(socket)
})
void this.#serveOne(socket)
}
)
this.#server = server
try {
await listenOnUnixSocket(server, this.socketPath)
const initialStat = lstatSync(this.socketPath)
this.#socketIdentity = fileIdentity(initialStat)
assertOwnedSocket(initialStat, 'Model bridge endpoint')
chmodSync(this.socketPath, 0o600)
const securedStat = lstatSync(this.socketPath)
assertSameFile(securedStat, this.#socketIdentity)
assertOwnedSocket(securedStat, 'Model bridge endpoint')
if ((securedStat.mode & 0o777) !== 0o600) {
throw new Error('Model bridge endpoint mode verification failed')
}
} catch (error) {
await this.close()
throw error
}
}
async close(): Promise<void> {
if (this.#closePromise !== undefined) {
await this.#closePromise
return
}
const closePromise = this.#close()
this.#closePromise = closePromise
try {
await closePromise
} finally {
if (this.#closePromise === closePromise) {
this.#closePromise = undefined
}
}
}
async #close(): Promise<void> {
const server = this.#server
this.#server = undefined
this.#closing = true
try {
for (const connection of this.#connections) {
connection.destroy()
}
await settleWithin(
server === undefined
? Promise.resolve()
: closeServer(server),
CLOSE_TIMEOUT_MS
)
} finally {
this.#connections.clear()
removeSocketIfIdentityMatches(
this.socketPath,
this.#socketIdentity
)
this.#socketIdentity = undefined
this.#closing = false
}
}
async #serveOne(socket: Socket): Promise<void> {
const cancellation = new AbortController()
const onClose = (): void => {
cancellation.abort(
new ModelBridgeBrokerError('request-cancelled')
)
}
socket.once('close', onClose)
const timeout = setTimeout(() => {
cancellation.abort(
new ModelBridgeBrokerError('request-timeout')
)
}, this.#requestTimeoutMs)
timeout.unref?.()
let requestId = `invalid-${randomUUID()}`
let dispatched: ModelBridgeExchangeResult | undefined
let delivered = false
try {
const packet = brokerRequestPacketSchema.parse(
await readPacket(socket, cancellation.signal)
)
requestId = packet.requestId
dispatched = normalizeExchangeResult(await raceWithAbort(
this.#dispatch(packet.request, {
requestId,
signal: cancellation.signal
}),
cancellation.signal
))
const response = remoteModelGatewayResponseSchema.parse(
dispatched.response
)
cancellation.signal.throwIfAborted()
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'model-response',
requestId,
ok: true,
response
})
const delivery = brokerDeliveryPacketSchema.parse(
await readPacket(socket, cancellation.signal)
)
if (delivery.requestId !== requestId) {
throw new ModelBridgeBrokerError('transport-failed')
}
await raceWithAbort(
Promise.resolve(dispatched.acknowledgeDelivery()),
cancellation.signal
)
delivered = true
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'delivery-accepted',
requestId
})
} catch (error) {
if (dispatched !== undefined && !delivered) {
await Promise.resolve(
dispatched.failDelivery(error)
).catch(() => undefined)
} else if (!socket.destroyed) {
await writeFailure(
socket,
requestId,
failureCode(error, cancellation.signal)
).catch(() => undefined)
}
} finally {
clearTimeout(timeout)
socket.off('close', onClose)
if (!socket.destroyed) {
socket.end()
}
}
}
}
export function createUnixModelBridgeExchange(options: {
socketPath: string
connectTimeoutMs?: number
requestTimeoutMs?: number
}): ModelBridgeExchange {
const socketPath = assertAbsoluteManagedPath(options.socketPath)
const connectTimeoutMs = boundedInteger(
options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
1,
60_000,
'Model bridge connect timeout'
)
const requestTimeoutMs = boundedInteger(
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
1,
300_000,
'Model bridge request timeout'
)
return async (request, context) => {
const parsedRequest = remoteModelGatewayRequestSchema.parse(request)
const requestId = requestIdSchema.parse(context.requestId)
context.signal.throwIfAborted()
const socket = createConnection(socketPath)
const timeoutController = new AbortController()
const timeout = setTimeout(() => {
timeoutController.abort(
new ModelBridgeBrokerError('request-timeout')
)
socket.destroy()
}, requestTimeoutMs)
timeout.unref?.()
const onAbort = (): void => {
socket.destroy()
}
context.signal.addEventListener('abort', onAbort, { once: true })
let responseHandedOff = false
const cleanup = (): void => {
clearTimeout(timeout)
context.signal.removeEventListener('abort', onAbort)
socket.destroy()
}
try {
await waitForConnection(
socket,
Math.min(connectTimeoutMs, requestTimeoutMs),
context.signal
)
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'model-request',
requestId,
request: parsedRequest
})
const packet = brokerResponsePacketSchema.parse(
await readPacket(
socket,
AbortSignal.any([
context.signal,
timeoutController.signal
])
)
)
if (packet.requestId !== requestId) {
throw new ModelBridgeBrokerError('transport-failed')
}
if (!packet.ok) {
throw new ModelBridgeBrokerError(packet.error.code)
}
responseHandedOff = true
let deliveryFinalized = false
return {
response: packet.response,
acknowledgeDelivery: async () => {
if (deliveryFinalized) {
throw new ModelBridgeBrokerError('transport-failed')
}
deliveryFinalized = true
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'response-delivered',
requestId
})
const accepted = brokerDeliveryAcceptedPacketSchema.parse(
await readPacket(
socket,
AbortSignal.any([
context.signal,
timeoutController.signal
])
)
)
if (accepted.requestId !== requestId) {
throw new ModelBridgeBrokerError('transport-failed')
}
cleanup()
},
failDelivery: () => {
deliveryFinalized = true
cleanup()
}
}
} catch (error) {
if (context.signal.aborted) {
throw new ModelBridgeBrokerError('request-cancelled')
}
if (timeoutController.signal.aborted) {
throw new ModelBridgeBrokerError('request-timeout')
}
if (error instanceof ModelBridgeBrokerError) {
throw error
}
throw new ModelBridgeBrokerError('transport-failed')
} finally {
if (!responseHandedOff) {
cleanup()
}
}
}
}
type BrokerFailureCode = z.infer<
typeof brokerFailureCodeSchema
>
type FileIdentity = {
dev: bigint
ino: bigint
}
function failureCode(
error: unknown,
signal: AbortSignal
): BrokerFailureCode {
const reason = signal.reason
if (
signal.aborted &&
reason instanceof ModelBridgeBrokerError &&
reason.code === 'request-timeout'
) {
return 'request-timeout'
}
if (signal.aborted) {
return 'request-cancelled'
}
if (error instanceof z.ZodError || error instanceof SyntaxError) {
return 'request-invalid'
}
return 'dispatch-failed'
}
async function writeFailure(
socket: Socket,
requestId: string,
code: BrokerFailureCode
): Promise<void> {
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'model-response',
requestId,
ok: false,
error: { code }
})
}
async function readPacket(
socket: Socket,
signal: AbortSignal
): Promise<unknown> {
const lengthBytes = await readExactly(socket, 4, signal)
const length = lengthBytes.readUInt32BE(0)
if (length < 2 || length > MAXIMUM_FRAME_BYTES) {
throw new ModelBridgeBrokerError('request-invalid')
}
const contents = await readExactly(socket, length, signal)
const text = new TextDecoder('utf-8', { fatal: true }).decode(
contents
)
return JSON.parse(text) as unknown
}
async function writePacket(
socket: Socket,
value: unknown
): Promise<void> {
const contents = Buffer.from(JSON.stringify(value), 'utf8')
if (
contents.byteLength < 2 ||
contents.byteLength > MAXIMUM_FRAME_BYTES
) {
throw new ModelBridgeBrokerError('request-invalid')
}
const packet = Buffer.allocUnsafe(contents.byteLength + 4)
packet.writeUInt32BE(contents.byteLength, 0)
contents.copy(packet, 4)
await new Promise<void>((resolve, reject) => {
socket.write(packet, (error) => {
if (error === undefined || error === null) {
resolve()
} else {
reject(error)
}
})
})
}
async function readExactly(
socket: Socket,
byteLength: number,
signal: AbortSignal
): Promise<Buffer> {
const chunks: Buffer[] = []
let remaining = byteLength
while (remaining > 0) {
signal.throwIfAborted()
const chunk = socket.read(remaining) as Buffer | null
if (chunk !== null) {
chunks.push(chunk)
remaining -= chunk.byteLength
continue
}
await new Promise<void>((resolve, reject) => {
const onReadable = (): void => {
cleanup()
resolve()
}
const onClose = (): void => {
cleanup()
reject(new ModelBridgeBrokerError('transport-failed'))
}
const onError = (): void => {
cleanup()
reject(new ModelBridgeBrokerError('transport-failed'))
}
const onAbort = (): void => {
cleanup()
reject(signal.reason)
}
const cleanup = (): void => {
socket.off('readable', onReadable)
socket.off('close', onClose)
socket.off('end', onClose)
socket.off('error', onError)
signal.removeEventListener('abort', onAbort)
}
socket.once('readable', onReadable)
socket.once('close', onClose)
socket.once('end', onClose)
socket.once('error', onError)
signal.addEventListener('abort', onAbort, { once: true })
})
}
return Buffer.concat(chunks, byteLength)
}
async function waitForConnection(
socket: Socket,
timeoutMs: number,
signal: AbortSignal
): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined
await new Promise<void>((resolve, reject) => {
const onConnect = (): void => {
cleanup()
resolve()
}
const onError = (): void => {
cleanup()
reject(new ModelBridgeBrokerError('transport-failed'))
}
const onAbort = (): void => {
cleanup()
reject(new ModelBridgeBrokerError('request-cancelled'))
}
const cleanup = (): void => {
if (timeout !== undefined) {
clearTimeout(timeout)
}
socket.off('connect', onConnect)
socket.off('error', onError)
signal.removeEventListener('abort', onAbort)
}
timeout = setTimeout(() => {
cleanup()
reject(new ModelBridgeBrokerError('request-timeout'))
}, timeoutMs)
timeout.unref?.()
socket.once('connect', onConnect)
socket.once('error', onError)
signal.addEventListener('abort', onAbort, { once: true })
})
}
function listenOnUnixSocket(
server: Server,
socketPath: string
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const onError = (error: Error): void => {
cleanup()
reject(error)
}
const onListening = (): void => {
cleanup()
resolve()
}
const cleanup = (): void => {
server.off('error', onError)
server.off('listening', onListening)
}
server.once('error', onError)
server.once('listening', onListening)
server.listen(socketPath)
})
}
function removeOwnedStaleSocket(path: string): void {
let stat: Stats
try {
stat = lstatSync(path)
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return
}
throw error
}
assertOwnedSocket(stat, 'Existing model bridge endpoint')
if ((stat.mode & 0o777) !== 0o600) {
throw new Error(
'Refusing to replace an insecure model bridge endpoint'
)
}
unlinkSync(path)
}
function removeSocketIfIdentityMatches(
path: string,
identity: FileIdentity | undefined
): void {
if (identity === undefined) {
return
}
let stat: Stats
try {
stat = lstatSync(path)
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return
}
throw error
}
if (
stat.isSocket() &&
isOwnedByCurrentUser(stat) &&
sameFile(stat, identity)
) {
unlinkSync(path)
}
}
function assertOwnedSocket(stat: Stats, label: string): void {
if (
stat.isSymbolicLink() ||
!stat.isSocket() ||
!isOwnedByCurrentUser(stat)
) {
throw new Error(`${label} is not an owned Unix socket`)
}
}
function assertSameFile(
stat: Stats,
identity: FileIdentity
): void {
if (!sameFile(stat, identity)) {
throw new Error('Model bridge endpoint changed during setup')
}
}
function sameFile(stat: Stats, identity: FileIdentity): boolean {
return (
BigInt(stat.dev) === identity.dev &&
BigInt(stat.ino) === identity.ino
)
}
function fileIdentity(stat: Stats): FileIdentity {
return {
dev: BigInt(stat.dev),
ino: BigInt(stat.ino)
}
}
function isOwnedByCurrentUser(stat: Stats): boolean {
const uid = process.getuid?.()
return uid === undefined || stat.uid === uid
}
function modelBridgeBrokerErrorMessage(
code: ModelBridgeBrokerError['code']
): string {
switch (code) {
case 'request-invalid':
return 'Model bridge request was rejected'
case 'request-timeout':
return 'Model bridge request timed out'
case 'request-cancelled':
return 'Model bridge request was cancelled'
case 'dispatch-failed':
return 'Model bridge dispatch failed'
case 'transport-failed':
return 'Model bridge transport failed'
}
}
function normalizeExchangeResult(
value: ModelBridgeExchangeResult | RemoteModelGatewayResponse
): ModelBridgeExchangeResult {
if (
value !== null &&
typeof value === 'object' &&
'response' in value &&
typeof value.acknowledgeDelivery === 'function' &&
typeof value.failDelivery === 'function'
) {
return value
}
return {
response: remoteModelGatewayResponseSchema.parse(value),
acknowledgeDelivery: async () => undefined,
failDelivery: () => undefined
}
}
@@ -0,0 +1,376 @@
import { describe, expect, it, vi } from 'vitest'
import {
decodeModelBridgeMessage,
encodeModelBridgeMessage,
type ModelBridgePolicy,
type ModelBridgeRequestMessage
} from '../shared/model-bridge-contracts'
import {
AGENT_PROTOCOL_VERSION,
type AgentFrame
} from '../shared/agent-protocol'
import {
ModelBridgeBlobClient,
ModelBridgeClientError
} from './model-bridge-client'
const digest = (character: string): string =>
`sha256:${character.repeat(64)}`
const policy: ModelBridgePolicy = {
protocol: 'openai-responses',
model: 'private-model',
modelProfileDigest: digest('a'),
supportsImageInput: false
}
function createHarness(policyOverride: Partial<ModelBridgePolicy> = {}) {
const sent: AgentFrame[] = []
const poisoned = vi.fn()
let nextId = 0
const client = new ModelBridgeBlobClient({
binding: {
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
controllerId: 'controller-1',
controllerGeneration: 2,
connectionId: 'connection-1',
acpChannelEpoch: '4',
channelId: 'model-channel-1',
channelEpoch: '9',
runtimeId: 'opencode',
workspaceIdentity: 'workspace-1',
policy: { ...policy, ...policyOverride },
deadlineAt: new Date(Date.now() + 60_000).toISOString()
},
sendBlobFrame: async (frame) => {
sent.push(frame)
},
onPoison: poisoned,
randomMessageId: () => `message-${nextId++}`
})
return { client, sent, poisoned }
}
const request = {
method: 'POST' as const,
path: '/v1/responses' as const,
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.from(
JSON.stringify({ model: 'private-model', max_output_tokens: 100 })
).toString('base64')
}
async function outboundRequest(
sent: AgentFrame[],
index = 0
): Promise<ModelBridgeRequestMessage> {
await waitFor(() => sent.length > index)
const decoded = await decodeModelBridgeMessage(sent[index]!.payload)
if (decoded.kind !== 'request') {
throw new Error('Expected request')
}
return decoded
}
async function deliverResponse(
client: ModelBridgeBlobClient,
outbound: ModelBridgeRequestMessage,
bodyBytes = 12
): Promise<void> {
await client.onBlobFrame(
incomingFrame(
await encodeModelBridgeMessage({
protocol: 'goodbuddy-model-bridge-v1',
kind: 'response',
identity: outbound.identity,
requestDigest: outbound.requestDigest,
response: {
status: 200,
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.alloc(bodyBytes, 2).toString('base64')
}
}),
1
)
)
}
describe('Agent model bridge blob client', () => {
it('uses exact sequential identities and two-phase delivery', async () => {
const fixture = createHarness()
const pending = fixture.client.exchange(request, {
requestId: 'request-local-1',
signal: new AbortController().signal
})
const outbound = await outboundRequest(fixture.sent)
expect(outbound).toMatchObject({
identity: {
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
requestId: 'request-local-1',
roundIndex: 0,
modelProfileDigest: digest('a'),
messageId: 'message-0'
},
policy
})
await deliverResponse(fixture.client, outbound)
const exchange = await pending
expect(exchange.response.status).toBe(200)
expect(fixture.sent).toHaveLength(1)
await exchange.acknowledgeDelivery()
expect(fixture.sent).toHaveLength(2)
await expect(
decodeModelBridgeMessage(fixture.sent[1]!.payload)
).resolves.toMatchObject({
kind: 'response-delivered',
identity: outbound.identity,
requestDigest: outbound.requestDigest
})
expect(fixture.poisoned).not.toHaveBeenCalled()
})
it('receives a maximum legal response in one message', async () => {
const fixture = createHarness()
const pending = fixture.client.exchange(request, {
requestId: 'request-large-response',
signal: new AbortController().signal
})
const outbound = await outboundRequest(fixture.sent)
await deliverResponse(fixture.client, outbound, 768 * 1024)
const exchange = await pending
expect(
Buffer.from(exchange.response.bodyBase64, 'base64').byteLength
).toBe(768 * 1024)
await exchange.acknowledgeDelivery()
expect(fixture.sent).toHaveLength(2)
expect(fixture.poisoned).not.toHaveBeenCalled()
})
it('sends a large request as one bounded message', async () => {
const fixture = createHarness()
const pending = fixture.client.exchange(
{
...request,
bodyBase64: Buffer.alloc(700_000, 1).toString('base64')
},
{
requestId: 'request-large',
signal: new AbortController().signal
}
)
const outbound = await outboundRequest(fixture.sent)
expect(fixture.sent).toHaveLength(1)
expect(outbound.identity.roundIndex).toBe(0)
await fixture.client.close({ poisonIfActive: false })
await expect(pending).rejects.toMatchObject({ code: 'cancelled' })
})
it('does not send after cancellation during request preparation', async () => {
const fixture = createHarness()
const controller = new AbortController()
const pending = fixture.client.exchange(
{
...request,
bodyBase64: Buffer.alloc(700_000, 1).toString('base64')
},
{
requestId: 'request-cancelled-during-prepare',
signal: controller.signal
}
)
controller.abort()
await expect(pending).rejects.toThrow()
expect(fixture.sent).toHaveLength(0)
expect(fixture.poisoned).not.toHaveBeenCalled()
})
it('does not dispatch when detach closes the bridge during request preparation', async () => {
const fixture = createHarness()
const pending = fixture.client.exchange(
{
...request,
bodyBase64: Buffer.alloc(700_000, 1).toString('base64')
},
{
requestId: 'request-closed-during-prepare',
signal: new AbortController().signal
}
)
await fixture.client.close({ poisonIfActive: false })
await expect(pending).rejects.toMatchObject({
code: 'cancelled',
poisoned: false,
outcomeUnknown: false
})
expect(fixture.sent).toHaveLength(0)
expect(fixture.poisoned).not.toHaveBeenCalled()
})
it('poisons cancellation after the complete request write starts', async () => {
const sent: AgentFrame[] = []
const poisoned = vi.fn()
const controller = new AbortController()
const client = new ModelBridgeBlobClient({
binding: {
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
controllerId: 'controller-1',
controllerGeneration: 2,
connectionId: 'connection-1',
acpChannelEpoch: '4',
channelId: 'model-channel-1',
channelEpoch: '9',
runtimeId: 'opencode',
workspaceIdentity: 'workspace-1',
policy,
deadlineAt: new Date(Date.now() + 60_000).toISOString()
},
sendBlobFrame: async (frame) => {
sent.push(frame)
controller.abort()
},
onPoison: poisoned,
randomMessageId: () => 'message-cancelled-after-write'
})
await expect(
client.exchange(request, {
requestId: 'request-cancelled-after-write',
signal: controller.signal
})
).rejects.toThrow()
expect(sent).toHaveLength(1)
await waitFor(() => poisoned.mock.calls.length === 1)
expect(client.poisoned).toBe(true)
})
it('does not impose a model output-token quota', async () => {
const fixture = createHarness()
const pending = fixture.client.exchange(
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'private-model',
max_output_tokens: 1_000_000
})
).toString('base64')
},
{
requestId: 'request-with-provider-limit',
signal: new AbortController().signal
}
)
await outboundRequest(fixture.sent)
await fixture.client.close({ poisonIfActive: false })
await expect(pending).rejects.toMatchObject({ code: 'cancelled' })
})
it('does not consume a round for a definitive rejection', async () => {
const fixture = createHarness()
const firstPending = fixture.client.exchange(request, {
requestId: 'request-first',
signal: new AbortController().signal
})
const first = await outboundRequest(fixture.sent)
await fixture.client.onBlobFrame(
incomingFrame(
await encodeModelBridgeMessage({
protocol: 'goodbuddy-model-bridge-v1',
kind: 'error',
identity: first.identity,
requestDigest: first.requestDigest,
error: {
code: 'policy-rejected',
message: 'Remote model request was rejected',
retryable: false,
poisoned: false,
outcomeUnknown: false
}
}),
1
)
)
await expect(firstPending).rejects.toMatchObject({ code: 'remote' })
const secondPending = fixture.client.exchange(request, {
requestId: 'request-second',
signal: new AbortController().signal
})
const second = await outboundRequest(fixture.sent, 1)
expect(second.identity.roundIndex).toBe(0)
await fixture.client.close({ poisonIfActive: false })
await expect(secondPending).rejects.toMatchObject({ code: 'cancelled' })
})
it('rejects a malformed response and poisons the binding', async () => {
const fixture = createHarness()
const pending = fixture.client.exchange(request, {
requestId: 'request-malformed',
signal: new AbortController().signal
})
await outboundRequest(fixture.sent)
await expect(
fixture.client.onBlobFrame(
incomingFrame(Buffer.from('{ "not": "canonical" }'), 1)
)
).rejects.toBeInstanceOf(Error)
await expect(pending).rejects.toBeInstanceOf(Error)
expect(fixture.client.poisoned).toBe(true)
})
it('permanently poisons delivery that ends before the helper ACK', async () => {
const fixture = createHarness()
const pending = fixture.client.exchange(request, {
requestId: 'request-uncertain',
signal: new AbortController().signal
})
const outbound = await outboundRequest(fixture.sent)
await deliverResponse(fixture.client, outbound, 0)
const exchange = await pending
await exchange.failDelivery()
expect(fixture.client.poisoned).toBe(true)
expect(fixture.poisoned).toHaveBeenCalledOnce()
await expect(
fixture.client.exchange(request, {
requestId: 'request-after-poison',
signal: new AbortController().signal
})
).rejects.toBeInstanceOf(ModelBridgeClientError)
})
})
function incomingFrame(payload: Uint8Array, sequence: number): AgentFrame {
return {
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: 'connection-1',
generation: 2,
channelId: 'model-channel-1',
channelEpoch: '9',
direction: 'main-to-agent',
sequence: String(sequence),
kind: 'blob',
payloadLength: payload.byteLength
},
payload
}
}
async function waitFor(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 2_000
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error('Timed out waiting for model bridge')
}
await new Promise<void>((resolve) => setTimeout(resolve, 1))
}
}
+519
View File
@@ -0,0 +1,519 @@
import { randomUUID } from 'node:crypto'
import {
AGENT_PROTOCOL_VERSION,
agentIdentifierSchema,
type AgentFrame
} from '../shared/agent-protocol'
import {
createModelBridgeRequestMessage,
decodeModelBridgeMessage,
encodeModelBridgeMessage,
modelBridgeDeliveryAckMessageSchema,
type ModelBridgeError,
type ModelBridgeIdentity,
type ModelBridgePolicy,
type ModelBridgeResponseMessage
} from '../shared/model-bridge-contracts'
import type {
RemoteModelGatewayRequest,
RemoteModelGatewayResponse
} from '../shared/remote-model-gateway-contracts'
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000
export type ModelBridgeClientBinding = {
bindingId: string
promptOperationId: string
controllerId: string
controllerGeneration: number
connectionId: string
acpChannelEpoch: string
channelId: string
channelEpoch: string
runtimeId: string
workspaceIdentity: string
policy: ModelBridgePolicy
deadlineAt: string
}
export type ModelBridgeClientExchange = {
response: RemoteModelGatewayResponse
acknowledgeDelivery(): Promise<void>
failDelivery(reason?: unknown): Promise<void>
}
export class ModelBridgeClientError extends Error {
constructor(
message: string,
readonly code:
| 'cancelled'
| 'capacity'
| 'identity'
| 'poisoned'
| 'remote'
| 'timeout'
| 'transport',
readonly poisoned = false,
readonly outcomeUnknown = false
) {
super(message)
this.name = 'ModelBridgeClientError'
}
}
type ActiveRequest = {
identity: ModelBridgeIdentity
requestDigest: string
resolve: (message: ModelBridgeResponseMessage) => void
reject: (error: unknown) => void
timeout: NodeJS.Timeout
responseReceived: boolean
providerDispatchPossible: boolean
settled: boolean
}
export class ModelBridgeBlobClient {
readonly binding: ModelBridgeClientBinding
readonly #sendBlobFrame: (frame: AgentFrame) => Promise<void>
readonly #onPoison: (error: ModelBridgeClientError) => void | Promise<void>
readonly #now: () => number
readonly #requestTimeoutMs: number
readonly #randomMessageId: () => string
#nextRoundIndex = 0
#nextOutboundSequence = 1n
#active?: ActiveRequest
#closed = false
#poisoned = false
constructor(options: {
binding: ModelBridgeClientBinding
sendBlobFrame: (frame: AgentFrame) => Promise<void>
onPoison: (error: ModelBridgeClientError) => void | Promise<void>
now?: () => number
requestTimeoutMs?: number
randomMessageId?: () => string
}) {
this.binding = options.binding
this.#sendBlobFrame = options.sendBlobFrame
this.#onPoison = options.onPoison
this.#now = options.now ?? Date.now
this.#requestTimeoutMs = boundedTimeout(
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
)
this.#randomMessageId =
options.randomMessageId ??
(() => `bridge-${randomUUID()}`)
}
get poisoned(): boolean {
return this.#poisoned
}
get active(): boolean {
return this.#active !== undefined
}
async exchange(
request: RemoteModelGatewayRequest,
context: { requestId: string; signal: AbortSignal }
): Promise<ModelBridgeClientExchange> {
this.#assertAvailable()
if (this.#active !== undefined) {
throw new ModelBridgeClientError(
'Only one model request may be active',
'capacity'
)
}
context.signal.throwIfAborted()
this.#assertRequest(request)
const roundIndex = this.#nextRoundIndex
const messageId = agentIdentifierSchema.parse(
this.#randomMessageId()
)
const identity: ModelBridgeIdentity = {
bindingId: this.binding.bindingId,
promptOperationId: this.binding.promptOperationId,
requestId: agentIdentifierSchema.parse(context.requestId),
roundIndex,
modelProfileDigest:
this.binding.policy.modelProfileDigest,
messageId
}
const message = await createModelBridgeRequestMessage({
identity,
policy: this.binding.policy,
request
})
context.signal.throwIfAborted()
const payload = await encodeModelBridgeMessage(message)
context.signal.throwIfAborted()
this.#assertAvailable()
const response = new Promise<ModelBridgeResponseMessage>(
(resolve, reject) => {
const remaining = Math.max(
1,
Date.parse(this.binding.deadlineAt) - this.#now()
)
const timeout = setTimeout(() => {
const active = this.#active
if (active?.identity.messageId !== messageId) {
return
}
const error = new ModelBridgeClientError(
'Model bridge response timed out',
'timeout',
true,
true
)
active.settled = true
active.reject(error)
void this.#poison(error)
}, Math.min(this.#requestTimeoutMs, remaining))
timeout.unref?.()
this.#active = {
identity,
requestDigest: message.requestDigest,
resolve,
reject,
timeout,
responseReceived: false,
providerDispatchPossible: false,
settled: false
}
}
)
// Cancellation can end the send loop before it reaches `await response`.
// Keep the original rejection for that await while preventing an orphaned
// internal promise from becoming an unhandled rejection.
void response.catch(() => undefined)
const onAbort = (): void => {
const active = this.#active
if (active?.identity.messageId !== messageId) {
return
}
const error = new ModelBridgeClientError(
'Model bridge request was cancelled',
'cancelled',
active.responseReceived || active.providerDispatchPossible,
active.responseReceived || active.providerDispatchPossible
)
active.settled = true
active.reject(error)
if (active.responseReceived || active.providerDispatchPossible) {
void this.#poison(error)
} else {
this.#clearActive(active)
}
}
context.signal.addEventListener('abort', onAbort, { once: true })
if (context.signal.aborted) {
onAbort()
}
try {
context.signal.throwIfAborted()
const dispatchActive = this.#currentActive()
if (
dispatchActive === undefined ||
dispatchActive.identity.messageId !== messageId
) {
throw new ModelBridgeClientError(
'Model request lost its active identity before dispatch',
'cancelled'
)
}
dispatchActive.providerDispatchPossible = true
try {
await this.#send(payload)
} catch {
const error = new ModelBridgeClientError(
'Model request transmission was uncertain',
'transport',
true,
true
)
await this.#poison(error)
throw error
}
const deliveredResponse = await response
const active = this.#currentActive()
if (
active === undefined ||
active.identity.messageId !== messageId
) {
throw new ModelBridgeClientError(
'Model bridge response lost its active identity',
'identity',
true,
true
)
}
active.responseReceived = true
let deliveryFinished = false
return {
response: deliveredResponse.response,
acknowledgeDelivery: async () => {
if (deliveryFinished) {
throw new ModelBridgeClientError(
'Model bridge delivery was already finalized',
'identity',
true,
true
)
}
deliveryFinished = true
try {
const acknowledgment =
modelBridgeDeliveryAckMessageSchema.parse({
protocol: 'goodbuddy-model-bridge-v1',
kind: 'response-delivered',
identity,
requestDigest: active.requestDigest
})
await this.#send(
await encodeModelBridgeMessage(acknowledgment)
)
this.#nextRoundIndex += 1
this.#clearActive(active)
} catch {
const error = new ModelBridgeClientError(
'Model bridge delivery acknowledgment failed',
'transport',
true,
true
)
await this.#poison(error)
throw error
}
},
failDelivery: async () => {
if (deliveryFinished) {
return
}
deliveryFinished = true
const error = new ModelBridgeClientError(
'Provider response delivery was not acknowledged',
'poisoned',
true,
true
)
await this.#poison(error)
}
}
} catch (error) {
const active = this.#currentActive()
if (
active !== undefined &&
active.identity.messageId === messageId &&
!active.responseReceived
) {
this.#clearActive(active)
}
throw error
} finally {
context.signal.removeEventListener('abort', onAbort)
}
}
async onBlobFrame(frame: AgentFrame): Promise<void> {
if (
this.#closed ||
frame.header.kind !== 'blob' ||
frame.header.direction !== 'main-to-agent' ||
frame.header.connectionId !== this.binding.connectionId ||
frame.header.generation !==
this.binding.controllerGeneration ||
frame.header.channelId !== this.binding.channelId ||
frame.header.channelEpoch !== this.binding.channelEpoch
) {
throw new ModelBridgeClientError(
'Blob frame does not match the active model bridge',
'identity'
)
}
const active = this.#active
if (active === undefined) {
throw new ModelBridgeClientError(
'No model request is awaiting blob data',
'identity'
)
}
try {
const message = await decodeModelBridgeMessage(frame.payload, {
expectedIdentity: active.identity,
expectedRequestDigest: active.requestDigest
})
if (message.kind === 'response') {
active.responseReceived = true
active.settled = true
clearTimeout(active.timeout)
active.resolve(message)
return
}
if (message.kind === 'error') {
const error = remoteError(message.error)
active.settled = true
clearTimeout(active.timeout)
active.reject(error)
if (message.error.poisoned || message.error.outcomeUnknown) {
await this.#poison(error)
} else {
this.#clearActive(active)
}
return
}
throw new ModelBridgeClientError(
'Main sent an invalid model bridge message kind',
'identity',
true,
true
)
} catch (error) {
if (!active.settled) {
active.settled = true
active.reject(error)
}
const poisoned =
error instanceof ModelBridgeClientError
? error
: new ModelBridgeClientError(
'Model bridge response framing failed',
'identity',
true,
true
)
await this.#poison(poisoned)
throw error
}
}
async close(options: { poisonIfActive: boolean }): Promise<void> {
if (this.#closed) {
return
}
this.#closed = true
const active = this.#active
if (active !== undefined) {
const error = new ModelBridgeClientError(
'Model bridge closed with an active request',
options.poisonIfActive ? 'poisoned' : 'cancelled',
options.poisonIfActive,
options.poisonIfActive
)
active.reject(error)
if (options.poisonIfActive) {
await this.#poison(error)
} else {
this.#clearActive(active)
}
}
}
#assertRequest(request: RemoteModelGatewayRequest): void {
const policy = this.binding.policy
const body = parseJsonBody(request.bodyBase64)
if (typeof body?.model === 'string' && body.model !== policy.model) {
throw new ModelBridgeClientError(
'Model request does not match the prepared profile',
'identity'
)
}
}
#assertAvailable(): void {
if (this.#poisoned) {
throw new ModelBridgeClientError(
'Model bridge is permanently poisoned',
'poisoned',
true,
true
)
}
if (this.#closed) {
throw new ModelBridgeClientError(
'Model bridge is closed',
'cancelled'
)
}
}
async #send(payload: Uint8Array): Promise<void> {
const sequence = this.#nextOutboundSequence.toString()
await this.#sendBlobFrame({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: this.binding.connectionId,
generation: this.binding.controllerGeneration,
channelId: this.binding.channelId,
channelEpoch: this.binding.channelEpoch,
direction: 'agent-to-main',
sequence,
kind: 'blob',
payloadLength: payload.byteLength
},
payload
})
this.#nextOutboundSequence += 1n
}
#clearActive(active: ActiveRequest): void {
clearTimeout(active.timeout)
if (this.#active === active) {
this.#active = undefined
}
}
#currentActive(): ActiveRequest | undefined {
return this.#active
}
async #poison(error: ModelBridgeClientError): Promise<void> {
if (this.#poisoned) {
return
}
this.#poisoned = true
const active = this.#active
if (active !== undefined) {
this.#clearActive(active)
}
await this.#onPoison(error)
}
}
function remoteError(error: ModelBridgeError): ModelBridgeClientError {
return new ModelBridgeClientError(
error.message,
error.poisoned || error.outcomeUnknown ? 'poisoned' : 'remote',
error.poisoned,
error.outcomeUnknown
)
}
function parseJsonBody(
bodyBase64: string
): Record<string, unknown> | undefined {
try {
const parsed: unknown = JSON.parse(
Buffer.from(bodyBase64, 'base64').toString('utf8')
)
return parsed !== null &&
typeof parsed === 'object' &&
!Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined
} catch {
return undefined
}
}
function boundedTimeout(value: number): number {
if (
!Number.isSafeInteger(value) ||
value < 1 ||
value > 300_000
) {
throw new RangeError('Invalid model bridge timeout')
}
return value
}
+900
View File
@@ -0,0 +1,900 @@
import { randomUUID } from 'node:crypto'
import { spawn as nodeSpawn } from 'node:child_process'
import {
createServer,
type IncomingHttpHeaders,
type IncomingMessage,
type Server,
type ServerResponse
} from 'node:http'
import type { Socket } from 'node:net'
import { isAbsolute, resolve } from 'node:path'
import type {
ModelBridgeModelProtocol
} from '../shared/model-bridge-contracts'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
remoteModelApiPathSchema,
remoteModelGatewayRequestSchema,
remoteModelGatewayResponseSchema,
type RemoteModelGatewayRequest
} from '../shared/remote-model-gateway-contracts'
import {
createUnixModelBridgeExchange,
ModelBridgeBrokerError,
type ModelBridgeExchange,
type ModelBridgeExchangeResult
} from './model-bridge-broker'
import {
boundedInteger,
closeServer,
raceWithAbort,
settleWithin
} from './async-utils'
export type ModelBridgeProtocol = ModelBridgeModelProtocol
// The Anthropic and OpenAI SDK adapters refuse to issue a request without an
// apiKey option. This fixed value is only a local compatibility marker. The
// loopback proxy verifies and removes its header before creating the wire
// request, so it is neither a credential nor visible outside the sandbox.
export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
'goodbuddy-local-model-bridge-v1'
export type OpenCodeModelBridgeProviderConfig = {
model: string
provider: Record<
string,
{
name: string
npm:
| '@ai-sdk/anthropic'
| '@ai-sdk/openai-compatible'
| '@ai-sdk/openai'
options: {
baseURL: string
apiKey?: typeof MODEL_BRIDGE_SDK_AUTH_SENTINEL
}
models: Record<
string,
{
name: string
attachment: boolean
modalities: {
input: Array<'text' | 'image'>
output: ['text']
}
provider: {
npm:
| '@ai-sdk/anthropic'
| '@ai-sdk/openai-compatible'
| '@ai-sdk/openai'
}
}
>
}
>
}
export type ModelBridgeHelperChild = {
once(event: 'error', listener: (error: Error) => void): unknown
once(
event: 'close',
listener: (code: number | null, signal: NodeJS.Signals | null) => void
): unknown
}
export type ModelBridgeHelperSpawn = (
executable: string,
args: readonly string[],
options: {
shell: false
stdio: 'inherit'
env: Readonly<NodeJS.ProcessEnv>
}
) => ModelBridgeHelperChild
const LOOPBACK_HOST = '127.0.0.1'
const DEFAULT_MAXIMUM_CONNECTIONS = 8
const DEFAULT_REQUEST_TIMEOUT_MS = 180_000
const RESPONSE_CLOSE_TIMEOUT_MS = 2_000
const MAXIMUM_HTTP_HEADER_BYTES = 16 * 1024
const MAXIMUM_HTTP_HEADER_COUNT = 32
const CREDENTIAL_INBOUND_HEADER_NAMES = new Set([
'api-key',
'authorization',
'cookie',
'proxy-authorization',
'set-cookie',
'x-api-key',
'x-goog-api-key'
])
const FORWARDED_HEADER_NAMES = [
'accept',
'content-type'
] as const
export class ModelBridgeLoopbackProxy {
readonly #exchange: ModelBridgeExchange
readonly #maximumConnections: number
readonly #requestTimeoutMs: number
readonly #sockets = new Set<Socket>()
readonly #dispatchWaiters: DispatchWaiter[] = []
#server?: Server
#origin?: string
#dispatchBusy = false
#closing = false
#closePromise?: Promise<void>
constructor(options: {
exchange: ModelBridgeExchange
maximumConnections?: number
requestTimeoutMs?: number
}) {
this.#exchange = options.exchange
this.#maximumConnections = boundedInteger(
options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS,
1,
64,
'Model bridge loopback connection limit'
)
this.#requestTimeoutMs = boundedInteger(
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
1,
300_000,
'Model bridge loopback request timeout'
)
}
get origin(): string {
if (this.#origin === undefined) {
throw new Error('Model bridge loopback proxy is not listening')
}
return this.#origin
}
async listen(): Promise<string> {
if (this.#server !== undefined || this.#closing) {
throw new Error('Model bridge loopback proxy is already active')
}
const server = createServer(
{
maxHeaderSize: MAXIMUM_HTTP_HEADER_BYTES,
requireHostHeader: true
},
(request, response) => {
void this.#handleRequest(request, response)
}
)
server.maxConnections = this.#maximumConnections
server.maxHeadersCount = MAXIMUM_HTTP_HEADER_COUNT
server.maxRequestsPerSocket = 1
server.requestTimeout = this.#requestTimeoutMs
server.headersTimeout = Math.min(
this.#requestTimeoutMs,
30_000
)
server.keepAliveTimeout = 1_000
server.on('connection', (socket) => {
if (
this.#closing ||
this.#sockets.size >= this.#maximumConnections
) {
socket.destroy()
return
}
this.#sockets.add(socket)
socket.once('close', () => this.#sockets.delete(socket))
})
this.#server = server
try {
await listenOnLoopback(server)
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error(
'Model bridge loopback address is unavailable'
)
}
this.#origin = `http://${LOOPBACK_HOST}:${address.port}`
return this.#origin
} catch (error) {
await this.close()
throw error
}
}
async close(): Promise<void> {
if (this.#closePromise !== undefined) {
await this.#closePromise
return
}
const closePromise = this.#close()
this.#closePromise = closePromise
try {
await closePromise
} finally {
if (this.#closePromise === closePromise) {
this.#closePromise = undefined
}
}
}
async #close(): Promise<void> {
const server = this.#server
this.#server = undefined
this.#origin = undefined
this.#closing = true
try {
for (const socket of this.#sockets) {
socket.destroy()
}
await settleWithin(
server === undefined
? Promise.resolve()
: closeServer(server),
RESPONSE_CLOSE_TIMEOUT_MS
)
} finally {
this.#sockets.clear()
this.#closing = false
}
}
async #handleRequest(
incoming: IncomingMessage,
outgoing: ServerResponse
): Promise<void> {
outgoing.setHeader('connection', 'close')
outgoing.setHeader('cache-control', 'no-store')
outgoing.setHeader('x-content-type-options', 'nosniff')
if (this.#closing) {
incoming.resume()
sendError(outgoing, 503, 'bridge-closing')
return
}
const cancellation = new AbortController()
let dispatched: ModelBridgeExchangeResult | undefined
let responseFlushed = false
let deliveryAcknowledged = false
let dispatchHeld = false
const onAborted = (): void => {
cancellation.abort(
new ModelBridgeBrokerError('request-cancelled')
)
}
const onResponseClose = (): void => {
if (!responseFlushed && !outgoing.writableFinished) {
onAborted()
void dispatched?.failDelivery(
new ModelBridgeBrokerError('transport-failed')
)
}
}
incoming.once('aborted', onAborted)
outgoing.once('close', onResponseClose)
const timeout = setTimeout(() => {
cancellation.abort(
new ModelBridgeBrokerError('request-timeout')
)
}, this.#requestTimeoutMs)
timeout.unref?.()
try {
const request = await parseHttpRequest(incoming)
await this.#acquireDispatch(cancellation.signal)
dispatchHeld = true
dispatched = normalizeExchangeResult(
await raceWithAbort(this.#exchange(request, {
requestId: `model-${randomUUID()}`,
signal: cancellation.signal
}), cancellation.signal)
)
const parsedResponse =
remoteModelGatewayResponseSchema.parse(dispatched.response)
if (!outgoing.headersSent && !outgoing.destroyed) {
outgoing.statusCode = parsedResponse.status
for (const [name, value] of Object.entries(
parsedResponse.headers
)) {
outgoing.setHeader(name, value)
}
const body = Buffer.from(
parsedResponse.bodyBase64,
'base64'
)
outgoing.setHeader('content-length', body.byteLength)
await endResponseFlushed(outgoing, body, () => {
responseFlushed = true
})
cancellation.signal.throwIfAborted()
await dispatched.acknowledgeDelivery()
deliveryAcknowledged = true
}
} catch (error) {
if (dispatched !== undefined && !deliveryAcknowledged) {
await Promise.resolve(
dispatched.failDelivery(error)
).catch(() => undefined)
}
if (!outgoing.headersSent && !outgoing.destroyed) {
const failure = httpFailure(error, cancellation.signal)
sendError(outgoing, failure.status, failure.code)
}
} finally {
clearTimeout(timeout)
incoming.off('aborted', onAborted)
outgoing.off('close', onResponseClose)
if (dispatchHeld) {
this.#releaseDispatch()
}
}
}
async #acquireDispatch(signal: AbortSignal): Promise<void> {
signal.throwIfAborted()
if (!this.#dispatchBusy) {
this.#dispatchBusy = true
return
}
await new Promise<void>((resolve, reject) => {
const waiter: DispatchWaiter = { resolve, reject, signal }
waiter.abort = (): void => {
const index = this.#dispatchWaiters.indexOf(waiter)
if (index >= 0) {
this.#dispatchWaiters.splice(index, 1)
}
reject(signal.reason)
}
signal.addEventListener('abort', waiter.abort, { once: true })
this.#dispatchWaiters.push(waiter)
})
}
#releaseDispatch(): void {
while (this.#dispatchWaiters.length > 0) {
const waiter = this.#dispatchWaiters.shift()!
if (waiter.abort !== undefined) {
waiter.signal.removeEventListener('abort', waiter.abort)
}
if (waiter.signal.aborted) {
waiter.reject(waiter.signal.reason)
continue
}
waiter.resolve()
return
}
this.#dispatchBusy = false
}
}
export async function runOpenCodeModelBridgeHelper(options: {
socketPath: string
protocol: ModelBridgeProtocol
model: string
supportsImageInput: boolean
opencodeEntrypoint: string
environment?: Readonly<NodeJS.ProcessEnv>
spawn?: ModelBridgeHelperSpawn
}): Promise<number> {
const socketPath = normalizedAbsolutePath(
options.socketPath,
'Model bridge socket'
)
const opencodeEntrypoint = normalizedAbsolutePath(
options.opencodeEntrypoint,
'OpenCode entrypoint'
)
const exchange = createUnixModelBridgeExchange({ socketPath })
const proxy = new ModelBridgeLoopbackProxy({ exchange })
const origin = await proxy.listen()
try {
const config = createOpenCodeModelBridgeProviderConfig({
protocol: options.protocol,
model: options.model,
loopbackOrigin: origin,
supportsImageInput: options.supportsImageInput
})
const environment = credentialFreeHelperEnvironment(
options.environment ?? process.env,
JSON.stringify(config)
)
const child = (options.spawn ?? defaultHelperSpawn)(
opencodeEntrypoint,
['acp'],
{
shell: false,
stdio: 'inherit',
env: environment
}
)
return await new Promise<number>((resolveExit, reject) => {
child.once('error', reject)
child.once('close', (code, signal) => {
if (signal !== null) {
resolveExit(128)
return
}
resolveExit(code ?? 1)
})
})
} finally {
await proxy.close()
}
}
function endResponseFlushed(
response: ServerResponse,
body: Uint8Array,
onFlushed: () => void
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const onFinish = (): void => {
cleanup()
onFlushed()
resolve()
}
const onClose = (): void => {
if (response.writableFinished) {
onFinish()
return
}
cleanup()
reject(new ModelBridgeBrokerError('transport-failed'))
}
const onError = (): void => {
cleanup()
reject(new ModelBridgeBrokerError('transport-failed'))
}
const cleanup = (): void => {
response.off('finish', onFinish)
response.off('close', onClose)
response.off('error', onError)
}
response.once('finish', onFinish)
response.once('close', onClose)
response.once('error', onError)
response.end(body)
})
}
type DispatchWaiter = {
resolve: () => void
reject: (error: unknown) => void
signal: AbortSignal
abort?: () => void
}
export function createOpenCodeModelBridgeProviderConfig(input: {
protocol: ModelBridgeProtocol
model: string
loopbackOrigin: string
name?: string
supportsImageInput?: boolean
}): OpenCodeModelBridgeProviderConfig {
const model = boundedMetadataText(input.model, 'Model name')
const name = boundedMetadataText(
input.name ?? 'GoodBuddy Model Bridge',
'Provider name'
)
const origin = parseLoopbackOrigin(input.loopbackOrigin)
const descriptor = providerDescriptor(input.protocol)
const npm = descriptor.npm
const providerId = descriptor.id
const baseURL = `${origin}/v1`
return {
model: `${providerId}/${model}`,
provider: {
[providerId]: {
name,
npm,
options: {
baseURL,
...(descriptor.requiresSdkAuthSentinel
? { apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL }
: {})
},
models: {
[model]: {
name,
attachment: input.supportsImageInput === true,
modalities: {
input:
input.supportsImageInput === true
? ['text', 'image']
: ['text'],
output: ['text']
},
provider: { npm }
}
}
}
}
}
}
async function parseHttpRequest(
incoming: IncomingMessage
): Promise<RemoteModelGatewayRequest> {
if (incoming.method !== 'POST') {
throw new HttpRequestError(405, 'method-not-allowed')
}
const path = remoteModelApiPathSchema.safeParse(incoming.url)
if (!path.success) {
throw new HttpRequestError(404, 'path-not-allowed')
}
validateInboundHeaders(
incoming.headers,
incoming.rawHeaders,
path.data
)
const declaredLength = parseContentLength(
incoming.headers['content-length']
)
if (declaredLength === undefined) {
throw new HttpRequestError(411, 'content-length-required')
}
if (
declaredLength !== undefined &&
declaredLength >
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
) {
incoming.resume()
throw new HttpRequestError(413, 'request-too-large')
}
const body = await readBoundedBody(incoming)
if (
declaredLength !== undefined &&
body.byteLength !== declaredLength
) {
throw new HttpRequestError(400, 'request-invalid')
}
const headers: Record<string, string> = {}
for (const name of FORWARDED_HEADER_NAMES) {
const value = incoming.headers[name]
if (typeof value === 'string') {
headers[name] = value
}
}
const parsed = remoteModelGatewayRequestSchema.safeParse({
method: 'POST',
path: path.data,
headers,
bodyBase64: body.toString('base64')
})
if (!parsed.success) {
throw new HttpRequestError(400, 'request-invalid')
}
return parsed.data
}
function validateInboundHeaders(
headers: IncomingHttpHeaders,
rawHeaders: readonly string[],
path: RemoteModelGatewayRequest['path']
): void {
const entries = Object.entries(headers)
if (entries.length > MAXIMUM_HTTP_HEADER_COUNT) {
throw new HttpRequestError(431, 'headers-rejected')
}
const securityCriticalNames = new Set([
'accept',
'connection',
'content-length',
'content-type',
'host',
'transfer-encoding',
...CREDENTIAL_INBOUND_HEADER_NAMES
])
const seen = new Set<string>()
for (let index = 0; index < rawHeaders.length; index += 2) {
const name = rawHeaders[index]?.toLowerCase()
if (
name !== undefined &&
securityCriticalNames.has(name) &&
seen.has(name)
) {
throw new HttpRequestError(400, 'headers-rejected')
}
if (name !== undefined) {
seen.add(name)
}
}
for (const [name, value] of entries) {
if (
!/^[a-z0-9!#$%&'*+.^_`|~-]+$/u.test(name) ||
Array.isArray(value) ||
(CREDENTIAL_INBOUND_HEADER_NAMES.has(name) &&
!isExpectedSdkAuthenticationHeader(path, name, value))
) {
throw new HttpRequestError(400, 'headers-rejected')
}
if (
value !== undefined &&
(value.length >
REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes ||
/[\r\n]/u.test(value))
) {
throw new HttpRequestError(431, 'headers-rejected')
}
if (name === 'transfer-encoding' && value !== undefined) {
throw new HttpRequestError(400, 'headers-rejected')
}
}
}
function isExpectedSdkAuthenticationHeader(
path: RemoteModelGatewayRequest['path'],
name: string,
value: string | undefined
): boolean {
if (path === '/v1/messages') {
return (
name === 'x-api-key' &&
value === MODEL_BRIDGE_SDK_AUTH_SENTINEL
)
}
if (path === '/responses' || path === '/v1/responses') {
return (
name === 'authorization' &&
value === `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
)
}
return false
}
function parseContentLength(
value: string | undefined
): number | undefined {
if (value === undefined) {
return undefined
}
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) {
throw new HttpRequestError(400, 'request-invalid')
}
const length = Number(value)
if (!Number.isSafeInteger(length)) {
throw new HttpRequestError(413, 'request-too-large')
}
return length
}
async function readBoundedBody(
incoming: IncomingMessage
): Promise<Buffer> {
const chunks: Buffer[] = []
let total = 0
for await (const untrustedChunk of incoming) {
const chunk = Buffer.isBuffer(untrustedChunk)
? untrustedChunk
: Buffer.from(untrustedChunk as Uint8Array)
total += chunk.byteLength
if (
total >
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
) {
throw new HttpRequestError(413, 'request-too-large')
}
chunks.push(chunk)
}
return Buffer.concat(chunks, total)
}
function sendError(
response: ServerResponse,
status: number,
code: string
): void {
const body = Buffer.from(JSON.stringify({ error: { code } }), 'utf8')
response.statusCode = status
response.setHeader('content-type', 'application/json')
response.setHeader('content-length', body.byteLength)
response.end(body)
}
function httpFailure(
error: unknown,
signal: AbortSignal
): { status: number; code: string } {
if (error instanceof HttpRequestError) {
return { status: error.status, code: error.code }
}
const reason = signal.reason
if (
(signal.aborted &&
reason instanceof ModelBridgeBrokerError &&
reason.code === 'request-timeout') ||
(error instanceof ModelBridgeBrokerError &&
error.code === 'request-timeout')
) {
return { status: 504, code: 'bridge-timeout' }
}
if (
signal.aborted ||
(error instanceof ModelBridgeBrokerError &&
error.code === 'request-cancelled')
) {
return { status: 499, code: 'request-cancelled' }
}
return { status: 502, code: 'bridge-failed' }
}
function providerDescriptor(protocol: ModelBridgeProtocol): {
id: string
npm:
| '@ai-sdk/anthropic'
| '@ai-sdk/openai-compatible'
| '@ai-sdk/openai'
requiresSdkAuthSentinel: boolean
} {
switch (protocol) {
case 'anthropic-messages':
return {
id: 'goodbuddy-anthropic',
npm: '@ai-sdk/anthropic',
requiresSdkAuthSentinel: true
}
case 'openai-chat-completions':
return {
id: 'goodbuddy-openai-chat',
npm: '@ai-sdk/openai-compatible',
requiresSdkAuthSentinel: false
}
case 'openai-responses':
return {
id: 'goodbuddy-openai-responses',
npm: '@ai-sdk/openai',
requiresSdkAuthSentinel: true
}
}
}
function normalizeExchangeResult(
value: Awaited<ReturnType<ModelBridgeExchange>>
): ModelBridgeExchangeResult {
if (
value !== null &&
typeof value === 'object' &&
'response' in value &&
typeof value.acknowledgeDelivery === 'function' &&
typeof value.failDelivery === 'function'
) {
return value
}
return {
response: remoteModelGatewayResponseSchema.parse(value),
acknowledgeDelivery: async () => undefined,
failDelivery: () => undefined
}
}
function defaultHelperSpawn(
executable: string,
args: readonly string[],
options: {
shell: false
stdio: 'inherit'
env: Readonly<NodeJS.ProcessEnv>
}
): ModelBridgeHelperChild {
return nodeSpawn(executable, [...args], {
shell: options.shell,
stdio: options.stdio,
env: options.env
})
}
function credentialFreeHelperEnvironment(
source: Readonly<NodeJS.ProcessEnv>,
config: string
): Readonly<NodeJS.ProcessEnv> {
const allowed = [
'HOME',
'LANG',
'LC_ALL',
'PATH',
'TMPDIR',
'XDG_CACHE_HOME',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_STATE_HOME'
] as const
const environment: NodeJS.ProcessEnv = {
OPENCODE_CONFIG_CONTENT: config
}
for (const name of allowed) {
const value = source[name]
if (
value === undefined ||
value.includes('\0') ||
/(?:key|secret|token|authorization)/iu.test(name)
) {
throw new Error(`Fixed helper environment is missing ${name}`)
}
environment[name] = value
}
return environment
}
function normalizedAbsolutePath(value: string, label: string): string {
if (
value.includes('\0') ||
!isAbsolute(value) ||
resolve(value) !== value
) {
throw new Error(`${label} must be a normalized absolute path`)
}
return value
}
function parseLoopbackOrigin(input: string): string {
let url: URL
try {
url = new URL(input)
} catch {
throw new Error('Model bridge origin is invalid')
}
if (
url.protocol !== 'http:' ||
url.hostname !== LOOPBACK_HOST ||
url.username ||
url.password ||
url.pathname !== '/' ||
url.search ||
url.hash ||
url.port === ''
) {
throw new Error('Model bridge origin must be loopback HTTP')
}
return url.origin
}
function boundedMetadataText(
value: string,
label: string
): string {
if (
value.length < 1 ||
Buffer.byteLength(value, 'utf8') > 512 ||
/[\0\r\n]/u.test(value)
) {
throw new Error(`${label} is invalid`)
}
return value
}
function listenOnLoopback(server: Server): Promise<void> {
return new Promise<void>((resolve, reject) => {
const onError = (error: Error): void => {
cleanup()
reject(error)
}
const onListening = (): void => {
cleanup()
resolve()
}
const cleanup = (): void => {
server.off('error', onError)
server.off('listening', onListening)
}
server.once('error', onError)
server.once('listening', onListening)
server.listen({
host: LOOPBACK_HOST,
port: 0,
exclusive: true
})
})
}
class HttpRequestError extends Error {
readonly status: number
readonly code: string
constructor(status: number, code: string) {
super('Model bridge HTTP request was rejected')
this.name = 'HttpRequestError'
this.status = status
this.code = code
}
}
+658
View File
@@ -0,0 +1,658 @@
import {
chmodSync,
existsSync,
lstatSync,
mkdtempSync,
rmSync,
writeFileSync
} from 'node:fs'
import {
request as httpRequest,
type RequestOptions
} from 'node:http'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
type RemoteModelGatewayRequest,
type RemoteModelGatewayResponse
} from '../shared/remote-model-gateway-contracts'
import {
createOpenCodeModelBridgeProviderConfig,
MODEL_BRIDGE_SDK_AUTH_SENTINEL,
ModelBridgeLoopbackProxy
} from './model-bridge-helper'
import {
createUnixModelBridgeExchange,
MODEL_BRIDGE_BROKER_SOCKET_NAME,
ModelBridgeBrokerServer,
type ModelBridgeExchange
} from './model-bridge-broker'
const temporaryPaths: string[] = []
const validResponse: RemoteModelGatewayResponse = {
status: 201,
headers: {
'content-type': 'application/json',
'x-request-id': 'provider-request'
},
bodyBase64: Buffer.from('{"ok":true}', 'utf8').toString(
'base64'
)
}
afterEach(() => {
vi.restoreAllMocks()
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('model bridge loopback helper', () => {
it('forwards only the bounded request contract and returns the response', async () => {
const exchange = vi.fn<ModelBridgeExchange>(
async () => validResponse
)
const proxy = new ModelBridgeLoopbackProxy({ exchange })
const origin = await proxy.listen()
try {
const response = await sendHttp(origin, {
path: '/v1/messages',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'x-api-key': MODEL_BRIDGE_SDK_AUTH_SENTINEL
},
body: '{"model":"private-model","messages":[]}'
})
expect(response).toEqual({
status: 201,
headers: expect.objectContaining({
'content-type': 'application/json',
'x-request-id': 'provider-request'
}),
body: '{"ok":true}'
})
expect(exchange).toHaveBeenCalledOnce()
const [request, context] = exchange.mock.calls[0]!
expect(request).toEqual({
method: 'POST',
path: '/v1/messages',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
bodyBase64: Buffer.from(
'{"model":"private-model","messages":[]}',
'utf8'
).toString('base64')
})
expect(context.requestId).toMatch(/^model-/u)
expect(context.signal.aborted).toBe(false)
} finally {
await proxy.close()
}
})
it('acknowledges delivery only after the HTTP response flushes', async () => {
const acknowledgeDelivery = vi.fn(async () => undefined)
const failDelivery = vi.fn(async () => undefined)
const proxy = new ModelBridgeLoopbackProxy({
exchange: async () => ({
response: validResponse,
acknowledgeDelivery,
failDelivery
})
})
const origin = await proxy.listen()
try {
const response = await sendHttp(origin, {
path: '/v1/messages',
body: '{}'
})
expect(response.body).toBe('{"ok":true}')
expect(acknowledgeDelivery).toHaveBeenCalledOnce()
expect(failDelivery).not.toHaveBeenCalled()
} finally {
await proxy.close()
}
})
it('flushes and acknowledges a multi-frame-sized HTTP response', async () => {
const body = Buffer.alloc(600 * 1024, 0x61)
const acknowledgeDelivery = vi.fn(async () => undefined)
const failDelivery = vi.fn(async () => undefined)
const proxy = new ModelBridgeLoopbackProxy({
exchange: async () => ({
response: {
status: 200,
headers: { 'content-type': 'application/octet-stream' },
bodyBase64: body.toString('base64')
},
acknowledgeDelivery,
failDelivery
})
})
const origin = await proxy.listen()
try {
const response = await sendHttp(origin, {
path: '/v1/messages',
body: '{}'
})
expect(Buffer.byteLength(response.body)).toBe(body.byteLength)
expect(acknowledgeDelivery).toHaveBeenCalledOnce()
expect(failDelivery).not.toHaveBeenCalled()
} finally {
await proxy.close()
}
})
it.each([
{
name: 'non-POST method',
request: { method: 'GET', path: '/v1/messages' },
status: 405
},
{
name: 'unknown path',
request: { path: '/images/generations' },
status: 404
},
{
name: 'path query',
request: { path: '/v1/messages?token=secret' },
status: 404
},
{
name: 'credential header',
request: {
path: '/v1/messages',
headers: { authorization: 'Bearer remote-secret' }
},
status: 400
}
])('rejects $name without dispatch', async ({ request, status }) => {
const exchange = vi.fn(async () => validResponse)
const proxy = new ModelBridgeLoopbackProxy({ exchange })
const origin = await proxy.listen()
try {
const response = await sendHttp(origin, request)
expect(response.status).toBe(status)
expect(response.body).not.toContain('remote-secret')
expect(exchange).not.toHaveBeenCalled()
} finally {
await proxy.close()
}
})
it('rejects a declared oversized body before dispatch', async () => {
const exchange = vi.fn(async () => validResponse)
const proxy = new ModelBridgeLoopbackProxy({ exchange })
const origin = await proxy.listen()
try {
const response = await sendHttp(origin, {
path: '/v1/messages',
headers: {
'content-length': String(
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes + 1
)
},
body: ''
})
expect(response.status).toBe(413)
expect(exchange).not.toHaveBeenCalled()
} finally {
await proxy.close()
}
})
it('drops harmless SDK metadata headers but rejects chunked framing', async () => {
const exchange = vi.fn<ModelBridgeExchange>(
async () => validResponse
)
const proxy = new ModelBridgeLoopbackProxy({ exchange })
const origin = await proxy.listen()
try {
const accepted = await sendHttp(origin, {
path: '/v1/messages',
headers: {
'content-type': 'application/json',
'x-stainless-runtime': 'node'
},
body: '{}'
})
expect(accepted.status).toBe(201)
expect(exchange).toHaveBeenCalledOnce()
expect(exchange.mock.calls[0]![0].headers).toEqual({
'content-type': 'application/json'
})
const rejected = await sendHttp(origin, {
path: '/v1/messages',
headers: {
'content-type': 'application/json',
'transfer-encoding': 'chunked'
},
body: '{}'
})
expect(rejected.status).toBe(400)
expect(exchange).toHaveBeenCalledOnce()
} finally {
await proxy.close()
}
})
it('accepts only the fixed SDK compatibility marker and strips it', async () => {
const exchange = vi.fn<ModelBridgeExchange>(
async () => validResponse
)
const proxy = new ModelBridgeLoopbackProxy({ exchange })
const origin = await proxy.listen()
try {
const accepted = await sendHttp(origin, {
path: '/v1/responses',
headers: {
authorization: `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
},
body: '{}'
})
expect(accepted.status).toBe(201)
expect(exchange).toHaveBeenCalledOnce()
expect(exchange.mock.calls[0]![0].headers).toEqual({})
for (const authorization of [
'Bearer remote-secret',
`Basic ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
]) {
const rejected = await sendHttp(origin, {
path: '/v1/responses',
headers: { authorization },
body: '{}'
})
expect(rejected.status).toBe(400)
}
expect(exchange).toHaveBeenCalledOnce()
} finally {
await proxy.close()
}
})
it('times out once, aborts dispatch, and never returns raw errors', async () => {
let dispatchSignal: AbortSignal | undefined
const exchange = vi.fn(
async (
_request: RemoteModelGatewayRequest,
context: { signal: AbortSignal }
) => {
dispatchSignal = context.signal
await new Promise(() => undefined)
return validResponse
}
)
const proxy = new ModelBridgeLoopbackProxy({
exchange,
requestTimeoutMs: 20
})
const origin = await proxy.listen()
try {
const response = await sendHttp(origin, {
path: '/v1/messages',
body: '{}'
})
expect(response.status).toBe(504)
expect(response.body).toBe(
'{"error":{"code":"bridge-timeout"}}'
)
expect(dispatchSignal?.aborted).toBe(true)
expect(exchange).toHaveBeenCalledOnce()
} finally {
await proxy.close()
}
})
it('maps dispatch errors without leaking credentials', async () => {
const privateValue = 'provider-key-do-not-leak'
const proxy = new ModelBridgeLoopbackProxy({
exchange: async () => {
throw new Error(`Upstream used ${privateValue}`)
}
})
const origin = await proxy.listen()
try {
const response = await sendHttp(origin, {
path: '/v1/messages',
body: '{}'
})
expect(response.status).toBe(502)
expect(response.body).toBe(
'{"error":{"code":"bridge-failed"}}'
)
expect(response.body).not.toContain(privateValue)
} finally {
await proxy.close()
}
})
it('queues concurrent OpenCode requests instead of returning bridge-busy', async () => {
let markStarted: (() => void) | undefined
let releaseFirst: (() => void) | undefined
const started = new Promise<void>((resolveStarted) => {
markStarted = resolveStarted
})
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve
})
let callIndex = 0
const exchange = vi.fn(async () => {
const index = callIndex++
if (index === 0) {
markStarted?.()
await firstGate
}
return validResponse
})
const proxy = new ModelBridgeLoopbackProxy({
exchange
})
const origin = await proxy.listen()
const first = sendHttp(origin, {
path: '/v1/messages',
body: '{}'
})
await started
const second = sendHttp(origin, {
path: '/v1/messages',
body: '{}'
})
try {
await new Promise<void>((resolve) => setTimeout(resolve, 20))
expect(exchange).toHaveBeenCalledOnce()
releaseFirst?.()
await expect(first).resolves.toMatchObject({ status: 201 })
await expect(second).resolves.toMatchObject({ status: 201 })
expect(exchange).toHaveBeenCalledTimes(2)
} finally {
await proxy.close()
}
})
it('aborts an in-flight dispatch when the proxy closes', async () => {
let dispatchSignal: AbortSignal | undefined
let markStarted: (() => void) | undefined
const started = new Promise<void>((resolveStarted) => {
markStarted = resolveStarted
})
const proxy = new ModelBridgeLoopbackProxy({
exchange: async (_request, context) => {
dispatchSignal = context.signal
markStarted?.()
await new Promise(() => undefined)
return validResponse
}
})
const origin = await proxy.listen()
const pending = sendHttp(origin, {
path: '/v1/messages',
body: '{}'
}).catch(() => undefined)
await started
await proxy.close()
await pending
expect(dispatchSignal?.aborted).toBe(true)
})
it.each([
{
protocol: 'anthropic-messages' as const,
providerId: 'goodbuddy-anthropic',
npm: '@ai-sdk/anthropic',
requiresSdkAuthSentinel: true
},
{
protocol: 'openai-chat-completions' as const,
providerId: 'goodbuddy-openai-chat',
npm: '@ai-sdk/openai-compatible',
requiresSdkAuthSentinel: false
},
{
protocol: 'openai-responses' as const,
providerId: 'goodbuddy-openai-responses',
npm: '@ai-sdk/openai',
requiresSdkAuthSentinel: true
}
])(
'creates secret-free $protocol OpenCode metadata',
({ protocol, providerId, npm, requiresSdkAuthSentinel }) => {
const config = createOpenCodeModelBridgeProviderConfig({
protocol,
model: 'private-model',
name: 'Private model',
loopbackOrigin: 'http://127.0.0.1:12345',
supportsImageInput: true
})
expect(config).toMatchObject({
model: `${providerId}/private-model`,
provider: {
[providerId]: {
npm,
options: {
baseURL: 'http://127.0.0.1:12345/v1'
},
models: {
'private-model': {
attachment: true,
provider: { npm }
}
}
}
}
})
const options = config.provider[providerId]!.options
expect(options.apiKey).toBe(
requiresSdkAuthSentinel
? MODEL_BRIDGE_SDK_AUTH_SENTINEL
: undefined
)
expect(JSON.stringify(config)).not.toContain('provider.example')
}
)
})
describe('model bridge Unix broker', () => {
const runOnUnix = process.platform === 'win32' ? it.skip : it
runOnUnix('rejects a socket path beyond the Unix kernel limit', () => {
const scratch = resolve(
privateTemporaryDirectory(),
'a'.repeat(150)
)
expect(
() =>
new ModelBridgeBrokerServer({
scratchDirectory: scratch,
dispatch: async () => validResponse
})
).toThrow('endpoint path is too long')
})
runOnUnix(
'uses a private owned socket, exchanges one request, and cleans up',
async () => {
const scratch = privateTemporaryDirectory()
const dispatch = vi.fn(async () => validResponse)
const broker = new ModelBridgeBrokerServer({
scratchDirectory: scratch,
dispatch
})
await broker.listen()
const socketPath = resolve(
scratch,
MODEL_BRIDGE_BROKER_SOCKET_NAME
)
expect(lstatSync(socketPath).mode & 0o777).toBe(0o600)
const exchange = createUnixModelBridgeExchange({
socketPath,
requestTimeoutMs: 1_000
})
const request: RemoteModelGatewayRequest = {
method: 'POST',
path: '/v1/responses',
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.from('{}').toString('base64')
}
const exchanged = await exchange(request, {
requestId: 'request-a',
signal: new AbortController().signal
})
expect(
'response' in exchanged ? exchanged.response : exchanged
).toEqual(validResponse)
if ('response' in exchanged) {
await exchanged.acknowledgeDelivery()
}
expect(dispatch).toHaveBeenCalledWith(request, {
requestId: 'request-a',
signal: expect.any(AbortSignal)
})
await broker.close()
expect(existsSync(socketPath)).toBe(false)
}
)
runOnUnix(
'refuses to replace or remove a non-socket endpoint path',
async () => {
const scratch = privateTemporaryDirectory()
const socketPath = join(
scratch,
MODEL_BRIDGE_BROKER_SOCKET_NAME
)
writeFileSync(socketPath, 'user-owned')
chmodSync(socketPath, 0o600)
const broker = new ModelBridgeBrokerServer({
scratchDirectory: scratch,
dispatch: async () => validResponse
})
await expect(broker.listen()).rejects.toThrow(
'not an owned Unix socket'
)
await broker.close()
expect(existsSync(socketPath)).toBe(true)
}
)
runOnUnix(
'bounds dispatch time and forwards only a generic failure code',
async () => {
const scratch = privateTemporaryDirectory()
const privateValue = 'provider-secret-in-error'
let dispatchSignal: AbortSignal | undefined
const broker = new ModelBridgeBrokerServer({
scratchDirectory: scratch,
requestTimeoutMs: 20,
dispatch: async (_request, context) => {
dispatchSignal = context.signal
await new Promise(() => undefined)
throw new Error(privateValue)
}
})
await broker.listen()
try {
const exchange = createUnixModelBridgeExchange({
socketPath: broker.socketPath,
requestTimeoutMs: 1_000
})
await expect(
exchange(
{
method: 'POST',
path: '/v1/messages',
headers: {},
bodyBase64: ''
},
{
requestId: 'request-timeout',
signal: new AbortController().signal
}
)
).rejects.toMatchObject({
code: 'request-timeout',
message: 'Model bridge request timed out'
})
expect(dispatchSignal?.aborted).toBe(true)
} finally {
await broker.close()
}
}
)
})
function privateTemporaryDirectory(): string {
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-model-bridge-'))
temporaryPaths.push(path)
if (process.platform !== 'win32') {
chmodSync(path, 0o700)
}
return resolve(path)
}
async function sendHttp(
origin: string,
options: {
method?: string
path?: string
headers?: Record<string, string>
body?: string
}
): Promise<{
status: number
headers: Record<string, string | string[] | undefined>
body: string
}> {
const url = new URL(origin)
const body = options.body ?? ''
const requestOptions: RequestOptions = {
hostname: url.hostname,
port: url.port,
method: options.method ?? 'POST',
path: options.path ?? '/v1/messages',
headers: {
...(options.headers ?? {}),
...(!hasHeader(options.headers, 'content-length') &&
!hasHeader(options.headers, 'transfer-encoding')
? { 'content-length': Buffer.byteLength(body) }
: {})
}
}
return await new Promise((resolveResponse, reject) => {
const request = httpRequest(requestOptions, (response) => {
const chunks: Buffer[] = []
response.on('data', (chunk: Buffer) => chunks.push(chunk))
response.once('end', () => {
resolveResponse({
status: response.statusCode ?? 0,
headers: response.headers,
body: Buffer.concat(chunks).toString('utf8')
})
})
})
request.once('error', reject)
request.end(body)
})
}
function hasHeader(
headers: Record<string, string> | undefined,
expected: string
): boolean {
return Object.keys(headers ?? {}).some(
(name) => name.toLowerCase() === expected
)
}
@@ -0,0 +1,446 @@
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
remoteRuntimeBundleManifestSchema,
type RemoteRuntimeBundleManifest
} from '../shared/remote-runtime-launch-contracts'
import {
OPENCODE_BWRAP_EXECUTABLE,
createOpenCodeLaunchProfile
} from './opencode-runtime-profile'
const temporaryPaths: string[] = []
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('OpenCode bubblewrap launch profile', () => {
it('uses a read-only Workspace bind for Ask', () => {
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'
])
})
it('runs Execute directly with the SSH account environment', () => {
const fixture = createFixture()
const execute = createOpenCodeLaunchProfile({
...fixture,
workMode: 'execute'
})
expect(execute.executable).toBe(
resolve(fixture.bundleDirectory, 'bin', 'opencode')
)
expect(execute.processExecutable).toBe(execute.executable)
expect(execute.args).toEqual(['acp'])
expect(execute.cwd).toBe(fixture.workspaceDirectory)
expect(execute.env).toMatchObject({
HOME:
process.env.HOME ?? fixture.workspaceDirectory,
LANG: process.env.LANG || 'C.UTF-8',
LC_ALL: process.env.LC_ALL || 'C.UTF-8',
PATH: process.env.PATH || '/usr/bin:/bin',
TMPDIR: process.env.TMPDIR || '/tmp',
XDG_CACHE_HOME:
process.env.XDG_CACHE_HOME ||
resolve(
process.env.HOME ?? fixture.workspaceDirectory,
'.cache'
),
XDG_CONFIG_HOME:
process.env.XDG_CONFIG_HOME ||
resolve(
process.env.HOME ?? fixture.workspaceDirectory,
'.config'
),
XDG_DATA_HOME:
process.env.XDG_DATA_HOME ||
resolve(
process.env.HOME ?? fixture.workspaceDirectory,
'.local',
'share'
),
XDG_STATE_HOME:
process.env.XDG_STATE_HOME ||
resolve(
process.env.HOME ?? fixture.workspaceDirectory,
'.local',
'state'
)
})
expect(execute.args).not.toContain('--unshare-all')
})
it('sets only the fixed environment and rejects a changed profile', () => {
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(() =>
createOpenCodeLaunchProfile({
...fixture,
manifest: remoteRuntimeBundleManifestSchema.parse({
...fixture.manifest,
allowedEnvironmentNames: ['HOME']
}),
workMode: 'ask'
})
).toThrow(/environment allowlist/iu)
expect(() =>
createOpenCodeLaunchProfile({
...fixture,
workspaceDirectory: 'relative/workspace',
workMode: 'ask'
})
).toThrow(/normalized absolute/iu)
})
it('rejects overlapping bundle, Workspace, and scratch mounts', () => {
const fixture = createFixture()
for (const conflict of [
{
scratchDirectory: fixture.workspaceDirectory
},
{
scratchDirectory: resolve(
fixture.workspaceDirectory,
'scratch'
)
},
{
workspaceDirectory: resolve(
fixture.bundleDirectory,
'workspace'
)
},
{
workspaceDirectory: resolve(
fixture.bundleDirectory,
'..'
)
}
]) {
expect(() =>
createOpenCodeLaunchProfile({
...fixture,
...conflict,
workMode: 'ask'
})
).toThrow(/must not overlap/iu)
}
})
it('launches the signed Agent helper with fixed credential-free bridge argv', () => {
const fixture = createFixture()
const bridgeDirectory = resolve(
fixture.workspaceDirectory,
'..',
'bridge'
)
mkdirSync(bridgeDirectory, { mode: 0o700 })
const agentInstallationDirectory = resolve(
fixture.workspaceDirectory,
'..',
'agent-installation'
)
mkdirSync(agentInstallationDirectory, { mode: 0o700 })
const agentExecutablePath = resolve(
agentInstallationDirectory,
'goodbuddy-agent'
)
const socketPath = resolve(
bridgeDirectory,
'model-bridge.sock'
)
const profile = createOpenCodeLaunchProfile({
...fixture,
workMode: 'ask',
modelBridge: {
agentExecutablePath,
bridgeDirectory,
socketPath,
policy: {
protocol: 'anthropic-messages',
model: 'private-model',
modelProfileDigest: `sha256:${'9'.repeat(64)}`,
supportsImageInput: false
}
}
})
expectArgumentSequence(profile.args, [
'--ro-bind',
agentInstallationDirectory,
agentInstallationDirectory
])
expectArgumentSequence(profile.args, [
'--ro-bind',
bridgeDirectory,
bridgeDirectory
])
expectArgumentSequence(profile.args, [
'--',
resolve(agentInstallationDirectory, 'node'),
resolve(agentInstallationDirectory, 'lib', 'agent.cjs'),
'model-bridge-helper',
'--socket-path',
socketPath,
'--protocol',
'anthropic-messages',
'--model',
'private-model',
'--supports-image-input',
'false',
'--opencode-entrypoint',
resolve(fixture.bundleDirectory, 'bin', 'opencode')
])
expect(profile.args.join('\0')).not.toMatch(
/api[-_]?key|authorization|bearer|(?:^|\0)--token(?:\0|$)/iu
)
const executeProfile = createOpenCodeLaunchProfile({
...fixture,
workMode: 'execute',
modelBridge: {
agentExecutablePath,
bridgeDirectory,
socketPath,
policy: {
protocol: 'anthropic-messages',
model: 'private-model',
modelProfileDigest: `sha256:${'9'.repeat(64)}`,
supportsImageInput: false
}
}
})
expect(executeProfile.executable).toBe(
resolve(agentInstallationDirectory, 'node')
)
expect(executeProfile.processExecutable).toBe(
resolve(agentInstallationDirectory, 'node')
)
expect(executeProfile.args[0]).toBe(
resolve(agentInstallationDirectory, 'lib', 'agent.cjs')
)
})
it('allows Execute to use a workspace that contains managed Runtime paths', () => {
const fixture = createFixture()
const workspaceDirectory = resolve(
fixture.workspaceDirectory,
'..'
)
const managedRoot = resolve(workspaceDirectory, '.goodbuddy')
const bundleDirectory = resolve(
managedRoot,
'runtimes',
'opencode'
)
const scratchDirectory = resolve(
managedRoot,
'scratch',
'prompt'
)
const agentInstallationDirectory = resolve(
managedRoot,
'agent',
'installation'
)
const bridgeDirectory = resolve(
managedRoot,
'bridges',
'prompt'
)
const modelBridge = {
agentExecutablePath: resolve(
agentInstallationDirectory,
'goodbuddy-agent'
),
bridgeDirectory,
socketPath: resolve(
bridgeDirectory,
'model-bridge.sock'
),
policy: {
protocol: 'anthropic-messages' as const,
model: 'private-model',
modelProfileDigest: `sha256:${'9'.repeat(64)}`,
supportsImageInput: false
}
}
expect(
createOpenCodeLaunchProfile({
manifest: fixture.manifest,
bundleDirectory,
workspaceDirectory,
scratchDirectory,
workMode: 'execute',
modelBridge
})
).toMatchObject({
cwd: workspaceDirectory,
workMode: 'execute'
})
expect(() =>
createOpenCodeLaunchProfile({
manifest: fixture.manifest,
bundleDirectory,
workspaceDirectory,
scratchDirectory,
workMode: 'ask',
modelBridge
})
).toThrow(/paths must not overlap/iu)
})
})
function createFixture(): {
manifest: RemoteRuntimeBundleManifest
bundleDirectory: string
workspaceDirectory: string
scratchDirectory: string
} {
const root = resolve(
mkdtempSync(join(tmpdir(), 'goodbuddy-opencode-profile-'))
)
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') {
chmodSync(directory, 0o700)
}
}
return {
bundleDirectory,
workspaceDirectory,
scratchDirectory,
manifest: remoteRuntimeBundleManifestSchema.parse({
formatVersion: 2,
product: 'GoodBuddy',
runtimeId: 'opencode',
runtimeVersion: '1.18.9',
provider: 'opencode',
platform: 'linux',
architecture: 'x64',
signingKeyId: 'runtime-test',
bundleDigest: `sha256:${'a'.repeat(64)}`,
adapterDigest: `sha256:${'b'.repeat(64)}`,
sourcePackage: {
name: 'opencode-linux-x64-baseline',
integrity:
'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ=='
},
entrypoint: {
identity: 'opencode-acp',
path: 'bin/opencode',
sha256: 'd'.repeat(64),
argvPrefix: ['acp']
},
files: [
{
path: 'bin/opencode',
size: 64,
sha256: 'd'.repeat(64),
mode: '0755'
},
{
path: 'licenses/opencode.txt',
size: 4,
sha256: 'e'.repeat(64),
mode: '0644'
}
],
licenses: [
{
package: 'opencode-ai',
version: '1.18.9',
spdx: 'MIT',
path: 'licenses/opencode.txt'
}
],
allowedEnvironmentNames: [
'HOME',
'LANG',
'LC_ALL',
'PATH',
'TMPDIR',
'XDG_CACHE_HOME',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_STATE_HOME'
],
protocol: { major: 1, minor: 0 },
acpCapabilitiesDigest: `sha256:${'f'.repeat(64)}`,
limits: {
maximumPromptRuntimeMilliseconds: 60_000,
maximumPromptInputBytes: 4096,
maximumPromptOutputBytes: 1024 * 1024
}
})
}
}
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)
}
@@ -0,0 +1,352 @@
import {
dirname,
isAbsolute,
join,
relative,
resolve,
sep
} from 'node:path'
import {
OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES,
remoteRuntimeBundleManifestSchema,
type RemoteRuntimeBundleManifest
} from '../shared/remote-runtime-launch-contracts'
import {
modelBridgePolicySchema,
type ModelBridgePolicy
} from '../shared/model-bridge-contracts'
import {
assertAbsoluteManagedPath,
ensurePrivateDirectoryTree
} from './managed-paths'
export const OPENCODE_BWRAP_EXECUTABLE = '/usr/bin/bwrap'
export type OpenCodeLaunchProfile = {
executable: string
processExecutable: string
args: readonly string[]
cwd: string
env: Readonly<NodeJS.ProcessEnv>
workMode: 'ask' | 'execute'
}
export function createOpenCodeLaunchProfile(input: {
manifest: RemoteRuntimeBundleManifest
bundleDirectory: string
workspaceDirectory: string
scratchDirectory: string
workMode: 'ask' | 'execute'
modelBridge?: {
agentExecutablePath: string
bridgeDirectory: string
socketPath: string
policy: ModelBridgePolicy
}
}): OpenCodeLaunchProfile {
const manifest = remoteRuntimeBundleManifestSchema.parse(
input.manifest
)
if (
manifest.runtimeId !== 'opencode' ||
manifest.provider !== 'opencode' ||
manifest.entrypoint.identity !== 'opencode-acp' ||
manifest.entrypoint.argvPrefix.length !== 1 ||
manifest.entrypoint.argvPrefix[0] !== 'acp'
) {
throw new Error('Runtime manifest is not the fixed OpenCode ACP profile')
}
if (
[...manifest.allowedEnvironmentNames].sort().join('\0') !==
[...OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES]
.sort()
.join('\0')
) {
throw new Error(
'OpenCode Runtime environment allowlist does not match the fixed profile'
)
}
const bundleDirectory = assertAbsoluteManagedPath(
resolve(input.bundleDirectory)
)
const workspaceDirectory = normalizedAbsolutePath(
input.workspaceDirectory,
'Workspace'
)
const modelBridge =
input.modelBridge === undefined
? undefined
: {
agentExecutablePath: normalizedAbsolutePath(
input.modelBridge.agentExecutablePath,
'Agent executable'
),
agentInstallationDirectory: normalizedAbsolutePath(
dirname(input.modelBridge.agentExecutablePath),
'Agent installation'
),
bridgeDirectory: normalizedAbsolutePath(
input.modelBridge.bridgeDirectory,
'Model bridge'
),
socketPath: normalizedAbsolutePath(
input.modelBridge.socketPath,
'Model bridge socket'
),
policy: modelBridgePolicySchema.parse(
input.modelBridge.policy
)
}
if (
modelBridge !== undefined &&
(!isStrictDescendant(
modelBridge.bridgeDirectory,
modelBridge.socketPath
) ||
!isStrictDescendant(
modelBridge.agentInstallationDirectory,
modelBridge.agentExecutablePath
))
) {
throw new Error(
'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('/')
)
if (!isStrictDescendant(bundleDirectory, executablePath)) {
throw new Error('OpenCode entrypoint escapes its bundle directory')
}
const runtimeCommand =
modelBridge === undefined
? {
executable: executablePath,
args: [...manifest.entrypoint.argvPrefix]
}
: {
executable: join(
modelBridge.agentInstallationDirectory,
'node'
),
args: [
join(
modelBridge.agentInstallationDirectory,
'lib',
'agent.cjs'
),
'model-bridge-helper',
'--socket-path',
modelBridge.socketPath,
'--protocol',
modelBridge.policy.protocol,
'--model',
modelBridge.policy.model,
'--supports-image-input',
modelBridge.policy.supportsImageInput ? 'true' : 'false',
'--opencode-entrypoint',
executablePath
]
}
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,
processExecutable: OPENCODE_BWRAP_EXECUTABLE,
args,
cwd: scratchDirectory,
env: {
PATH: '/usr/bin:/bin',
LANG: 'C.UTF-8'
},
workMode: 'ask'
}
}
function normalizedAbsolutePath(value: string, label: string): string {
if (
value.includes('\0') ||
!isAbsolute(value) ||
resolve(value) !== value
) {
throw new Error(`${label} path must be a normalized absolute path`)
}
return value
}
function executeEnvironment(
source: Readonly<NodeJS.ProcessEnv>,
workspaceDirectory: string
): Readonly<NodeJS.ProcessEnv> {
const home = usableEnvironmentValue(source.HOME)
? source.HOME!
: workspaceDirectory
const defaults = {
HOME: home,
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
PATH: '/usr/bin:/bin',
TMPDIR: '/tmp',
XDG_CACHE_HOME: join(home, '.cache'),
XDG_CONFIG_HOME: join(home, '.config'),
XDG_DATA_HOME: join(home, '.local', 'share'),
XDG_STATE_HOME: join(home, '.local', 'state')
} as const
const environment: NodeJS.ProcessEnv = { ...source }
for (const [name, fallback] of Object.entries(defaults)) {
if (!usableEnvironmentValue(environment[name])) {
environment[name] = fallback
}
}
return environment
}
function usableEnvironmentValue(
value: string | undefined
): value is string {
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 (
child.length > 0 &&
child !== '..' &&
!child.startsWith(`..${sep}`) &&
!isAbsolute(child)
)
}
@@ -0,0 +1,106 @@
import type { Socket } from 'node:net'
import { describe, expect, it, vi } from 'vitest'
import {
createNativeBinding,
createLinuxPeerIdentityProvider,
LinuxPeerIdentityProvider,
type LinuxPeerCredentialNativeBinding
} from './peer-identity-provider'
describe('Linux SO_PEERCRED provider', () => {
it('returns only kernel-provided credentials for the accepted socket', async () => {
const native = nativeBinding((descriptor, credentials, length) => {
expect(descriptor).toBe(42)
expect(length.readUInt32LE()).toBe(12)
credentials.writeInt32LE(1234, 0)
credentials.writeUInt32LE(1000, 4)
credentials.writeUInt32LE(1000, 8)
return 0
})
const provider = new LinuxPeerIdentityProvider(native)
await expect(
provider.getPeerIdentity(socketWithDescriptor(42))
).resolves.toEqual({ uid: 1000, pid: 1234 })
})
it('fails closed on missing descriptors, native errors, and ABI mismatch', async () => {
const provider = new LinuxPeerIdentityProvider(
nativeBinding((_descriptor, _credentials, length) => {
length.writeUInt32LE(8)
return 0
})
)
await expect(
provider.getPeerIdentity({} as Socket)
).rejects.toMatchObject({
code: 'peer-identity-unavailable'
})
await expect(
new LinuxPeerIdentityProvider(
nativeBinding(() => -1, 13)
).getPeerIdentity(socketWithDescriptor(7))
).rejects.toThrow('errno 13')
await expect(
provider.getPeerIdentity(socketWithDescriptor(7))
).rejects.toThrow('invalid ABI size')
})
it('rejects unsupported platforms and ABIs before loading native code', async () => {
const loadKoffi = vi.fn()
await expect(
createLinuxPeerIdentityProvider({
platform: 'win32',
architecture: 'x64',
byteOrder: 'LE',
loadKoffi
})
).rejects.toMatchObject({
code: 'peer-identity-unavailable'
})
await expect(
createLinuxPeerIdentityProvider({
platform: 'linux',
architecture: 'ia32',
byteOrder: 'LE',
loadKoffi
})
).rejects.toMatchObject({
code: 'peer-identity-unavailable'
})
await expect(
createLinuxPeerIdentityProvider({
platform: 'linux',
architecture: 'arm64',
byteOrder: 'BE',
loadKoffi
})
).rejects.toMatchObject({
code: 'peer-identity-unavailable'
})
expect(loadKoffi).not.toHaveBeenCalled()
expect(() =>
createNativeBinding({
sizeof: () => 8
} as never)
).toThrow('integer ABI is unsupported')
})
})
function nativeBinding(
getSocketPeerCredentials: LinuxPeerCredentialNativeBinding[
'getSocketPeerCredentials'
],
errorNumber = 0
): LinuxPeerCredentialNativeBinding {
return {
getSocketPeerCredentials,
errno: () => errorNumber
}
}
function socketWithDescriptor(descriptor: number): Socket {
return {
_handle: { fd: descriptor }
} as unknown as Socket
}
+158
View File
@@ -0,0 +1,158 @@
import { arch, endianness } from 'node:os'
import type { Socket } from 'node:net'
import type koffiType from 'koffi'
import { AgentUnsupportedError } from './errors'
import type {
PeerIdentity,
UnixPeerIdentityProvider
} from './private-endpoint'
const SOL_SOCKET = 1
const SO_PEERCRED = 17
const LINUX_UCRED_BYTES = 12
const SUPPORTED_ARCHITECTURES = new Set(['x64', 'arm64'])
type Koffi = typeof koffiType
export type LinuxPeerCredentialNativeBinding = {
getSocketPeerCredentials(
descriptor: number,
credentials: Buffer,
length: Buffer
): number
errno(): number
}
export class LinuxPeerIdentityProvider
implements UnixPeerIdentityProvider
{
readonly #native: LinuxPeerCredentialNativeBinding
constructor(native: LinuxPeerCredentialNativeBinding) {
this.#native = native
}
async getPeerIdentity(socket: Socket): Promise<PeerIdentity> {
const descriptor = socketDescriptor(socket)
const credentials = Buffer.alloc(LINUX_UCRED_BYTES)
const length = Buffer.alloc(4)
length.writeUInt32LE(credentials.byteLength)
const result = this.#native.getSocketPeerCredentials(
descriptor,
credentials,
length
)
if (result !== 0) {
const nativeError = this.#native.errno()
throw new Error(
`getsockopt(SO_PEERCRED) failed with errno ${nativeError}`
)
}
if (length.readUInt32LE() !== LINUX_UCRED_BYTES) {
throw new Error('getsockopt(SO_PEERCRED) returned an invalid ABI size')
}
const pid = credentials.readInt32LE(0)
const uid = credentials.readUInt32LE(4)
if (pid <= 0) {
throw new Error('getsockopt(SO_PEERCRED) returned an invalid PID')
}
return { uid, pid }
}
}
export async function createLinuxPeerIdentityProvider(
options: {
platform?: NodeJS.Platform
architecture?: string
byteOrder?: 'BE' | 'LE'
loadKoffi?: () => Promise<Koffi>
} = {}
): Promise<LinuxPeerIdentityProvider> {
const platform = options.platform ?? process.platform
const architecture = options.architecture ?? arch()
const byteOrder = options.byteOrder ?? endianness()
if (
platform !== 'linux' ||
!SUPPORTED_ARCHITECTURES.has(architecture) ||
byteOrder !== 'LE'
) {
throw unavailable(
'SO_PEERCRED is certified only on little-endian Linux x64 and arm64'
)
}
try {
const koffi =
await (options.loadKoffi?.() ??
import('koffi').then((module) => module.default))
return new LinuxPeerIdentityProvider(createNativeBinding(koffi))
} catch (error) {
if (error instanceof AgentUnsupportedError) {
throw error
}
throw unavailable(
`SO_PEERCRED native binding is unavailable: ${boundedError(error)}`
)
}
}
export function createNativeBinding(
koffi: Koffi
): LinuxPeerCredentialNativeBinding {
if (
koffi.sizeof('int32_t') !== 4 ||
koffi.sizeof('uint32_t') !== 4
) {
throw unavailable('Linux peer credential integer ABI is unsupported')
}
const libc = koffi.load(null)
const getsockopt = libc.func('getsockopt', 'int', [
'int',
'int',
'int',
'void *',
'void *'
])
return {
getSocketPeerCredentials: (
descriptor,
credentials,
length
): number =>
getsockopt(
descriptor,
SOL_SOCKET,
SO_PEERCRED,
credentials,
length
) as number,
errno: () => koffi.errno()
}
}
function socketDescriptor(socket: Socket): number {
const descriptor = (
socket as Socket & {
_handle?: { fd?: unknown }
}
)._handle?.fd
if (
typeof descriptor !== 'number' ||
!Number.isSafeInteger(descriptor) ||
descriptor < 0
) {
throw unavailable('The accepted Unix socket descriptor is unavailable')
}
return descriptor
}
function unavailable(message: string): AgentUnsupportedError {
return new AgentUnsupportedError(
message,
'peer-identity-unavailable'
)
}
function boundedError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return message.replaceAll(/\s+/gu, ' ').trim().slice(0, 500)
}
+576
View File
@@ -0,0 +1,576 @@
import {
chmodSync,
mkdtempSync,
rmSync,
unlinkSync
} from 'node:fs'
import { createConnection, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_PROTOCOL_VERSION,
attachWelcomeSchema,
daemonCapabilitiesSchema,
daemonStatusSchema
} from '../shared/agent-protocol'
import {
decodeAgentFrame,
encodeAgentFrame,
type AgentFrame
} from '../shared/agent-protocol/frame'
import { ControllerRegistry } from './controller-registry'
import { EventJournal } from './event-journal'
import { InstallationChallengeVerifier } from './installation-challenge'
import {
attachRelay,
PrivateEndpoint,
type PeerIdentity
} from './private-endpoint'
import { AgentProtocolServer } from './protocol-server'
const runOnUnix = process.platform !== 'win32' ? it : it.skip
const temporaryPaths: string[] = []
const binaryDigest = `sha256:${'a'.repeat(64)}`
const endpointIdentity = {
installationId: 'installation-a',
binaryDigest,
daemonBootId: 'boot-a',
protocol: AGENT_PROTOCOL_VERSION
}
afterEach(() => {
vi.restoreAllMocks()
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('private endpoint lifecycle', () => {
runOnUnix(
'never pre-unlinks an endpoint that may still be live',
async () => {
const root = privateTemporaryDirectory()
const socketPath = resolve(root, 'agent.sock')
const existing = createServer()
await new Promise<void>((resolveListen, reject) => {
existing.once('error', reject)
existing.listen(socketPath, resolveListen)
})
const endpoint = new PrivateEndpoint({
socketPath,
peerIdentity: currentPeerIdentity(),
challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)),
controllers: new ControllerRegistry(),
...endpointIdentity,
onAttach: vi.fn()
})
await expect(endpoint.listen()).rejects.toMatchObject({
code: 'EADDRINUSE'
})
const client = createConnection(socketPath)
await expect(onceConnected(client)).resolves.toBeUndefined()
client.destroy()
await new Promise<void>((resolveClose) =>
existing.close(() => resolveClose())
)
}
)
runOnUnix(
'never unlinks a replacement socket it did not bind',
async () => {
const root = privateTemporaryDirectory()
const socketPath = resolve(root, 'agent.sock')
const endpoint = new PrivateEndpoint({
socketPath,
peerIdentity: currentPeerIdentity(),
challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)),
controllers: new ControllerRegistry(),
...endpointIdentity,
onAttach: vi.fn()
})
await endpoint.listen()
unlinkSync(socketPath)
const replacement = createServer()
await new Promise<void>((resolveListen, reject) => {
replacement.once('error', reject)
replacement.listen(socketPath, resolveListen)
})
await endpoint.close()
const client = createConnection(socketPath)
await expect(onceConnected(client)).resolves.toBeUndefined()
client.destroy()
await new Promise<void>((resolveClose) =>
replacement.close(() => resolveClose())
)
}
)
runOnUnix(
'destroys an authenticated persistent socket during shutdown',
async () => {
const root = privateTemporaryDirectory()
const secret = Buffer.alloc(32, 7)
const verifier = new InstallationChallengeVerifier(secret)
const attached = vi.fn()
const endpoint = new PrivateEndpoint({
socketPath: resolve(root, 'agent.sock'),
peerIdentity: currentPeerIdentity(),
challenge: verifier,
controllers: new ControllerRegistry(),
...endpointIdentity,
onAttach: attached
})
await endpoint.listen()
const client = createConnection(resolve(root, 'agent.sock'))
await onceConnected(client)
const challenge = await readPacket(client)
const clientNonce = 'client-nonce'
await writePacket(client, {
type: 'response',
version: 1,
controllerId: 'controller-a',
clientNonce,
response: verifier.createResponse({
serverNonce: String(challenge.serverNonce),
clientNonce,
controllerId: 'controller-a'
}),
protocol: AGENT_PROTOCOL_VERSION
})
const welcome = await readPacket(client)
expect(attachWelcomeSchema.parse(welcome)).toEqual({
type: 'goodbuddy-agent-welcome',
protocol: AGENT_PROTOCOL_VERSION,
connectionId: expect.any(String),
generation: 1,
...endpointIdentity,
serverNonce: challenge.serverNonce
})
await vi.waitFor(() => expect(attached).toHaveBeenCalledOnce())
const closed = onceClosed(client)
await expect(endpoint.close()).resolves.toBeUndefined()
await closed
}
)
runOnUnix(
'settles a socket that is still resolving peer identity',
async () => {
const root = privateTemporaryDirectory()
let resolvePeer: ((peer: PeerIdentity) => void) | undefined
const peerIdentity = vi.fn(
() =>
new Promise<PeerIdentity>((resolveIdentity) => {
resolvePeer = resolveIdentity
})
)
const attached = vi.fn()
const endpoint = new PrivateEndpoint({
socketPath: resolve(root, 'agent.sock'),
peerIdentity: { getPeerIdentity: peerIdentity },
challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)),
controllers: new ControllerRegistry(),
...endpointIdentity,
onAttach: attached
})
await endpoint.listen()
const client = createConnection(resolve(root, 'agent.sock'))
await onceConnected(client)
await vi.waitFor(() => expect(peerIdentity).toHaveBeenCalledOnce())
const closed = onceClosed(client)
await expect(endpoint.close()).resolves.toBeUndefined()
await closed
resolvePeer?.({ uid: process.getuid!() })
await Promise.resolve()
expect(attached).not.toHaveBeenCalled()
}
)
runOnUnix('rejects connections beyond the configured cap', async () => {
const root = privateTemporaryDirectory()
const peerIdentity = vi.fn(
() => new Promise<PeerIdentity>(() => undefined)
)
const endpoint = new PrivateEndpoint({
socketPath: resolve(root, 'agent.sock'),
peerIdentity: { getPeerIdentity: peerIdentity },
challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)),
controllers: new ControllerRegistry(),
...endpointIdentity,
onAttach: vi.fn(),
maximumConnections: 1
})
await endpoint.listen()
const first = createConnection(resolve(root, 'agent.sock'))
await onceConnected(first)
await vi.waitFor(() => expect(peerIdentity).toHaveBeenCalledOnce())
const second = createConnection(resolve(root, 'agent.sock'))
const rejected = onceClosed(second)
await onceConnected(second)
await rejected
expect(peerIdentity).toHaveBeenCalledOnce()
await endpoint.close()
first.destroy()
})
runOnUnix(
'rejects malformed or client-extended authentication packets',
async () => {
const root = privateTemporaryDirectory()
const endpoint = new PrivateEndpoint({
socketPath: resolve(root, 'agent.sock'),
peerIdentity: currentPeerIdentity(),
challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)),
controllers: new ControllerRegistry(),
...endpointIdentity,
onAttach: vi.fn()
})
await endpoint.listen()
const client = createConnection(resolve(root, 'agent.sock'))
await onceConnected(client)
await readPacket(client)
await writePacket(client, {
type: 'response',
version: 1,
controllerId: 'controller-a',
clientNonce: 'client-nonce',
protocol: AGENT_PROTOCOL_VERSION,
response: 'not-authenticated',
installationId: 'client-claimed'
})
await onceClosed(client)
await endpoint.close()
}
)
runOnUnix(
'relays a strict Main attach into real protocol status and capabilities',
async () => {
const root = privateTemporaryDirectory()
const controllers = new ControllerRegistry()
const events = new EventJournal(resolve(root, 'events.sqlite'))
const status = {
state: 'ready' as const,
installationId: endpointIdentity.installationId,
binaryDigest,
daemonBootId: endpointIdentity.daemonBootId,
agentVersion: '0.11.0',
protocol: AGENT_PROTOCOL_VERSION,
platform: 'linux' as const,
architecture: 'x64' as const,
supervisor: 'detached-on-demand' as const,
remoteUserIdentity: `uid:${process.getuid!()}`,
draining: false
}
const readMethods = Object.fromEntries(
[
'workspace/validate',
'workspace/open',
'workspace/resume',
'workspace/close',
'workspace/list',
'workspace/stat',
'workspace/readText',
'workspace/search',
'git/status',
'git/diff'
].map((method) => [method, () => null])
)
const protocol = new AgentProtocolServer({
controllers,
events,
status: () => status,
methods: readMethods
})
const secret = Buffer.alloc(32, 7)
const endpoint = new PrivateEndpoint({
socketPath: resolve(root, 'agent.sock'),
peerIdentity: currentPeerIdentity(),
challenge: new InstallationChallengeVerifier(secret),
controllers,
...endpointIdentity,
onAttach: ({ socket, controller }) => {
protocol.accept(socket, controller)
}
})
await endpoint.listen()
const input = new PassThrough()
const output = new PassThrough()
const relay = attachRelay({
socketPath: resolve(root, 'agent.sock'),
secret,
input,
output
})
writeStreamPacket(input, {
type: 'goodbuddy-agent-attach',
protocol: AGENT_PROTOCOL_VERSION,
goodBuddyVersion: '0.11.0',
controllerId: 'controller-main',
clientNonce: 'main-client-nonce',
hostRevision: 2,
hostKeyGeneration: 3
})
const welcome = attachWelcomeSchema.parse(
await readStreamPacket(output)
)
expect(welcome).toMatchObject({
type: 'goodbuddy-agent-welcome',
protocol: AGENT_PROTOCOL_VERSION,
installationId: endpointIdentity.installationId,
binaryDigest,
daemonBootId: endpointIdentity.daemonBootId,
generation: 1
})
input.write(
controlRequest(welcome, 'status-channel', 1, 'agent/status')
)
const statusResponse = await readFrame(output)
await readFrame(output)
expect(
daemonStatusSchema.parse(jsonRpcResult(statusResponse))
).toEqual(status)
input.write(
controlRequest(
welcome,
'capabilities-channel',
2,
'agent/capabilities'
)
)
const capabilitiesResponse = await readFrame(output)
await readFrame(output)
expect(
daemonCapabilitiesSchema.parse(
jsonRpcResult(capabilitiesResponse)
)
).toEqual({
generation: welcome.generation,
capabilities: [
{ name: 'agent/control', version: 1, critical: true },
{ name: 'workspace/read', version: 1, critical: true }
],
runtimes: []
})
input.end()
await relay
await endpoint.close()
events.close()
}
)
})
describe('attach relay preface validation', () => {
it('rejects unknown Main preface fields before opening an endpoint', async () => {
const input = new PassThrough()
const output = new PassThrough()
writeStreamPacket(input, {
type: 'goodbuddy-agent-attach',
protocol: AGENT_PROTOCOL_VERSION,
goodBuddyVersion: '0.11.0',
controllerId: 'controller-main',
clientNonce: 'main-client-nonce',
hostRevision: 2,
hostKeyGeneration: 3,
installationId: 'client-claimed'
})
await expect(
attachRelay({
socketPath: resolve('missing-agent.sock'),
secret: Buffer.alloc(32, 7),
input,
output
})
).rejects.toThrow()
})
it('rejects malformed and oversized Main prefaces deterministically', async () => {
const malformed = new PassThrough()
writeStreamPacket(malformed, {
type: 'goodbuddy-agent-attach',
protocol: { major: 1, minor: -1 }
})
await expect(
attachRelay({
socketPath: resolve('missing-agent.sock'),
secret: Buffer.alloc(32, 7),
input: malformed,
output: new PassThrough()
})
).rejects.toThrow()
const oversized = new PassThrough()
const length = Buffer.alloc(4)
length.writeUInt32BE(4097)
oversized.end(length)
await expect(
attachRelay({
socketPath: resolve('missing-agent.sock'),
secret: Buffer.alloc(32, 7),
input: oversized,
output: new PassThrough()
})
).rejects.toThrow('oversized')
})
})
function currentPeerIdentity(): {
getPeerIdentity(): Promise<PeerIdentity>
} {
return {
async getPeerIdentity() {
return { uid: process.getuid!() }
}
}
}
function privateTemporaryDirectory(): string {
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-endpoint-'))
chmodSync(path, 0o700)
temporaryPaths.push(path)
return path
}
async function onceConnected(socket: Socket): Promise<void> {
await new Promise<void>((resolveConnection, reject) => {
socket.once('connect', resolveConnection)
socket.once('error', reject)
})
}
async function onceClosed(socket: Socket): Promise<void> {
await new Promise<void>((resolveClose) => {
socket.once('close', resolveClose)
})
}
async function readPacket(socket: Socket): Promise<Record<string, unknown>> {
const length = await readExactly(socket, 4)
const contents = await readExactly(socket, length.readUInt32BE(0))
return JSON.parse(contents.toString('utf8')) as Record<string, unknown>
}
async function writePacket(
socket: Socket,
value: Record<string, unknown>
): Promise<void> {
const contents = Buffer.from(JSON.stringify(value), 'utf8')
const packet = Buffer.allocUnsafe(contents.byteLength + 4)
packet.writeUInt32BE(contents.byteLength, 0)
contents.copy(packet, 4)
await new Promise<void>((resolveWrite, reject) => {
socket.write(packet, (error) => {
if (error === null || error === undefined) {
resolveWrite()
} else {
reject(error)
}
})
})
}
async function readExactly(
socket: NodeJS.ReadableStream & {
read(size?: number): Buffer | string | null
},
byteLength: number
): Promise<Buffer> {
const chunks: Buffer[] = []
let remaining = byteLength
while (remaining > 0) {
const chunk = socket.read(remaining)
if (chunk !== null) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
chunks.push(bytes)
remaining -= bytes.byteLength
continue
}
await new Promise<void>((resolveRead, reject) => {
socket.once('readable', resolveRead)
socket.once('error', reject)
})
}
return Buffer.concat(chunks, byteLength)
}
function writeStreamPacket(
output: NodeJS.WritableStream,
value: Record<string, unknown>
): void {
const contents = Buffer.from(JSON.stringify(value), 'utf8')
const packet = Buffer.allocUnsafe(contents.byteLength + 4)
packet.writeUInt32BE(contents.byteLength, 0)
contents.copy(packet, 4)
output.write(packet)
}
async function readStreamPacket(
input: PassThrough
): Promise<Record<string, unknown>> {
const length = await readExactly(input, 4)
const contents = await readExactly(input, length.readUInt32BE(0))
return JSON.parse(contents.toString('utf8')) as Record<string, unknown>
}
function controlRequest(
welcome: {
connectionId: string
generation: number
protocol: { major: number; minor: number }
},
channelId: string,
id: number,
method: string
): Uint8Array {
const payload = Buffer.from(
JSON.stringify({ jsonrpc: '2.0', id, method, params: {} }),
'utf8'
)
return encodeAgentFrame({
header: {
protocolMajor: welcome.protocol.major,
protocolMinor: welcome.protocol.minor,
connectionId: welcome.connectionId,
generation: welcome.generation,
channelId,
channelEpoch: '1',
direction: 'main-to-agent',
sequence: '1',
kind: 'control',
payloadLength: payload.byteLength
},
payload
})
}
async function readFrame(input: PassThrough): Promise<AgentFrame> {
const header = await readExactly(input, 40)
const connectionLength = header.readUInt16BE(32)
const channelLength = header.readUInt16BE(34)
const payloadLength = header.readUInt32BE(36)
const remainder = await readExactly(
input,
connectionLength + channelLength + payloadLength
)
return decodeAgentFrame(Buffer.concat([header, remainder]))
}
function jsonRpcResult(frame: AgentFrame): unknown {
const response = JSON.parse(
Buffer.from(frame.payload).toString('utf8')
) as { result?: unknown }
return response.result
}
+635
View File
@@ -0,0 +1,635 @@
import {
chmodSync,
lstatSync
} from 'node:fs'
import { randomBytes } from 'node:crypto'
import {
createConnection,
createServer,
type Server,
type Socket
} from 'node:net'
import { dirname } from 'node:path'
import { z } from 'zod'
import {
agentIdentifierSchema,
attachPrefaceSchema,
attachWelcomeSchema,
protocolVersionSchema,
sha256DigestSchema,
type AttachPreface,
type AttachWelcome
} from '../shared/agent-protocol'
import { ControllerRegistry, type ControllerLease } from './controller-registry'
import {
InstallationChallengeVerifier
} from './installation-challenge'
import {
assertAbsoluteManagedPath,
ensurePrivateDirectory
} from './managed-paths'
import { AgentUnsupportedError } from './errors'
import { closeServer, settleWithin } from './async-utils'
const MAXIMUM_HANDSHAKE_BYTES = 4096
const DEFAULT_MAXIMUM_CONNECTIONS = 64
const ENDPOINT_CLOSE_TIMEOUT_MS = 2_000
const challengePacketSchema = z
.object({
type: z.literal('challenge'),
version: z.literal(1),
serverNonce: agentIdentifierSchema,
expiresAt: z.number().int().safe().nonnegative()
})
.strict()
const challengeResponsePacketSchema = z
.object({
type: z.literal('response'),
version: z.literal(1),
controllerId: agentIdentifierSchema,
clientNonce: agentIdentifierSchema,
protocol: protocolVersionSchema,
response: agentIdentifierSchema
})
.strict()
export type PeerIdentity = {
uid: number
pid?: number
}
export interface UnixPeerIdentityProvider {
getPeerIdentity(socket: Socket): Promise<PeerIdentity>
}
export type AuthenticatedAttach = {
socket: Socket
controller: ControllerLease
peer: PeerIdentity
}
export class PrivateEndpoint {
readonly #socketPath: string
readonly #peerIdentity: UnixPeerIdentityProvider
readonly #challenge: InstallationChallengeVerifier
readonly #controllers: ControllerRegistry
readonly #onAttach: (attach: AuthenticatedAttach) => void | Promise<void>
readonly #installationId: string
readonly #binaryDigest: string
readonly #daemonBootId: string
readonly #protocol: AttachPreface['protocol']
readonly #maximumConnections: number
readonly #sockets = new Set<Socket>()
#server?: Server
#closing = false
#closePromise?: Promise<void>
constructor(options: {
socketPath: string
peerIdentity: UnixPeerIdentityProvider
challenge: InstallationChallengeVerifier
controllers: ControllerRegistry
onAttach: (attach: AuthenticatedAttach) => void | Promise<void>
installationId: string
binaryDigest: string
daemonBootId: string
protocol: AttachPreface['protocol']
maximumConnections?: number
}) {
this.#socketPath = assertAbsoluteManagedPath(options.socketPath)
this.#peerIdentity = options.peerIdentity
this.#challenge = options.challenge
this.#controllers = options.controllers
this.#onAttach = options.onAttach
this.#installationId = agentIdentifierSchema.parse(options.installationId)
this.#binaryDigest = sha256DigestSchema.parse(options.binaryDigest)
this.#daemonBootId = agentIdentifierSchema.parse(options.daemonBootId)
this.#protocol = protocolVersionSchema.parse(options.protocol)
this.#maximumConnections =
options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS
if (
!Number.isSafeInteger(this.#maximumConnections) ||
this.#maximumConnections < 1 ||
this.#maximumConnections > 1024
) {
throw new RangeError('Invalid private endpoint connection limit')
}
}
async listen(): Promise<void> {
if (process.platform === 'win32') {
throw new AgentUnsupportedError(
'Unix private endpoints are unavailable on Windows',
'platform-incompatible'
)
}
ensurePrivateDirectory(dirname(this.#socketPath), { create: false })
if (this.#server !== undefined || this.#closing) {
throw new Error('Private endpoint is already active')
}
this.#server = createServer((socket) => {
if (
this.#closing ||
this.#sockets.size >= this.#maximumConnections
) {
socket.destroy()
return
}
this.#sockets.add(socket)
socket.once('close', () => {
this.#sockets.delete(socket)
})
void this.#authenticate(socket).catch(() => socket.destroy())
})
await new Promise<void>((resolve, reject) => {
const server = this.#server
if (server === undefined) {
reject(new Error('Endpoint server was not created'))
return
}
server.once('error', reject)
server.listen(this.#socketPath, () => {
server.off('error', reject)
resolve()
})
})
chmodSync(this.#socketPath, 0o600)
const stat = lstatSync(this.#socketPath, { bigint: true })
if (!stat.isSocket() || (stat.mode & 0o177n) !== 0n) {
await this.close()
throw new Error('Private endpoint mode verification failed')
}
const uid = process.getuid?.()
if (uid !== undefined && stat.uid !== BigInt(uid)) {
await this.close()
throw new Error('Private endpoint owner verification failed')
}
}
async close(): Promise<void> {
const activeClose = this.#closePromise
if (activeClose !== undefined) {
await activeClose
return
}
const closePromise = this.#closeEndpoint()
this.#closePromise = closePromise
try {
await closePromise
} finally {
if (this.#closePromise === closePromise) {
this.#closePromise = undefined
}
}
}
async #closeEndpoint(): Promise<void> {
const server = this.#server
this.#server = undefined
this.#closing = true
try {
const socketClosures = [...this.#sockets].map(waitForSocketClose)
for (const socket of this.#sockets) {
socket.destroy()
}
let serverCloseError: unknown
const serverClosure =
server === undefined
? Promise.resolve()
: closeServer(server).catch((error: unknown) => {
serverCloseError = error
})
await settleWithin(
Promise.allSettled([...socketClosures, serverClosure]),
ENDPOINT_CLOSE_TIMEOUT_MS
)
if (serverCloseError !== undefined) {
throw serverCloseError
}
} finally {
this.#sockets.clear()
this.#closing = false
}
}
async #authenticate(socket: Socket): Promise<void> {
socket.pause()
socket.setTimeout(30_000, () => socket.destroy())
const peer = await this.#peerIdentity.getPeerIdentity(socket)
this.#assertOpen(socket)
const expectedUid = process.getuid?.()
if (expectedUid === undefined || peer.uid !== expectedUid) {
throw new Error('Attach peer is not the current user')
}
const challenge = this.#challenge.issue()
this.#assertOpen(socket)
await writePacket(socket, {
type: 'challenge',
version: 1,
...challenge
})
const response = challengeResponsePacketSchema.parse(
await readPacket(socket)
)
this.#assertOpen(socket)
const controllerId = response.controllerId
if (
!this.#challenge.verify({
serverNonce: challenge.serverNonce,
clientNonce: response.clientNonce,
controllerId,
response: response.response
})
) {
throw new Error('Installation challenge failed')
}
if (
response.protocol.major !== this.#protocol.major ||
response.protocol.minor < this.#protocol.minor
) {
throw new Error('Attach protocol version is incompatible')
}
this.#assertOpen(socket)
const controller = this.#controllers.attach(controllerId)
this.#assertOpen(socket)
await writePacket(socket, attachWelcomeSchema.parse({
type: 'goodbuddy-agent-welcome',
protocol: this.#protocol,
connectionId: controller.connectionId,
generation: controller.generation,
installationId: this.#installationId,
binaryDigest: this.#binaryDigest,
daemonBootId: this.#daemonBootId,
serverNonce: challenge.serverNonce
}))
this.#assertOpen(socket)
socket.setTimeout(0)
await this.#onAttach({ socket, controller, peer })
this.#assertOpen(socket)
socket.resume()
}
#assertOpen(socket: Socket): void {
if (
this.#closing ||
this.#server === undefined ||
!this.#sockets.has(socket) ||
socket.destroyed
) {
throw new Error('Private endpoint is closing')
}
}
}
export async function attachRelay(options: {
socketPath: string
secret: Uint8Array
input: NodeJS.ReadableStream
output: NodeJS.WritableStream
ensureEndpoint?: (
reason: 'absent' | 'stale'
) => void | Promise<void>
onWelcome?: (welcome: AttachWelcome) => void
}): Promise<void> {
const preface = await readRelayPreface(options.input)
if (process.platform === 'win32') {
throw new AgentUnsupportedError(
'Unix attach is unavailable on Windows',
'platform-incompatible'
)
}
const socketPath = assertAbsoluteManagedPath(options.socketPath)
let socket = createConnection(socketPath)
let welcome: AttachWelcome
try {
try {
await waitForConnection(socket)
} catch (error) {
socket.destroy()
const reason = endpointAbsenceReason(error)
if (reason === undefined || options.ensureEndpoint === undefined) {
throw error
}
await options.ensureEndpoint(reason)
socket = createConnection(socketPath)
await waitForConnection(socket)
}
socket.pause()
const challengeResult = challengePacketSchema.safeParse(
await readPacket(socket)
)
if (!challengeResult.success) {
throw new Error('Daemon returned an invalid challenge')
}
const challenge = challengeResult.data
const verifier = new InstallationChallengeVerifier(options.secret)
await writePacket(socket, {
type: 'response',
version: 1,
controllerId: preface.controllerId,
clientNonce: preface.clientNonce,
protocol: preface.protocol,
response: verifier.createResponse({
serverNonce: challenge.serverNonce,
clientNonce: preface.clientNonce,
controllerId: preface.controllerId
})
})
const welcomeResult = attachWelcomeSchema.safeParse(
await readPacket(socket)
)
if (!welcomeResult.success) {
throw new Error('Daemon rejected attach authentication')
}
welcome = welcomeResult.data
if (
welcome.protocol.major !== preface.protocol.major ||
welcome.protocol.minor > preface.protocol.minor ||
welcome.serverNonce !== challenge.serverNonce
) {
throw new Error('Daemon returned an incompatible attach welcome')
}
} catch (error) {
socket.destroy()
throw error
}
try {
await writeStreamPacket(options.output, welcome)
options.onWelcome?.(welcome)
} catch (error) {
socket.destroy()
throw error
}
socket.resume()
options.input.pipe(socket)
socket.pipe(options.output)
await new Promise<void>((resolve, reject) => {
socket.once('close', resolve)
socket.once('error', reject)
options.input.once('error', reject)
})
}
export async function probeAuthenticatedEndpoint(options: {
socketPath: string
secret: Buffer
installationId: string
binaryDigest: string
protocol: AttachPreface['protocol']
}): Promise<AttachWelcome> {
if (process.platform === 'win32') {
throw new AgentUnsupportedError(
'Unix attach is unavailable on Windows',
'platform-incompatible'
)
}
const socket = createConnection(
assertAbsoluteManagedPath(options.socketPath)
)
try {
await waitForConnection(socket)
socket.pause()
const challenge = challengePacketSchema.parse(await readPacket(socket))
const verifier = new InstallationChallengeVerifier(options.secret)
const controllerId = 'lifecycle-health'
const clientNonce = randomBytes(24).toString('base64url')
await writePacket(socket, {
type: 'response',
version: 1,
controllerId,
clientNonce,
protocol: options.protocol,
response: verifier.createResponse({
serverNonce: challenge.serverNonce,
clientNonce,
controllerId
})
})
const welcome = attachWelcomeSchema.parse(await readPacket(socket))
if (
welcome.serverNonce !== challenge.serverNonce ||
welcome.installationId !== options.installationId ||
welcome.binaryDigest !== options.binaryDigest ||
welcome.protocol.major !== options.protocol.major ||
welcome.protocol.minor > options.protocol.minor
) {
throw new Error(
'Detached Agent endpoint identity does not match its installation'
)
}
return welcome
} finally {
socket.destroy()
}
}
function waitForConnection(socket: Socket): Promise<void> {
return new Promise<void>((resolve, reject) => {
const connected = (): void => {
socket.off('error', failed)
resolve()
}
const failed = (error: Error): void => {
socket.off('connect', connected)
reject(error)
}
socket.once('connect', connected)
socket.once('error', failed)
})
}
function endpointAbsenceReason(
error: unknown
): 'absent' | 'stale' | undefined {
if (!isNodeError(error)) {
return undefined
}
if (error.code === 'ENOENT') {
return 'absent'
}
if (error.code === 'ECONNREFUSED') {
return 'stale'
}
return undefined
}
async function readRelayPreface(
input: NodeJS.ReadableStream
): Promise<Pick<
AttachPreface,
'protocol' | 'controllerId' | 'clientNonce'
>> {
const parsed = attachPrefaceSchema.parse(await readStreamPacket(input))
return {
protocol: parsed.protocol,
controllerId: parsed.controllerId,
clientNonce: parsed.clientNonce
}
}
async function readStreamPacket(
input: NodeJS.ReadableStream
): Promise<Record<string, unknown>> {
const stream = input as NodeJS.ReadableStream & {
read(size?: number): Buffer | null
}
const lengthBytes = await readExactlyFromStream(stream, 4)
const length = lengthBytes.readUInt32BE(0)
if (length < 2 || length > MAXIMUM_HANDSHAKE_BYTES) {
throw new Error('Attach relay preface is oversized')
}
const contents = await readExactlyFromStream(stream, length)
const value: unknown = JSON.parse(contents.toString('utf8'))
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Attach relay preface must be an object')
}
return value as Record<string, unknown>
}
async function writeStreamPacket(
output: NodeJS.WritableStream,
value: Record<string, unknown>
): Promise<void> {
const contents = Buffer.from(JSON.stringify(value), 'utf8')
const packet = Buffer.allocUnsafe(contents.byteLength + 4)
packet.writeUInt32BE(contents.byteLength, 0)
contents.copy(packet, 4)
await new Promise<void>((resolve, reject) => {
output.write(packet, (error?: Error | null) => {
if (error === null || error === undefined) {
resolve()
} else {
reject(error)
}
})
})
}
async function readPacket(socket: Socket): Promise<Record<string, unknown>> {
const lengthBytes = await readExactly(socket, 4)
const length = lengthBytes.readUInt32BE(0)
if (length < 2 || length > MAXIMUM_HANDSHAKE_BYTES) {
throw new Error('Attach handshake packet is oversized')
}
const contents = await readExactly(socket, length)
const value: unknown = JSON.parse(contents.toString('utf8'))
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Attach handshake packet must be an object')
}
return value as Record<string, unknown>
}
async function writePacket(
socket: Socket,
value: Record<string, unknown>
): Promise<void> {
const contents = Buffer.from(JSON.stringify(value), 'utf8')
if (contents.byteLength > MAXIMUM_HANDSHAKE_BYTES) {
throw new Error('Attach handshake packet is oversized')
}
const packet = Buffer.allocUnsafe(contents.byteLength + 4)
packet.writeUInt32BE(contents.byteLength, 0)
contents.copy(packet, 4)
await new Promise<void>((resolve, reject) => {
socket.write(packet, (error) => {
if (error === null || error === undefined) {
resolve()
} else {
reject(error)
}
})
})
}
async function readExactly(socket: Socket, byteLength: number): Promise<Buffer> {
const chunks: Buffer[] = []
let remaining = byteLength
while (remaining > 0) {
const chunk = socket.read(remaining) as Buffer | null
if (chunk !== null) {
chunks.push(chunk)
remaining -= chunk.byteLength
continue
}
await new Promise<void>((resolve, reject) => {
const onReadable = (): void => {
cleanup()
resolve()
}
const onEnd = (): void => {
cleanup()
reject(new Error('Attach socket closed during handshake'))
}
const onError = (error: Error): void => {
cleanup()
reject(error)
}
const cleanup = (): void => {
socket.off('readable', onReadable)
socket.off('end', onEnd)
socket.off('error', onError)
}
socket.once('readable', onReadable)
socket.once('end', onEnd)
socket.once('error', onError)
})
}
return Buffer.concat(chunks, byteLength)
}
async function readExactlyFromStream(
stream: NodeJS.ReadableStream & {
read(size?: number): Buffer | null
},
byteLength: number
): Promise<Buffer> {
const chunks: Buffer[] = []
let remaining = byteLength
while (remaining > 0) {
const chunk = stream.read(remaining)
if (chunk !== null) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
chunks.push(bytes)
remaining -= bytes.byteLength
continue
}
await new Promise<void>((resolve, reject) => {
const onReadable = (): void => {
cleanup()
resolve()
}
const onEnd = (): void => {
cleanup()
reject(new Error('Attach relay input closed during preface'))
}
const onError = (error: Error): void => {
cleanup()
reject(error)
}
const cleanup = (): void => {
stream.removeListener('readable', onReadable)
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
}
stream.once('readable', onReadable)
stream.once('end', onEnd)
stream.once('error', onError)
})
}
return Buffer.concat(chunks, byteLength)
}
function waitForSocketClose(socket: Socket): Promise<void> {
if (socket.closed) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
socket.once('close', resolve)
})
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error
}
+970
View File
@@ -0,0 +1,970 @@
import { EventEmitter } from 'node:events'
import { chmodSync, mkdtempSync, rmSync } from 'node:fs'
import type { Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
AGENT_PROTOCOL_LIMITS,
AGENT_PROTOCOL_VERSION,
daemonCapabilitiesSchema,
daemonStatusSchema
} from '../shared/agent-protocol'
import {
decodeAgentFrame,
encodeAgentFrame,
type AgentFrame
} from '../shared/agent-protocol/frame'
import { ControllerRegistry, type ControllerLease } from './controller-registry'
import { EventJournal } from './event-journal'
import { AgentProtocolServer } from './protocol-server'
const temporaryPaths: string[] = []
const closeMethod = 'channel/close'
const daemonStatus = {
state: 'ready' as const,
installationId: 'installation-test',
binaryDigest: `sha256:${'a'.repeat(64)}`,
daemonBootId: 'boot-test',
agentVersion: '0.11.0',
protocol: AGENT_PROTOCOL_VERSION,
platform: 'linux' as const,
architecture: 'x64' as const,
supervisor: 'detached-on-demand' as const,
remoteUserIdentity: 'uid:1000',
draining: false
}
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('AgentProtocolServer connection bounds', () => {
it('accepts a frame fragmented into single-byte socket chunks', async () => {
const harness = createHarness()
const frame = controlRequest(
harness.controller,
'fragmented',
'1',
1,
'agent/status'
)
for (const byte of frame) {
harness.socket.receive(Uint8Array.of(byte))
}
await waitFor(() => harness.socket.writes.length === 2)
expect(
jsonPayload(decodeAgentFrame(harness.socket.writes[0]!))
).toMatchObject({ result: { state: 'ready' } })
harness.close()
})
it('reports a bounded category when binary dispatch fails', async () => {
const harness = createHarness({
onAcpFrame: async () => {
throw Object.assign(new Error('sensitive detail'), {
code: 'process'
})
}
})
harness.socket.receive(
binaryFrame(
harness.controller,
'binding-failed',
'1',
1,
'acp',
'prompt'
)
)
await waitFor(() => harness.socket.destroyCalled)
expect(harness.failures).toEqual([
{
connectionId: harness.controller.connectionId,
category: 'dispatch/process'
}
])
harness.close()
})
it('pauses at the item cap and dispatches frames in FIFO order', async () => {
const gate = deferred<void>()
const calls: string[] = []
const harness = createHarness({
methods: {
'test/wait': async (_params, context) => {
calls.push(context.channelId)
await gate.promise
return { done: true }
}
},
incomingQueueLimits: {
maximumItems: 2,
maximumBytes: 4096,
maximumBufferedBytes: 4096
}
})
const frames = ['one', 'two', 'three'].map((channelId) =>
controlRequest(harness.controller, channelId, '1', 1, 'test/wait')
)
harness.socket.receive(Buffer.concat(frames.map(Buffer.from)))
expect(harness.socket.paused).toBe(true)
expect(calls).toEqual(['one'])
expect(harness.socket.writes).toHaveLength(0)
gate.resolve()
await waitFor(() => harness.socket.writes.length === 6)
expect(calls).toEqual(['one', 'two', 'three'])
expect(harness.socket.maximumConcurrentWrites).toBe(1)
expect(harness.socket.paused).toBe(false)
harness.close()
})
it('writes bounded blob messages directly and honors channel close', async () => {
const harness = createHarness({ authorizeBlobFrame: () => true })
const maximum = Buffer.alloc(
AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes
)
await harness.protocol.sendBlobFrame(
outgoingBinaryFrame(
harness.controller,
'blob-direct',
'1',
1,
'blob',
maximum
)
)
await harness.protocol.sendBlobFrame(
outgoingBinaryFrame(
harness.controller,
'blob-direct',
'1',
2,
'blob',
Buffer.of(1)
)
)
expect(harness.socket.writes).toHaveLength(2)
expect(decodeAgentFrame(harness.socket.writes[0]!).payload).toHaveLength(
maximum.byteLength
)
harness.socket.receive(
controlNotification(
harness.controller,
'blob-direct',
'1',
1,
closeMethod,
{ channelId: 'blob-direct', channelEpoch: '1' }
)
)
await waitFor(() => !harness.socket.paused)
await expect(
harness.protocol.sendBlobFrame(
outgoingBinaryFrame(
harness.controller,
'blob-direct',
'1',
3,
'blob',
Buffer.of(2)
)
)
).rejects.toThrow(/cannot be reused/iu)
expect(harness.socket.destroyCalled).toBe(false)
harness.close()
})
it('fails closed when a peer keeps flooding past the paused byte bound', async () => {
const gate = deferred<void>()
const harness = createHarness({
methods: {
'test/wait': async () => {
await gate.promise
return null
}
},
incomingQueueLimits: {
maximumItems: 1,
maximumBytes: 512,
maximumBufferedBytes: 512
}
})
harness.socket.receive(
controlRequest(harness.controller, 'one', '1', 1, 'test/wait')
)
expect(harness.socket.paused).toBe(true)
harness.socket.receive(Buffer.alloc(1024))
await waitFor(() => harness.socket.destroyCalled)
expect(harness.socket.writes).toHaveLength(0)
gate.resolve()
harness.close()
})
it('retires request channels after a response and rejects ID reuse', async () => {
const harness = createHarness()
harness.socket.receive(
controlRequest(
harness.controller,
'request-1',
'1',
1,
'agent/status'
)
)
await waitFor(() => harness.socket.writes.length === 2)
const response = decodeAgentFrame(harness.socket.writes[0]!)
const close = decodeAgentFrame(harness.socket.writes[1]!)
expect(jsonPayload(response)).toMatchObject({
jsonrpc: '2.0',
id: 1,
result: daemonStatus
})
expect(jsonPayload(close)).toEqual({
jsonrpc: '2.0',
method: closeMethod,
params: { channelId: 'request-1', channelEpoch: '1' }
})
harness.socket.receive(
controlRequest(
harness.controller,
'request-1',
'2',
1,
'agent/status'
)
)
await waitFor(() => harness.socket.destroyCalled)
harness.close()
})
it('accepts an authenticated explicit close and prevents queued follow-up work', async () => {
const calls: string[] = []
const harness = createHarness({
methods: {
'test/record': () => {
calls.push('called')
return null
}
}
})
const close = controlNotification(
harness.controller,
'stream-1',
'7',
1,
closeMethod,
{ channelId: 'stream-1', channelEpoch: '7' }
)
const followUp = controlNotification(
harness.controller,
'stream-1',
'7',
2,
'test/record',
{}
)
harness.socket.receive(Buffer.concat([Buffer.from(close), Buffer.from(followUp)]))
await waitFor(() => harness.socket.destroyCalled)
expect(calls).toEqual([])
harness.close()
})
it('fails closed on malformed envelopes and malformed close controls', async () => {
const malformedHarness = createHarness()
const malformed = Buffer.from(
controlRequest(
malformedHarness.controller,
'bad-frame',
'1',
1,
'agent/status'
)
)
malformed[0] = 0
malformedHarness.socket.receive(malformed)
await waitFor(() => malformedHarness.socket.destroyCalled)
expect(malformedHarness.socket.writes).toHaveLength(0)
malformedHarness.close()
const closeHarness = createHarness()
closeHarness.socket.receive(
controlNotification(
closeHarness.controller,
'bad-close',
'1',
1,
closeMethod,
{ channelId: 'another-channel', channelEpoch: '1' }
)
)
await waitFor(() => closeHarness.socket.destroyCalled)
expect(closeHarness.socket.writes).toHaveLength(0)
closeHarness.close()
})
it('preserves stable typed service error codes', async () => {
const harness = createHarness({
methods: {
'test/typed-error': () => {
throw Object.assign(new Error('Read-only service'), {
code: 'read-only'
})
}
}
})
harness.socket.receive(
controlRequest(
harness.controller,
'typed-error',
'1',
1,
'test/typed-error'
)
)
await waitFor(() => harness.socket.writes.length === 2)
expect(jsonPayload(decodeAgentFrame(harness.socket.writes[0]!))).toEqual({
jsonrpc: '2.0',
id: 1,
error: {
code: -32000,
message: 'Read-only service',
data: { code: 'read-only' }
}
})
harness.close()
})
it('returns strict status, doctor, and exact implemented capabilities', async () => {
const methods = Object.fromEntries(
[
'workspace/validate',
'workspace/open',
'workspace/resume',
'workspace/close',
'workspace/list',
'workspace/stat',
'workspace/readText',
'workspace/search',
'git/status',
'git/diff'
].map((method) => [method, () => null])
)
const harness = createHarness({ methods })
for (const [index, method] of [
'agent/status',
'agent/doctor',
'agent/capabilities'
].entries()) {
harness.socket.receive(
controlRequest(
harness.controller,
`strict-${index}`,
'1',
index + 1,
method
)
)
await waitFor(() => harness.socket.writes.length === (index + 1) * 2)
const response = decodeAgentFrame(
harness.socket.writes[index * 2]!
)
const result = (
jsonPayload(response) as { result: unknown }
).result
if (method === 'agent/capabilities') {
expect(daemonCapabilitiesSchema.parse(result)).toEqual({
generation: harness.controller.capabilityGeneration,
capabilities: [
{ name: 'agent/control', version: 1, critical: true },
{ name: 'workspace/read', version: 1, critical: true }
],
runtimes: []
})
} else {
expect(daemonStatusSchema.parse(result)).toEqual(daemonStatus)
}
}
harness.close()
})
it('does not advertise workspace writes, runtime, ACP, or partial reads', async () => {
const harness = createHarness({
methods: {
'workspace/readText': () => null,
'workspace/writeTextAtomic': () => null,
'runtime/openAcpChannel': () => null,
'runtime/closeAcpChannel': () => null
}
})
harness.socket.receive(
controlRequest(
harness.controller,
'capabilities',
'1',
1,
'agent/capabilities'
)
)
await waitFor(() => harness.socket.writes.length === 2)
const result = (
jsonPayload(
decodeAgentFrame(harness.socket.writes[0]!)
) as { result: unknown }
).result
expect(daemonCapabilitiesSchema.parse(result)).toEqual({
generation: harness.controller.capabilityGeneration,
capabilities: [
{ name: 'agent/control', version: 1, critical: true }
],
runtimes: []
})
harness.close()
})
it('advertises only a verified Runtime with ACP control and data handlers', async () => {
const runtime = {
runtimeId: 'opencode',
version: '1.18.9',
bundleDigest: `sha256:${'b'.repeat(64)}`,
acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}`,
sessionLoad: false,
sessionResume: false
}
const harness = createHarness({
methods: {
'runtime/openAcpChannel': () => null,
'runtime/closeAcpChannel': () => null,
'runtime/resumeAcpChannel': () => null,
'runtime/replayAcpChannel': () => null,
'runtime/preparePrompt': () => null,
'runtime/completePrompt': () => null,
'runtime/getAcpCursors': () => null,
'runtime/escalateCancellation': () => null,
'runtime/reconcilePrompt': () => null
},
onAcpFrame: async () => {},
runtimes: async () => [runtime]
})
harness.socket.receive(
controlRequest(
harness.controller,
'runtime-capabilities',
'1',
1,
'agent/capabilities'
)
)
await waitFor(() => harness.socket.writes.length === 2)
const result = (
jsonPayload(
decodeAgentFrame(harness.socket.writes[0]!)
) as { result: unknown }
).result
expect(daemonCapabilitiesSchema.parse(result)).toEqual({
generation: harness.controller.capabilityGeneration,
capabilities: [
{ name: 'agent/control', version: 1, critical: true },
{ name: 'runtime/acp', version: 3, critical: true }
],
runtimes: [runtime]
})
harness.close()
})
it('advertises the critical model bridge only with blob composition', async () => {
const runtime = {
runtimeId: 'opencode',
version: '1.18.9',
bundleDigest: `sha256:${'b'.repeat(64)}`,
acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}`,
sessionLoad: false,
sessionResume: false
}
const methods = Object.fromEntries(
[
'runtime/openAcpChannel',
'runtime/closeAcpChannel',
'runtime/resumeAcpChannel',
'runtime/replayAcpChannel',
'runtime/preparePrompt',
'runtime/completePrompt',
'runtime/getAcpCursors',
'runtime/escalateCancellation',
'runtime/reconcilePrompt'
].map((method) => [method, () => null])
)
const harness = createHarness({
methods,
onAcpFrame: async () => {},
onBlobFrame: async () => {},
authorizeBlobFrame: () => true,
runtimes: async () => [runtime]
})
harness.socket.receive(
controlRequest(
harness.controller,
'bridge-capabilities',
'1',
1,
'agent/capabilities'
)
)
await waitFor(() => harness.socket.writes.length === 2)
const result = (
jsonPayload(
decodeAgentFrame(harness.socket.writes[0]!)
) as { result: unknown }
).result
expect(daemonCapabilitiesSchema.parse(result)).toMatchObject({
capabilities: [
{ name: 'agent/control', version: 1, critical: true },
{ name: 'runtime/acp', version: 3, critical: true },
{
name: 'runtime/model-bridge',
version: 1,
critical: true
}
]
})
harness.close()
})
it('locks channel kinds and rejects ACP/blob cross-injection', async () => {
let handled = false
const harness = createHarness({
onBlobFrame: async () => {},
methods: {
'test/notify': async () => {
handled = true
}
}
})
harness.socket.receive(
controlNotification(
harness.controller,
'locked-channel',
'5',
1,
'test/notify',
{}
)
)
await waitFor(() => handled)
harness.socket.receive(
binaryFrame(
harness.controller,
'locked-channel',
'5',
2,
'blob',
'injected'
)
)
await waitFor(() => harness.socket.destroyCalled)
harness.close()
})
it('rejects Agent-first blob output without exact prompt authority', async () => {
const harness = createHarness({
onBlobFrame: async () => {}
})
const payload = Buffer.from('request')
await expect(
harness.protocol.sendBlobFrame({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: harness.controller.connectionId,
generation: harness.controller.generation,
channelId: 'unknown-blob-output',
channelEpoch: '3',
direction: 'agent-to-main',
sequence: '1',
kind: 'blob',
payloadLength: payload.byteLength
},
payload
})
).rejects.toThrow(/active prompt authority/iu)
expect(harness.socket.writes).toHaveLength(0)
harness.close()
})
it('routes bounded blob frames without adding them to ACP journals', async () => {
const received: AgentFrame[] = []
const harness = createHarness({
onBlobFrame: async (frame) => {
received.push(frame)
}
})
harness.socket.receive(
binaryFrame(
harness.controller,
'blob-channel',
'11',
1,
'blob',
'chunk'
)
)
await waitFor(() => received.length === 1)
expect(received).toHaveLength(1)
expect(harness.socket.writes).toHaveLength(0)
expect(harness.socket.destroyCalled).toBe(false)
harness.close()
})
it('rejects non-empty status and capability params', async () => {
const harness = createHarness()
harness.socket.receive(
controlFrame(harness.controller, 'bad-params', '1', 1, {
jsonrpc: '2.0',
id: 1,
method: 'agent/status',
params: { unexpected: true }
})
)
await waitFor(() => harness.socket.writes.length === 2)
expect(
jsonPayload(decodeAgentFrame(harness.socket.writes[0]!))
).toMatchObject({
error: { code: -32602, message: 'Method params must be an empty object' }
})
harness.close()
})
it('resumes with the current connection and capability generations', async () => {
const harness = createHarness({ reconnect: true })
const previous = harness.previousController!
harness.socket.receive(
controlFrame(harness.controller, 'resume', '1', 1, {
jsonrpc: '2.0',
id: 1,
method: 'controller/resume',
params: {
previousGeneration: previous.generation,
previousConnectionId: previous.connectionId,
daemonBootId: daemonStatus.daemonBootId,
capabilityGeneration: previous.capabilityGeneration
}
})
)
await waitFor(() => harness.socket.writes.length === 2)
const result = (
jsonPayload(
decodeAgentFrame(harness.socket.writes[0]!)
) as { result: Record<string, unknown> }
).result
expect(result).toMatchObject({
resumed: true,
generation: harness.controller.generation,
daemonBootId: daemonStatus.daemonBootId,
capabilityGeneration:
harness.controller.capabilityGeneration
})
expect(result.leaseDeadlineAt).toEqual(expect.any(String))
harness.close()
})
it.each([
['previous connection', { previousConnectionId: 'connection-wrong' }],
['capability generation', { capabilityGeneration: 2 }],
['daemon boot', { daemonBootId: 'boot-wrong' }]
])('rejects takeover with mismatched %s', async (_label, override) => {
const harness = createHarness({ reconnect: true })
const previous = harness.previousController!
harness.socket.receive(
controlFrame(harness.controller, 'resume-mismatch', '1', 1, {
jsonrpc: '2.0',
id: 1,
method: 'controller/resume',
params: {
previousGeneration: previous.generation,
previousConnectionId: previous.connectionId,
daemonBootId: daemonStatus.daemonBootId,
capabilityGeneration: previous.capabilityGeneration,
...override
}
})
)
await waitFor(() => harness.socket.writes.length === 2)
expect(
(
jsonPayload(
decodeAgentFrame(harness.socket.writes[0]!)
) as { result: { resumed: boolean } }
).result.resumed
).toBe(false)
harness.close()
})
})
class FakeSocket extends EventEmitter {
writes: Buffer[] = []
paused = false
destroyCalled = false
maximumConcurrentWrites = 0
#concurrentWrites = 0
pause(): this {
this.paused = true
return this
}
resume(): this {
this.paused = false
return this
}
write(
data: Uint8Array,
callback?: (error?: Error | null) => void
): boolean {
this.#concurrentWrites += 1
this.maximumConcurrentWrites = Math.max(
this.maximumConcurrentWrites,
this.#concurrentWrites
)
this.writes.push(Buffer.from(data))
queueMicrotask(() => {
this.#concurrentWrites -= 1
callback?.()
})
return true
}
destroy(): this {
if (!this.destroyCalled) {
this.destroyCalled = true
queueMicrotask(() => this.emit('close'))
}
return this
}
receive(data: Uint8Array): void {
this.emit('data', Buffer.from(data))
}
}
type Harness = {
socket: FakeSocket
protocol: AgentProtocolServer
controller: ControllerLease
previousController?: ControllerLease
failures: Array<{
connectionId: string
category: string
}>
close: () => void
}
function createHarness(
options: {
methods?: ConstructorParameters<typeof AgentProtocolServer>[0]['methods']
incomingQueueLimits?: ConstructorParameters<
typeof AgentProtocolServer
>[0]['incomingQueueLimits']
runtimes?: ConstructorParameters<
typeof AgentProtocolServer
>[0]['runtimes']
onAcpFrame?: ConstructorParameters<
typeof AgentProtocolServer
>[0]['onAcpFrame']
onBlobFrame?: ConstructorParameters<
typeof AgentProtocolServer
>[0]['onBlobFrame']
authorizeBlobFrame?: ConstructorParameters<
typeof AgentProtocolServer
>[0]['authorizeBlobFrame']
reconnect?: boolean
} = {}
): Harness {
const root = temporaryDirectory()
const controllers = new ControllerRegistry()
const previousController = options.reconnect
? controllers.attach('controller-test')
: undefined
if (previousController !== undefined) {
controllers.disconnect(
previousController.controllerId,
previousController.generation
)
}
const controller = controllers.attach('controller-test')
const events = new EventJournal(resolve(root, 'events.sqlite'))
const failures: Harness['failures'] = []
const protocol = new AgentProtocolServer({
controllers,
events,
status: () => daemonStatus,
runtimes: options.runtimes,
methods: options.methods,
onAcpFrame: options.onAcpFrame,
onBlobFrame: options.onBlobFrame,
authorizeBlobFrame: options.authorizeBlobFrame,
onProtocolFailure: (failure) => {
failures.push(failure)
},
incomingQueueLimits: options.incomingQueueLimits
})
const socket = new FakeSocket()
protocol.accept(socket as unknown as Socket, controller)
return {
socket,
protocol,
controller,
failures,
...(previousController === undefined ? {} : { previousController }),
close: () => {
socket.destroy()
events.close()
}
}
}
function binaryFrame(
controller: ControllerLease,
channelId: string,
channelEpoch: string,
sequence: number,
kind: 'acp' | 'blob',
value: string
): Uint8Array {
const payload = Buffer.from(value)
return encodeAgentFrame({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: controller.connectionId,
generation: controller.generation,
channelId,
channelEpoch,
direction: 'main-to-agent',
sequence: String(sequence),
kind,
payloadLength: payload.byteLength
},
payload
})
}
function outgoingBinaryFrame(
controller: ControllerLease,
channelId: string,
channelEpoch: string,
sequence: number,
kind: 'acp' | 'blob',
payload: Uint8Array
): AgentFrame {
return {
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: controller.connectionId,
generation: controller.generation,
channelId,
channelEpoch,
direction: 'agent-to-main',
sequence: String(sequence),
kind,
payloadLength: payload.byteLength
},
payload
}
}
function controlRequest(
controller: ControllerLease,
channelId: string,
channelEpoch: string,
id: number,
method: string
): Uint8Array {
return controlFrame(controller, channelId, channelEpoch, 1, {
jsonrpc: '2.0',
id,
method,
params: {}
})
}
function controlNotification(
controller: ControllerLease,
channelId: string,
channelEpoch: string,
sequence: number,
method: string,
params: Record<string, unknown>
): Uint8Array {
return controlFrame(controller, channelId, channelEpoch, sequence, {
jsonrpc: '2.0',
method,
params
})
}
function controlFrame(
controller: ControllerLease,
channelId: string,
channelEpoch: string,
sequence: number,
value: Record<string, unknown>
): Uint8Array {
const payload = Buffer.from(JSON.stringify(value), 'utf8')
return encodeAgentFrame({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: controller.connectionId,
generation: controller.generation,
channelId,
channelEpoch,
direction: 'main-to-agent',
sequence: sequence.toString(),
kind: 'control',
payloadLength: payload.byteLength
},
payload
})
}
function jsonPayload(frame: AgentFrame): unknown {
return JSON.parse(Buffer.from(frame.payload).toString('utf8'))
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T | PromiseLike<T>) => void
} {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
async function waitFor(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 2_000
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error('Timed out waiting for protocol state')
}
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 1))
}
}
function temporaryDirectory(): string {
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-protocol-'))
temporaryPaths.push(path)
if (process.platform !== 'win32') {
chmodSync(path, 0o700)
}
return path
}
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import {
AgentChannelState,
AgentConnectionChannels,
ChannelProtocolError
} from '../shared/agent-protocol/channel-state'
import {
AGENT_PROTOCOL_VERSION,
type AgentFrameHeader
} from '../shared/agent-protocol/contracts'
const base = {
connectionId: 'connection-1',
generation: 1,
channelId: 'channel-1',
channelEpoch: '1',
inboundDirection: 'main-to-agent' as const
}
function inbound(
overrides: Partial<AgentFrameHeader> = {}
): AgentFrameHeader {
return {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: base.connectionId,
generation: base.generation,
channelId: base.channelId,
channelEpoch: base.channelEpoch,
direction: 'main-to-agent',
sequence: '1',
kind: 'acp',
payloadLength: 1,
...overrides
}
}
describe('AgentChannelState', () => {
it('enforces identity, generation, direction, and contiguous sequence', () => {
const channel = new AgentChannelState(base)
expect(() => channel.acceptInbound(inbound())).not.toThrow()
expect(() =>
channel.acceptInbound(inbound({ sequence: '2' }))
).not.toThrow()
const stale = new AgentChannelState(base)
expect(() =>
stale.acceptInbound(inbound({ generation: 2 }))
).toThrowError(ChannelProtocolError)
expect(stale.closed).toBe(true)
const wrongDirection = new AgentChannelState(base)
expect(() =>
wrongDirection.acceptInbound(
inbound({ direction: 'agent-to-main' })
)
).toThrowError(ChannelProtocolError)
const gap = new AgentChannelState(base)
expect(() =>
gap.acceptInbound(inbound({ sequence: '2' }))
).toThrowError(ChannelProtocolError)
})
it('sequences durable ACKs independently from data', () => {
const channel = new AgentChannelState(base)
channel.acceptInbound(inbound({ kind: 'acp', sequence: '1' }))
channel.acceptInbound(inbound({ kind: 'ack', sequence: '1' }))
channel.acceptInbound(inbound({ kind: 'acp', sequence: '2' }))
const reserve = (kind: AgentFrameHeader['kind']) =>
channel.reserveOutbound({
...inbound({ kind }),
direction: 'agent-to-main'
})
expect(reserve('acp').sequence).toBe('1')
expect(reserve('ack').sequence).toBe('1')
expect(reserve('acp').sequence).toBe('2')
})
it('rejects inbound data after close begins', () => {
const channel = new AgentChannelState(base)
channel.beginClose()
expect(() => channel.acceptInbound(inbound())).toThrowError(
ChannelProtocolError
)
})
})
describe('AgentConnectionChannels', () => {
it('bounds active and lifetime channels without reusing IDs', () => {
const connection = new AgentConnectionChannels({
connectionId: 'connection-1',
generation: 1,
maximumChannels: 2,
maximumLifetimeChannels: 3
})
connection.open({
channelId: 'one',
channelEpoch: '1',
inboundDirection: 'main-to-agent'
})
connection.open({
channelId: 'two',
channelEpoch: '1',
inboundDirection: 'main-to-agent'
})
expect(() =>
connection.open({
channelId: 'blocked',
channelEpoch: '1',
inboundDirection: 'main-to-agent'
})
).toThrowError(ChannelProtocolError)
connection.close('one')
expect(() =>
connection.open({
channelId: 'one',
channelEpoch: '2',
inboundDirection: 'main-to-agent'
})
).toThrowError(ChannelProtocolError)
connection.open({
channelId: 'three',
channelEpoch: '1',
inboundDirection: 'main-to-agent'
})
connection.close('two')
expect(() =>
connection.open({
channelId: 'four',
channelEpoch: '1',
inboundDirection: 'main-to-agent'
})
).toThrowError(ChannelProtocolError)
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,289 @@
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
import { RuntimeBundleRegistry } from './runtime-bundle-registry'
import type { VerifiedRuntimeBundle } from './runtime-bundle-verifier'
const temporaryPaths: string[] = []
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('Runtime bundle registry', () => {
it('registers and resolves the current signed digest atomically', () => {
const fixture = createRegistry()
const verified = runtimeBundle(fixture, '1.18.9', 'a')
const entry = fixture.registry.register(verified)
expect(entry).toMatchObject({
runtimeId: 'opencode',
runtimeVersion: '1.18.9',
architecture: 'x64',
bundleDigest: verified.manifest.bundleDigest
})
expect(
fixture.registry.resolve(
'opencode',
verified.manifest.bundleDigest,
'x64'
)
).toEqual({
entry,
bundleDirectory: verified.bundleDirectory
})
expect(
new RuntimeBundleRegistry({
runtimeRoot: fixture.runtimeRoot
}).snapshot()
).toEqual(fixture.registry.snapshot())
})
it('reuses an exact bundle and replaces it with another signed identity', () => {
const fixture = createRegistry()
const first = runtimeBundle(fixture, '1.18.9', 'a')
const second = runtimeBundle(fixture, '1.19.0', 'b')
expect(fixture.registry.register(first)).toEqual(
fixture.registry.register(first)
)
fixture.registry.register(second)
expect(fixture.registry.snapshot().current).toEqual([
expect.objectContaining({
runtimeVersion: '1.19.0',
bundleDigest: second.manifest.bundleDigest
})
])
expect(() =>
fixture.registry.resolve(
'opencode',
first.manifest.bundleDigest,
'x64'
)
).toThrow('not the verified current bundle')
})
it('rewrites the previous sequence registry into the minimal format', () => {
const fixture = createRegistry()
writeFileSync(
fixture.storagePath,
`${JSON.stringify({
formatVersion: 1,
minimumTrustedReleaseSequence: 1,
current: [{
runtimeId: 'opencode',
provider: 'opencode',
runtimeVersion: '1.18.9',
releaseSequence: 3,
architecture: 'x64',
signingKeyId: 'test-key',
bundleDigest: `sha256:${'a'.repeat(64)}`,
manifestDigest: `sha256:${'b'.repeat(64)}`,
acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}`
}]
}, null, 2)}\n`,
{ mode: 0o600 }
)
expect(
new RuntimeBundleRegistry({
runtimeRoot: fixture.runtimeRoot
}).snapshot()
).toEqual({
formatVersion: 1,
current: [{
runtimeId: 'opencode',
runtimeVersion: '1.18.9',
architecture: 'x64',
bundleDigest: `sha256:${'a'.repeat(64)}`,
manifestDigest: `sha256:${'b'.repeat(64)}`,
acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}`
}]
})
expect(readFileSync(fixture.storagePath, 'utf8')).not.toContain(
'releaseSequence'
)
})
it('observes activations from another registry instance without no-op writes', () => {
const fixture = createRegistry()
const activator = new RuntimeBundleRegistry({
runtimeRoot: fixture.runtimeRoot
})
const first = runtimeBundle(fixture, '1.18.9', 'a')
const firstEntry = activator.register(first)
expect(fixture.registry.current('x64')).toEqual([firstEntry])
expect(
fixture.registry.resolve(
'opencode',
first.manifest.bundleDigest,
'x64'
)
).toEqual({
entry: firstEntry,
bundleDirectory: first.bundleDirectory
})
const beforeNoOp = fileIdentity(fixture.storagePath)
expect(fixture.registry.register(first)).toEqual(firstEntry)
expect(fileIdentity(fixture.storagePath)).toEqual(beforeNoOp)
const second = runtimeBundle(fixture, '1.19.0', 'b')
const secondEntry = new RuntimeBundleRegistry({
runtimeRoot: fixture.runtimeRoot
}).register(second)
const beforeSecondNoOp = fileIdentity(fixture.storagePath)
expect(fixture.registry.register(second)).toEqual(secondEntry)
expect(fileIdentity(fixture.storagePath)).toEqual(
beforeSecondNoOp
)
expect(fixture.registry.current('x64')).toEqual([secondEntry])
expect(() =>
fixture.registry.resolve(
'opencode',
first.manifest.bundleDigest,
'x64'
)
).toThrow('not the verified current bundle')
})
it('fails closed when the live registry becomes corrupt', () => {
const fixture = createRegistry()
writeFileSync(fixture.storagePath, '{"formatVersion":1}', {
mode: 0o600
})
expect(() => fixture.registry.current('x64')).toThrow(
/corrupt/iu
)
expect(() =>
new RuntimeBundleRegistry({
runtimeRoot: fixture.runtimeRoot
})
).toThrow(/corrupt/iu)
expect(readFileSync(fixture.storagePath, 'utf8')).toContain(
'formatVersion'
)
})
})
function fileIdentity(path: string): {
ino: bigint
mtimeNs: bigint
ctimeNs: bigint
} {
const stat = statSync(path, { bigint: true })
return {
ino: stat.ino,
mtimeNs: stat.mtimeNs,
ctimeNs: stat.ctimeNs
}
}
function createRegistry(): {
registry: RuntimeBundleRegistry
runtimeRoot: string
storagePath: string
} {
const root = resolve(
mkdtempSync(join(tmpdir(), 'goodbuddy-runtime-registry-'))
)
temporaryPaths.push(root)
if (process.platform !== 'win32') {
chmodSync(root, 0o700)
}
const runtimeRoot = resolve(root, 'runtimes')
const storagePath = resolve(runtimeRoot, 'registry.json')
return {
registry: new RuntimeBundleRegistry({ runtimeRoot }),
runtimeRoot,
storagePath
}
}
function runtimeBundle(
fixture: ReturnType<typeof createRegistry>,
runtimeVersion: string,
digestCharacter: string
): VerifiedRuntimeBundle {
const rawBundleDigest = digestCharacter.repeat(64)
const bundleDigest = `sha256:${rawBundleDigest}`
const bundleDirectory =
fixture.registry.bundleDirectory('opencode', bundleDigest)
mkdirSync(bundleDirectory, {
recursive: true,
mode: 0o700
})
const manifest = {
formatVersion: 2,
product: 'GoodBuddy',
runtimeId: 'opencode',
runtimeVersion,
provider: 'opencode',
platform: 'linux',
architecture: 'x64',
signingKeyId: 'runtime-test',
bundleDigest,
adapterDigest: `sha256:${'a'.repeat(64)}`,
sourcePackage: {
name: 'opencode-linux-x64-baseline',
integrity:
'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ=='
},
entrypoint: {
identity: 'opencode-acp',
path: 'bin/opencode',
sha256: 'c'.repeat(64),
argvPrefix: ['acp']
},
files: [
{
path: 'bin/opencode',
size: 64,
sha256: 'c'.repeat(64),
mode: '0755'
},
{
path: 'licenses/opencode.txt',
size: 4,
sha256: 'd'.repeat(64),
mode: '0644'
}
],
licenses: [
{
package: 'opencode-ai',
version: runtimeVersion,
spdx: 'MIT',
path: 'licenses/opencode.txt'
}
],
allowedEnvironmentNames: ['HOME'],
protocol: { major: 1, minor: 0 },
acpCapabilitiesDigest: `sha256:${'e'.repeat(64)}`,
limits: {
maximumPromptRuntimeMilliseconds: 60_000,
maximumPromptInputBytes: 4096,
maximumPromptOutputBytes: 1024 * 1024
}
} as const satisfies RemoteRuntimeBundleManifest
return {
bundleDirectory,
executablePath: resolve(bundleDirectory, 'bin', 'opencode'),
manifest,
manifestDigest: `sha256:${rawBundleDigest}`
}
}
+343
View File
@@ -0,0 +1,343 @@
import { readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import type {
AgentArchitecture,
AgentReleaseKeyRegistry
} from '../shared/agent-installation-contracts'
import type { DaemonCapabilities } from '../shared/agent-protocol'
import {
agentIdentifierSchema,
sha256DigestSchema
} from '../shared/agent-protocol/contracts'
import {
runtimeRegistryEntrySchema,
runtimeRegistryStateSchema,
parseRuntimeRegistryState,
type RuntimeRegistryEntry,
type RuntimeRegistryState
} from '../shared/remote-environment-registry-contracts'
import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts'
import {
assertAbsoluteManagedPath,
assertPrivateRegularFile,
ensurePrivateDirectory,
writePrivateFileAtomic
} from './managed-paths'
import type { InstalledBundleVerificationEnvironment } from './installed-bundle-verifier'
import {
verifyRuntimeBundle,
type VerifiedRuntimeBundle
} from './runtime-bundle-verifier'
export {
runtimeRegistryEntrySchema,
runtimeRegistryStateSchema
}
export type {
RuntimeRegistryEntry,
RuntimeRegistryState
}
export class RuntimeBundleRegistry {
readonly #runtimeRoot: string
readonly #storagePath: string
#state: RuntimeRegistryState
constructor(options: {
runtimeRoot: string
storagePath?: string
}) {
this.#runtimeRoot = assertAbsoluteManagedPath(
resolve(options.runtimeRoot)
)
this.#storagePath = assertAbsoluteManagedPath(
resolve(
options.storagePath ??
join(this.#runtimeRoot, 'registry.json')
)
)
ensurePrivateDirectory(this.#runtimeRoot)
ensurePrivateDirectory(dirname(this.#storagePath))
this.#state = this.#load()
}
snapshot(): RuntimeRegistryState {
return structuredClone(this.#state)
}
register(verified: VerifiedRuntimeBundle): RuntimeRegistryEntry {
const entry = entryFromVerified(verified)
const expectedDirectory = this.bundleDirectory(
entry.runtimeId,
entry.bundleDigest
)
if (resolve(verified.bundleDirectory) !== expectedDirectory) {
throw new Error(
'Verified Runtime bundle is outside its digest directory'
)
}
this.#state = this.#load({ createIfMissing: false })
const existing = this.#state.current.find(
(candidate) =>
candidate.runtimeId === entry.runtimeId &&
candidate.architecture === entry.architecture
)
if (existing !== undefined && entryEquals(existing, entry)) {
return existing
}
this.#commit({
formatVersion: 1,
current: [
...this.#state.current.filter(
(candidate) =>
candidate.runtimeId !== entry.runtimeId ||
candidate.architecture !== entry.architecture
),
entry
].sort((left, right) =>
`${left.runtimeId}:${left.architecture}`.localeCompare(
`${right.runtimeId}:${right.architecture}`
)
)
})
return entry
}
current(
architecture: AgentArchitecture
): RuntimeRegistryEntry[] {
this.#state = this.#load({ createIfMissing: false })
return this.#state.current
.filter((entry) => entry.architecture === architecture)
.map((entry) => ({ ...entry }))
}
resolve(
runtimeIdInput: string,
bundleDigestInput: string,
architecture: AgentArchitecture
): {
entry: RuntimeRegistryEntry
bundleDirectory: string
} {
const runtimeId = agentIdentifierSchema.parse(runtimeIdInput)
const bundleDigest = sha256DigestSchema.parse(bundleDigestInput)
this.#state = this.#load({ createIfMissing: false })
const entry = this.#state.current.find(
(candidate) =>
candidate.runtimeId === runtimeId &&
candidate.bundleDigest === bundleDigest &&
candidate.architecture === architecture
)
if (entry === undefined) {
throw new Error(
'Runtime bundle is not the verified current bundle'
)
}
return {
entry: { ...entry },
bundleDirectory: this.bundleDirectory(
runtimeId,
bundleDigest
)
}
}
bundleDirectory(
runtimeIdInput: string,
bundleDigestInput: string
): string {
const runtimeId = agentIdentifierSchema.parse(runtimeIdInput)
const bundleDigest = sha256DigestSchema.parse(bundleDigestInput)
return resolve(
this.#runtimeRoot,
runtimeId,
bundleDigest.slice('sha256:'.length)
)
}
#load(
options: { createIfMissing?: boolean } = {}
): RuntimeRegistryState {
let value: unknown
try {
assertPrivateRegularFile(this.#storagePath)
value = JSON.parse(readFileSync(this.#storagePath, 'utf8'))
} catch (error) {
if (
options.createIfMissing !== false &&
isNodeError(error) &&
error.code === 'ENOENT'
) {
const initial: RuntimeRegistryState = {
formatVersion: 1,
current: []
}
writePrivateFileAtomic(
this.#storagePath,
`${JSON.stringify(initial, null, 2)}\n`
)
return initial
}
throw new Error('Runtime bundle registry is corrupt', {
cause: error
})
}
let parsed: RuntimeRegistryState
try {
parsed = parseRuntimeRegistryState(value)
} catch (error) {
throw new Error(
'Runtime bundle registry is corrupt',
{ cause: error }
)
}
if (JSON.stringify(parsed) !== JSON.stringify(value)) {
writePrivateFileAtomic(
this.#storagePath,
`${JSON.stringify(parsed, null, 2)}\n`
)
}
return parsed
}
#commit(stateInput: RuntimeRegistryState): void {
const state = runtimeRegistryStateSchema.parse(stateInput)
let persisted: unknown
try {
assertPrivateRegularFile(this.#storagePath)
persisted = JSON.parse(readFileSync(this.#storagePath, 'utf8'))
} catch (error) {
throw new Error(
'Runtime bundle registry changed or became corrupt before commit',
{ cause: error }
)
}
const parsedPersisted =
runtimeRegistryStateSchema.safeParse(persisted)
if (
!parsedPersisted.success ||
JSON.stringify(parsedPersisted.data) !== JSON.stringify(this.#state)
) {
throw new Error(
'Runtime bundle registry changed concurrently; refusing to overwrite it'
)
}
writePrivateFileAtomic(
this.#storagePath,
`${JSON.stringify(state, null, 2)}\n`
)
this.#state = state
}
}
export function createVerifiedRuntimeCapabilitySource(options: {
registry: RuntimeBundleRegistry
architecture: AgentArchitecture
releaseKeyRegistry: AgentReleaseKeyRegistry
runtimeLock: RemoteRuntimeLock
verificationEnvironment?: InstalledBundleVerificationEnvironment
enforceFilesystemMode?: boolean
filesystemPlatform?: NodeJS.Platform
uid?: number
reportError?: (message: string, error: unknown) => void
}): () => Promise<DaemonCapabilities['runtimes']> {
return async () => await loadVerifiedRuntimeCapabilities(options)
}
async function loadVerifiedRuntimeCapabilities(options: {
registry: RuntimeBundleRegistry
architecture: AgentArchitecture
releaseKeyRegistry: AgentReleaseKeyRegistry
runtimeLock: RemoteRuntimeLock
verificationEnvironment?: InstalledBundleVerificationEnvironment
enforceFilesystemMode?: boolean
filesystemPlatform?: NodeJS.Platform
uid?: number
reportError?: (message: string, error: unknown) => void
}): Promise<DaemonCapabilities['runtimes']> {
const capabilities: DaemonCapabilities['runtimes'] = []
for (const entry of options.registry.current(options.architecture)) {
try {
const verified = await verifyRuntimeBundle(
options.registry.bundleDirectory(
entry.runtimeId,
entry.bundleDigest
),
{
architecture: options.architecture,
releaseKeyRegistry: options.releaseKeyRegistry,
runtimeLock: options.runtimeLock,
...(options.verificationEnvironment === undefined
? {}
: {
verificationEnvironment:
options.verificationEnvironment
}),
...(options.enforceFilesystemMode === undefined
? {}
: {
enforceFilesystemMode:
options.enforceFilesystemMode
}),
...(options.filesystemPlatform === undefined
? {}
: {
filesystemPlatform: options.filesystemPlatform
}),
...(options.uid === undefined ? {} : { uid: options.uid })
}
)
if (
verified.manifest.runtimeVersion !== entry.runtimeVersion ||
verified.manifestDigest !== entry.manifestDigest ||
verified.manifest.bundleDigest !== entry.bundleDigest ||
verified.manifest.acpCapabilitiesDigest !==
entry.acpCapabilitiesDigest
) {
throw new Error(
'Runtime bundle no longer matches its registry identity'
)
}
capabilities.push({
runtimeId: entry.runtimeId,
version: entry.runtimeVersion,
bundleDigest: entry.bundleDigest,
acpCapabilitiesDigest: entry.acpCapabilitiesDigest,
sessionLoad: true,
sessionResume: true
})
} catch (error) {
options.reportError?.(
`Verified Runtime unavailable: ${entry.runtimeId}`,
error
)
}
}
return capabilities
}
function entryFromVerified(
verified: VerifiedRuntimeBundle
): RuntimeRegistryEntry {
return runtimeRegistryEntrySchema.parse({
runtimeId: verified.manifest.runtimeId,
runtimeVersion: verified.manifest.runtimeVersion,
architecture: verified.manifest.architecture,
bundleDigest: verified.manifest.bundleDigest,
manifestDigest: verified.manifestDigest,
acpCapabilitiesDigest:
verified.manifest.acpCapabilitiesDigest
})
}
function entryEquals(
left: RuntimeRegistryEntry,
right: RuntimeRegistryEntry
): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error
}
@@ -0,0 +1,242 @@
import {
createHash,
generateKeyPairSync,
sign
} from 'node:crypto'
import {
chmodSync,
mkdirSync,
mkdtempSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import type {
AgentReleaseKeyRegistry
} from '../shared/agent-installation-contracts'
import {
digestRemoteRuntimeBundleIdentity,
remoteRuntimeBundleManifestSchema,
type RemoteRuntimeBundleManifest,
type RemoteRuntimeLock
} from '../shared/remote-runtime-launch-contracts'
import {
canonicalRuntimeManifestBytes,
runtimeManifestSignaturePayload
} from './runtime-bundle-verifier'
const RAW_DIGEST = 'a'.repeat(64)
export const TEST_REMOTE_RUNTIME_LOCK: RemoteRuntimeLock = {
formatVersion: 1,
runtimes: {
opencode: {
version: '1.18.9',
provider: 'opencode',
entrypoint: 'bin/opencode',
entrypointIdentity: 'opencode-acp',
argvPrefix: ['acp'],
allowedEnvironmentNames: [
'HOME',
'LANG',
'LC_ALL',
'PATH',
'TMPDIR',
'XDG_CACHE_HOME',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_STATE_HOME'
],
protocol: { major: 1, minor: 0 },
targets: {
x64: {
package: 'opencode-linux-x64-baseline',
integrity:
'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ=='
},
arm64: {
package: 'opencode-linux-arm64',
integrity:
'sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A=='
}
}
}
}
}
export async function createRuntimeBundleTestFixture(
overrides: {
sourcePackage?: RemoteRuntimeBundleManifest['sourcePackage']
directoryDigest?: string
} = {}
): Promise<{
root: string
bundleDirectory: string
manifest: RemoteRuntimeBundleManifest
registry: AgentReleaseKeyRegistry
}> {
const root = resolve(
mkdtempSync(join(tmpdir(), 'goodbuddy-runtime-bundle-'))
)
if (process.platform !== 'win32') {
chmodSync(root, 0o700)
}
const binary = elfHeader('x64')
const license = Buffer.from('MIT\n', 'utf8')
const initial = remoteRuntimeBundleManifestSchema.parse({
formatVersion: 2,
product: 'GoodBuddy',
runtimeId: 'opencode',
runtimeVersion: '1.18.9',
provider: 'opencode',
platform: 'linux',
architecture: 'x64',
signingKeyId: 'runtime-test',
bundleDigest: `sha256:${RAW_DIGEST}`,
adapterDigest: `sha256:${'b'.repeat(64)}`,
sourcePackage:
overrides.sourcePackage ??
{
name:
TEST_REMOTE_RUNTIME_LOCK.runtimes.opencode.targets.x64
.package,
integrity:
TEST_REMOTE_RUNTIME_LOCK.runtimes.opencode.targets.x64
.integrity
},
entrypoint: {
identity: 'opencode-acp',
path: 'bin/opencode',
sha256: sha256(binary),
argvPrefix: ['acp']
},
files: [
{
path: 'bin/opencode',
size: binary.byteLength,
sha256: sha256(binary),
mode: '0755'
},
{
path: 'licenses/opencode.txt',
size: license.byteLength,
sha256: sha256(license),
mode: '0644'
}
],
licenses: [
{
package: 'opencode-ai',
version: '1.18.9',
spdx: 'MIT',
path: 'licenses/opencode.txt'
}
],
allowedEnvironmentNames: [
'HOME',
'LANG',
'LC_ALL',
'PATH',
'TMPDIR',
'XDG_CACHE_HOME',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_STATE_HOME'
],
protocol: { major: 1, minor: 0 },
acpCapabilitiesDigest: `sha256:${'d'.repeat(64)}`,
limits: {
maximumPromptRuntimeMilliseconds: 60_000,
maximumPromptInputBytes: 4096,
maximumPromptOutputBytes: 1024 * 1024
}
})
const manifest = remoteRuntimeBundleManifestSchema.parse({
...initial,
bundleDigest:
await digestRemoteRuntimeBundleIdentity(initial)
})
const directoryDigest =
overrides.directoryDigest ?? manifest.bundleDigest
const bundleDirectory = resolve(
root,
'opencode',
directoryDigest.slice('sha256:'.length)
)
writePayload(
join(bundleDirectory, 'bin', 'opencode'),
binary,
0o755
)
writePayload(
join(bundleDirectory, 'licenses', 'opencode.txt'),
license,
0o644
)
const { publicKey, privateKey } =
generateKeyPairSync('ed25519')
const registry: AgentReleaseKeyRegistry = {
formatVersion: 1,
keys: [
{
keyId: 'runtime-test',
publicKeySpkiBase64: publicKey
.export({ format: 'der', type: 'spki' })
.toString('base64'),
environment: 'test'
}
],
revocations: []
}
const manifestBytes = canonicalRuntimeManifestBytes(manifest)
writePayload(
join(bundleDirectory, 'manifest.json'),
manifestBytes,
0o644
)
writePayload(
join(bundleDirectory, 'manifest.sig'),
Buffer.from(
`${sign(
null,
runtimeManifestSignaturePayload(manifestBytes),
privateKey
).toString('base64')}\n`,
'utf8'
),
0o644
)
if (process.platform !== 'win32') {
chmodSync(bundleDirectory, 0o700)
chmodSync(join(bundleDirectory, 'bin'), 0o700)
chmodSync(join(bundleDirectory, 'licenses'), 0o700)
}
return { root, bundleDirectory, manifest, registry }
}
function writePayload(
path: string,
contents: Uint8Array,
mode: number
): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
writeFileSync(path, contents, { mode })
if (process.platform !== 'win32') {
chmodSync(path, mode)
}
}
function elfHeader(architecture: 'x64' | 'arm64'): Buffer {
const header = Buffer.alloc(64)
header[0] = 0x7f
header.write('ELF', 1, 'ascii')
header[4] = 2
header[5] = 1
header.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18)
return header
}
function sha256(contents: Uint8Array): string {
return createHash('sha256').update(contents).digest('hex')
}
@@ -0,0 +1,220 @@
import {
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
type RemoteRuntimeBundleManifest
} from '../shared/remote-runtime-launch-contracts'
import { verifyRuntimeBundle } from './runtime-bundle-verifier'
import {
RuntimeBundleRegistry,
createVerifiedRuntimeCapabilitySource
} from './runtime-bundle-registry'
import {
createRuntimeBundleTestFixture,
TEST_REMOTE_RUNTIME_LOCK
} from './runtime-bundle-test-fixture'
const temporaryPaths: string[] = []
const lock = TEST_REMOTE_RUNTIME_LOCK
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('Remote Runtime bundle verifier', () => {
it('verifies an authentic digest-addressed OpenCode bundle', async () => {
const fixture = await createBundle()
await expect(
verifyRuntimeBundle(fixture.bundleDirectory, {
architecture: 'x64',
releaseKeyRegistry: fixture.registry,
runtimeLock: lock,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
})
).resolves.toMatchObject({
bundleDirectory: fixture.bundleDirectory,
executablePath: join(
fixture.bundleDirectory,
'bin',
'opencode'
),
manifest: fixture.manifest,
manifestDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u)
})
})
it('fails closed on test signing keys in production verification', async () => {
const fixture = await createBundle()
await expect(
verifyRuntimeBundle(fixture.bundleDirectory, {
architecture: 'x64',
releaseKeyRegistry: fixture.registry,
runtimeLock: lock,
filesystemPlatform: 'win32'
})
).rejects.toThrow(/production key registry is empty/iu)
})
it('rejects a forged signature and payload mutation', async () => {
const forged = await createBundle()
const signaturePath = join(
forged.bundleDirectory,
'manifest.sig'
)
const signature = Buffer.from(
readFileSync(signaturePath, 'utf8').trim(),
'base64'
)
signature[0] ^= 0xff
writeFileSync(
signaturePath,
`${signature.toString('base64')}\n`
)
await expect(
verifyRuntimeBundle(forged.bundleDirectory, {
architecture: 'x64',
releaseKeyRegistry: forged.registry,
runtimeLock: lock,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
})
).rejects.toThrow(/signature verification failed/iu)
const mutated = await createBundle()
writeFileSync(
join(mutated.bundleDirectory, 'licenses', 'opencode.txt'),
'mutated'
)
await expect(
verifyRuntimeBundle(mutated.bundleDirectory, {
architecture: 'x64',
releaseKeyRegistry: mutated.registry,
runtimeLock: lock,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
})
).rejects.toThrow(/payload (?:size|hash) mismatch/iu)
})
it('rejects a validly signed bundle outside the locked profile', async () => {
const fixture = await createBundle({
sourcePackage: {
name: 'opencode-linux-x64',
integrity:
'sha512-VrvzV5Agrj0T2ZPvr5gzmh8xc4zqQ5pW8UeNgTzt5cJ/9Cbdxw6oFywgv7nfJqlpSfQqTWYJYH+LIHt3QdCS5g=='
}
})
await expect(
verifyRuntimeBundle(fixture.bundleDirectory, {
architecture: 'x64',
releaseKeyRegistry: fixture.registry,
runtimeLock: lock,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
})
).rejects.toThrow(/locked OpenCode profile/iu)
})
it('rejects undeclared files and a mismatched digest directory', async () => {
const undeclared = await createBundle()
writeFileSync(
join(undeclared.bundleDirectory, 'unexpected.txt'),
'unexpected'
)
await expect(
verifyRuntimeBundle(undeclared.bundleDirectory, {
architecture: 'x64',
releaseKeyRegistry: undeclared.registry,
runtimeLock: lock,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
})
).rejects.toThrow(/undeclared or missing/iu)
const mismatched = await createBundle({
directoryDigest: `sha256:${'f'.repeat(64)}`
})
await expect(
verifyRuntimeBundle(mismatched.bundleDirectory, {
architecture: 'x64',
releaseKeyRegistry: mismatched.registry,
runtimeLock: lock,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
})
).rejects.toThrow(/managed directory/iu)
})
it('advertises only a bundle that still verifies against its registry', async () => {
const fixture = await createBundle()
const runtimeRoot = dirname(dirname(fixture.bundleDirectory))
const registry = new RuntimeBundleRegistry({
runtimeRoot
})
const verified = await verifyRuntimeBundle(
fixture.bundleDirectory,
{
architecture: 'x64',
releaseKeyRegistry: fixture.registry,
runtimeLock: lock,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
}
)
new RuntimeBundleRegistry({ runtimeRoot }).register(verified)
const reportError = vi.fn()
const capabilities = createVerifiedRuntimeCapabilitySource({
registry,
architecture: 'x64',
releaseKeyRegistry: fixture.registry,
runtimeLock: lock,
verificationEnvironment: 'test',
filesystemPlatform: 'win32',
reportError
})
await expect(capabilities()).resolves.toEqual([
{
runtimeId: 'opencode',
version: '1.18.9',
bundleDigest: fixture.manifest.bundleDigest,
acpCapabilitiesDigest:
fixture.manifest.acpCapabilitiesDigest,
sessionLoad: true,
sessionResume: true
}
])
writeFileSync(
join(fixture.bundleDirectory, 'licenses', 'opencode.txt'),
'mutated'
)
await expect(capabilities()).resolves.toEqual([])
expect(reportError).toHaveBeenCalledWith(
'Verified Runtime unavailable: opencode',
expect.any(Error)
)
})
})
async function createBundle(
overrides: {
sourcePackage?: RemoteRuntimeBundleManifest['sourcePackage']
directoryDigest?: string
} = {}
): Promise<Awaited<
ReturnType<typeof createRuntimeBundleTestFixture>
>> {
const fixture = await createRuntimeBundleTestFixture(overrides)
temporaryPaths.push(fixture.root)
return fixture
}
@@ -0,0 +1,12 @@
export {
canonicalRuntimeManifestBytes,
readRemoteRuntimeLock,
runtimeManifestSignaturePayload,
verifyRuntimeBundle,
verifyRuntimeManifestSignature
} from '../shared/node/runtime-bundle-verifier'
export type {
VerifiedRuntimeBundle,
VerifyRuntimeBundleOptions
} from '../shared/node/runtime-bundle-verifier'
@@ -0,0 +1,272 @@
import {
chmodSync,
lstatSync,
mkdtempSync,
mkdirSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ControllerRegistry } from './controller-registry'
import { EventJournal } from './event-journal'
import { RuntimeBundleRegistry } from './runtime-bundle-registry'
import {
createRuntimeBundleTestFixture,
TEST_REMOTE_RUNTIME_LOCK
} from './runtime-bundle-test-fixture'
import { verifyRuntimeBundle } from './runtime-bundle-verifier'
import {
createProductionRuntimeProtocol,
derivePrivateModelBridgeDirectory,
derivePrivateRuntimeScratchDirectory
} from './runtime-composition'
import {
MODEL_BRIDGE_BROKER_SOCKET_NAME
} from './model-bridge-broker'
import { WorkspaceRegistry } from './workspace-registry'
const temporaryPaths: string[] = []
afterEach(() => {
vi.restoreAllMocks()
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('production Runtime composition', () => {
it('keeps ACP live and reloads managed metadata installed after daemon startup', async () => {
const fixture = await createRuntimeBundleTestFixture()
const root = fixture.root
temporaryPaths.push(root)
const runtimeRoot = root
const stateDirectory = resolve(root, 'state')
mkdirSync(stateDirectory, { mode: 0o700 })
const agentExecutablePath = resolve(root, 'goodbuddy-agent')
writeFileSync(agentExecutablePath, 'agent', { mode: 0o755 })
const reportError = vi.fn()
const events = new EventJournal(resolve(root, 'events.sqlite'))
const workspaces = new WorkspaceRegistry({
controllers: new ControllerRegistry()
})
const protocol = await createProductionRuntimeProtocol({
runtimeRoot,
stateDirectory,
architecture: 'x64',
events,
workspaces,
outputSink: async () => {},
blobSink: async () => {},
agentExecutablePath,
reportError,
prerequisitesAvailable: async () => true,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
})
expect(Object.keys(protocol.methods)).toContain(
'runtime/openAcpChannel'
)
await expect(protocol.runtimes()).resolves.toEqual([])
expect(reportError).toHaveBeenCalledWith(
expect.stringContaining('Runtime metadata'),
expect.anything()
)
writeFileSync(
resolve(runtimeRoot, 'release-keys.json'),
`${JSON.stringify(fixture.registry, null, 2)}\n`,
{ mode: 0o600 }
)
writeFileSync(
resolve(runtimeRoot, 'remote-runtime-lock.json'),
`${JSON.stringify(TEST_REMOTE_RUNTIME_LOCK, null, 2)}\n`,
{ mode: 0o600 }
)
const verified = await verifyRuntimeBundle(
fixture.bundleDirectory,
{
architecture: 'x64',
releaseKeyRegistry: fixture.registry,
runtimeLock: TEST_REMOTE_RUNTIME_LOCK,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
}
)
new RuntimeBundleRegistry({ runtimeRoot }).register(verified)
reportError.mockClear()
await expect(protocol.runtimes()).resolves.toEqual([
{
runtimeId: 'opencode',
version: '1.18.9',
bundleDigest: fixture.manifest.bundleDigest,
acpCapabilitiesDigest:
fixture.manifest.acpCapabilitiesDigest,
sessionLoad: true,
sessionResume: true
}
])
expect(reportError).not.toHaveBeenCalled()
writeFileSync(
resolve(runtimeRoot, 'release-keys.json'),
`${JSON.stringify({
formatVersion: 1,
keys: [],
revocations: []
}, null, 2)}\n`,
{ mode: 0o600 }
)
await expect(protocol.runtimes()).resolves.toEqual([])
expect(reportError).toHaveBeenCalledWith(
'Verified Runtime unavailable: opencode',
expect.any(Error)
)
await expect(protocol.dispose()).resolves.toBeUndefined()
events.close()
})
it('composes all ACP v3 methods but advertises no Runtime when prerequisites fail', async () => {
const root = temporaryDirectory()
const runtimeRoot = resolve(root, 'runtimes')
const stateDirectory = resolve(root, 'state')
mkdirSync(stateDirectory, { mode: 0o700 })
const releaseKeyRegistry = {
formatVersion: 1 as const,
keys: [],
revocations: []
}
const registry = new RuntimeBundleRegistry({
runtimeRoot
})
const events = new EventJournal(resolve(root, 'events.sqlite'))
const workspaces = new WorkspaceRegistry({
controllers: new ControllerRegistry()
})
const protocol = await createProductionRuntimeProtocol({
runtimeRoot,
stateDirectory,
architecture: 'x64',
events,
workspaces,
outputSink: async () => {},
releaseKeyRegistry,
runtimeLock: {} as RemoteRuntimeLock,
registry,
prerequisitesAvailable: async () => false,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
})
expect(Object.keys(protocol!.methods).sort()).toEqual([
'runtime/closeAcpChannel',
'runtime/completePrompt',
'runtime/escalateCancellation',
'runtime/getAcpCursors',
'runtime/openAcpChannel',
'runtime/preparePrompt',
'runtime/reconcilePrompt',
'runtime/replayAcpChannel',
'runtime/resumeAcpChannel'
])
await expect(protocol!.runtimes()).resolves.toEqual([])
await expect(protocol!.dispose()).resolves.toBeUndefined()
events.close()
})
it('derives private binding scratch only beneath Agent state', () => {
const root = temporaryDirectory()
const stateDirectory = resolve(root, 'state')
mkdirSync(stateDirectory, { mode: 0o700 })
const binding = {
bindingId: 'binding-1',
runtimeBundleDigest: `sha256:${'a'.repeat(64)}`,
workspaceIdentity: 'workspace-1',
controllerId: 'controller-1',
controllerGeneration: 1
}
const first = derivePrivateRuntimeScratchDirectory(
stateDirectory,
binding
)
const second = derivePrivateRuntimeScratchDirectory(
stateDirectory,
binding
)
const other = derivePrivateRuntimeScratchDirectory(
stateDirectory,
{ ...binding, bindingId: 'binding-2' }
)
const nextGeneration = derivePrivateRuntimeScratchDirectory(
stateDirectory,
{
...binding,
controllerGeneration: 2,
controllerId: 'controller-2'
}
)
expect(first).toBe(second)
expect(first).not.toBe(other)
expect(nextGeneration).toBe(first)
expect(first.startsWith(resolve(stateDirectory, 'runtime-scratch'))).toBe(
true
)
expect(lstatSync(first).isDirectory()).toBe(true)
if (process.platform !== 'win32') {
expect(lstatSync(first).mode & 0o777).toBe(0o700)
}
})
it('keeps the Linux model bridge endpoint within the Unix socket limit', () => {
const root = temporaryDirectory()
const stateDirectory = resolve(root, 'state')
mkdirSync(stateDirectory, { mode: 0o700 })
const bridgeDirectory = derivePrivateModelBridgeDirectory(
stateDirectory,
{
bindingId: 'binding-1',
runtimeBundleDigest: `sha256:${'a'.repeat(64)}`,
workspaceIdentity: 'workspace-1'
}
)
if (process.platform === 'linux') {
temporaryPaths.push(bridgeDirectory)
expect(
Buffer.byteLength(
join(
bridgeDirectory,
MODEL_BRIDGE_BROKER_SOCKET_NAME
),
'utf8'
)
).toBeLessThanOrEqual(107)
expect(bridgeDirectory).toMatch(
/^\/tmp\/goodbuddy-[0-9]+-[0-9a-f]{20}\/mb\/[0-9a-f]{32}$/u
)
} else {
expect(
bridgeDirectory.startsWith(
resolve(stateDirectory, 'model-bridges')
)
).toBe(true)
}
})
})
function temporaryDirectory(): string {
const path = mkdtempSync(
join(tmpdir(), 'goodbuddy-runtime-composition-')
)
temporaryPaths.push(path)
if (process.platform !== 'win32') {
chmodSync(path, 0o700)
}
return path
}
+474
View File
@@ -0,0 +1,474 @@
import { constants } from 'node:fs'
import { access } from 'node:fs/promises'
import { createHash, randomUUID } from 'node:crypto'
import { join, resolve } from 'node:path'
import type {
AgentArchitecture,
AgentReleaseKeyRegistry
} from '../shared/agent-installation-contracts'
import type { AgentFrame } from '../shared/agent-protocol'
import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts'
import type { EventJournal } from './event-journal'
import {
derivePrivateTemporaryRoot,
ensurePrivateDirectory,
ensurePrivateDirectoryTree,
ensurePrivateTemporaryRoot
} from './managed-paths'
import { readManagedAgentReleaseKeyRegistry } from './installed-bundle-verifier'
import {
RuntimeBundleRegistry,
createVerifiedRuntimeCapabilitySource
} from './runtime-bundle-registry'
import {
readRemoteRuntimeLock,
verifyRuntimeBundle
} from './runtime-bundle-verifier'
import { RuntimeAcpBackend } from './runtime-acp-backend'
import type { ProtocolMethodContext } from './protocol-server'
import {
launchDirectLinuxStdioProcessOwner,
reconcileOrphanedDirectLinuxStdioProcesses
} from './direct-linux-stdio-process-owner'
import { RuntimeOwnerRegistry } from './runtime-owner-registry'
import type { WorkspaceRegistry } from './workspace-registry'
const BWRAP_EXECUTABLE = '/usr/bin/bwrap'
export type ProductionRuntimeProtocol = {
runtimes: ReturnType<typeof createVerifiedRuntimeCapabilitySource>
methods: RuntimeAcpBackend['methods']
onAcpFrame: RuntimeAcpBackend['onAcpFrame']
onBlobFrame?: RuntimeAcpBackend['onBlobFrame']
authorizeBlobFrame?: RuntimeAcpBackend['authorizeBlobFrame']
dispose: () => Promise<void>
}
export type ProductionRuntimeCompositionOptions = {
runtimeRoot: string
stateDirectory: string
architecture: AgentArchitecture
events: EventJournal
workspaces: WorkspaceRegistry
outputSink: (
frame: AgentFrame,
context: {
bindingId: string
controllerId: string
controllerGeneration: number
}
) => void | Promise<void>
blobSink?: (
frame: AgentFrame,
context: {
bindingId: string
controllerId: string
controllerGeneration: number
}
) => void | Promise<void>
agentExecutablePath?: string
installationId?: string
agentServiceUnitName?: string
releaseKeyRegistry?: AgentReleaseKeyRegistry
runtimeLock?: RemoteRuntimeLock
registry?: RuntimeBundleRegistry
prerequisitesAvailable?: () => boolean | Promise<boolean>
launchProcess?: typeof launchDirectLinuxStdioProcessOwner
reconcileOrphanedProcesses?: (
installationId: string
) => Promise<unknown>
ownerRegistry?: RuntimeOwnerRegistry
verificationEnvironment?: 'production' | 'test'
filesystemPlatform?: NodeJS.Platform
reportError?: (message: string, error: unknown) => void
}
export async function createProductionRuntimeProtocol(
options: ProductionRuntimeCompositionOptions
): Promise<ProductionRuntimeProtocol> {
const runtimeRoot = resolve(options.runtimeRoot)
const stateDirectory = resolve(options.stateDirectory)
const verificationEnvironment =
options.verificationEnvironment ?? 'production'
const installationId =
options.installationId ??
(verificationEnvironment === 'test'
? 'test-installation'
: undefined)
if (installationId === undefined) {
throw new Error(
'Production Runtime composition requires its Agent installation identity'
)
}
ensurePrivateDirectory(stateDirectory, { create: false })
const ownerRegistry =
options.ownerRegistry ??
new RuntimeOwnerRegistry(join(stateDirectory, 'runtime-owners.sqlite'))
let registry = options.registry
const currentRegistry = (): RuntimeBundleRegistry => {
if (registry === undefined) {
registry = new RuntimeBundleRegistry({ runtimeRoot })
}
return registry
}
const currentVerificationOptions = async () => {
const metadataOptions =
options.filesystemPlatform === undefined
? {}
: { filesystemPlatform: options.filesystemPlatform }
const [releaseKeyRegistry, runtimeLock] = await Promise.all([
options.releaseKeyRegistry ??
readManagedAgentReleaseKeyRegistry(
join(runtimeRoot, 'release-keys.json'),
metadataOptions
),
options.runtimeLock ??
readRemoteRuntimeLock(
join(runtimeRoot, 'remote-runtime-lock.json'),
metadataOptions
)
])
return {
architecture: options.architecture,
releaseKeyRegistry,
runtimeLock,
verificationEnvironment,
...(options.filesystemPlatform === undefined
? {}
: { filesystemPlatform: options.filesystemPlatform })
} as const
}
const verifiedCapabilities = async () => {
const source = createVerifiedRuntimeCapabilitySource({
registry: currentRegistry(),
...(await currentVerificationOptions()),
reportError: options.reportError
})
return await source()
}
const prerequisites =
options.prerequisitesAvailable ??
productionRuntimePrerequisitesAvailable
const launchProcess =
options.launchProcess ?? launchDirectLinuxStdioProcessOwner
let backend: RuntimeAcpBackend
try {
if (
verificationEnvironment === 'production' &&
process.platform === 'linux'
) {
await (
options.reconcileOrphanedProcesses ??
(async (currentInstallationId: string) =>
await reconcileOrphanedDirectLinuxStdioProcesses({
installationId: currentInstallationId,
registry: ownerRegistry
}))
)(installationId)
}
ensurePrivateDirectoryTree(
join(stateDirectory, 'runtime-scratch'),
stateDirectory
)
ensurePrivateDirectoryTree(
join(stateDirectory, 'model-bridges'),
stateDirectory
)
backend = new RuntimeAcpBackend({
journal: options.events,
resolveRuntimeBundle: (runtimeId, bundleDigest) =>
currentRegistry().resolve(
runtimeId,
bundleDigest,
options.architecture
),
reverifyRuntimeBundle: async (resolved) => {
const verified = await verifyRuntimeBundle(
resolved.bundleDirectory,
await currentVerificationOptions()
)
if (
verified.manifestDigest !== resolved.entry.manifestDigest ||
verified.manifest.bundleDigest !==
resolved.entry.bundleDigest
) {
throw new Error(
'Runtime bundle no longer matches its registered identity'
)
}
return verified
},
resolveWorkspace: async (preparation, context) =>
resolveCurrentWorkspace(
options.workspaces,
stateDirectory,
preparation,
context
),
launchProcess: async (launch) =>
await launchProcess({
manifest: launch.manifest,
profileInput: {
bundleDirectory: launch.bundle.bundleDirectory,
workspaceDirectory: launch.workspace.workspaceDirectory,
scratchDirectory: launch.scratch,
workMode: launch.workMode,
...(launch.modelBridge === undefined ||
options.agentExecutablePath === undefined
? {}
: {
modelBridge: {
agentExecutablePath: resolve(
options.agentExecutablePath
),
bridgeDirectory:
launch.workspace.bridgeDirectory!,
socketPath: launch.modelBridge.socketPath,
policy: launch.modelBridge.policy
}
})
},
identity: {
launchId: `launch-${randomUUID()}`,
processId: `process-${randomUUID()}`
},
installationId,
registry: ownerRegistry,
deadlineAt: launch.deadlineAt,
maximumInputBytes: launch.budget.maximumInputBytes
}),
outputSink: options.outputSink,
...(options.blobSink === undefined
? {}
: { blobSink: options.blobSink })
})
} catch (error) {
if (options.ownerRegistry === undefined) {
ownerRegistry.close()
}
throw error
}
return {
runtimes: async () => {
try {
if (
options.blobSink === undefined ||
options.agentExecutablePath === undefined ||
!(await executableAvailable(
resolve(options.agentExecutablePath)
)) ||
!(await prerequisites())
) {
return []
}
} catch (error) {
options.reportError?.(
'Agent Runtime prerequisites are unavailable',
error
)
return []
}
try {
return await verifiedCapabilities()
} catch (error) {
options.reportError?.(
'Agent Runtime metadata is unavailable; Runtime remains disabled',
error
)
return []
}
},
methods: backend.methods,
onAcpFrame: backend.onAcpFrame,
...(options.blobSink === undefined ||
options.agentExecutablePath === undefined
? {}
: {
onBlobFrame: backend.onBlobFrame,
authorizeBlobFrame: backend.authorizeBlobFrame
}),
dispose: async () => {
await backend.dispose()
if (options.ownerRegistry === undefined) {
ownerRegistry.close()
}
}
}
}
async function executableAvailable(path: string): Promise<boolean> {
try {
await access(path, constants.X_OK)
return true
} catch {
return false
}
}
async function resolveCurrentWorkspace(
workspaces: WorkspaceRegistry,
stateDirectory: string,
preparation: {
bindingId: string
runtimeBundleDigest: string
workspaceIdentity: string
},
context: ProtocolMethodContext
): Promise<{
workspaceIdentity: string
workspaceDirectory: string
scratchDirectory: string
bridgeDirectory?: string
}> {
const lease = await workspaces.getCurrentByIdentity(
preparation.workspaceIdentity,
context.controller,
{ signal: context.signal }
)
const scratchDirectory = derivePrivateRuntimeScratchDirectory(
stateDirectory,
{
bindingId: preparation.bindingId,
runtimeBundleDigest: preparation.runtimeBundleDigest,
workspaceIdentity: preparation.workspaceIdentity
}
)
const bridgeDirectory =
preparation.modelBridge === undefined
? undefined
: derivePrivateModelBridgeDirectory(
stateDirectory,
{
bindingId: preparation.bindingId,
runtimeBundleDigest:
preparation.runtimeBundleDigest,
workspaceIdentity:
preparation.workspaceIdentity
}
)
return {
workspaceIdentity: lease.handle.workspaceIdentity,
workspaceDirectory: lease.access.root.canonicalPath,
scratchDirectory,
...(bridgeDirectory === undefined
? {}
: { bridgeDirectory })
}
}
export function derivePrivateModelBridgeDirectory(
stateDirectoryInput: string,
binding: {
bindingId: string
runtimeBundleDigest: string
workspaceIdentity: string
controllerId?: string
controllerGeneration?: number
}
): string {
if (process.platform === 'linux') {
const stateDirectory = resolve(stateDirectoryInput)
ensurePrivateDirectory(stateDirectory, { create: false })
const userRoot = ensurePrivateTemporaryRoot(
derivePrivateTemporaryRoot()
)
const bridgeRoot = join(userRoot, 'mb')
ensurePrivateDirectoryTree(bridgeRoot, userRoot)
const bindingDirectory = join(
bridgeRoot,
createHash('sha256')
.update(
JSON.stringify([
'goodbuddy-model-bridge-v2',
stateDirectory,
binding.bindingId,
binding.runtimeBundleDigest,
binding.workspaceIdentity
])
)
.digest('hex')
.slice(0, 32)
)
ensurePrivateDirectoryTree(bindingDirectory, bridgeRoot)
return bindingDirectory
}
return derivePrivateBindingDirectory(
stateDirectoryInput,
'model-bridges',
'goodbuddy-model-bridge-v1',
binding
)
}
export function derivePrivateRuntimeScratchDirectory(
stateDirectoryInput: string,
binding: {
bindingId: string
runtimeBundleDigest: string
workspaceIdentity: string
controllerId?: string
controllerGeneration?: number
}
): string {
return derivePrivateBindingDirectory(
stateDirectoryInput,
'runtime-scratch',
'goodbuddy-runtime-scratch-v1',
binding
)
}
function derivePrivateBindingDirectory(
stateDirectoryInput: string,
rootName: 'model-bridges' | 'runtime-scratch',
digestDomain:
| 'goodbuddy-model-bridge-v1'
| 'goodbuddy-runtime-scratch-v1',
binding: {
bindingId: string
runtimeBundleDigest: string
workspaceIdentity: string
}
): string {
const stateDirectory = resolve(stateDirectoryInput)
ensurePrivateDirectory(stateDirectory, { create: false })
const bindingRoot = join(stateDirectory, rootName)
ensurePrivateDirectoryTree(bindingRoot, stateDirectory)
const bindingDigest = bindingDirectoryDigest(
digestDomain,
binding
)
const bindingDirectory = join(bindingRoot, bindingDigest)
ensurePrivateDirectoryTree(bindingDirectory, bindingRoot)
return bindingDirectory
}
function bindingDirectoryDigest(
digestDomain:
| 'goodbuddy-model-bridge-v1'
| 'goodbuddy-runtime-scratch-v1',
binding: {
bindingId: string
runtimeBundleDigest: string
workspaceIdentity: string
}
): string {
return createHash('sha256')
.update(JSON.stringify([
digestDomain,
binding.bindingId,
binding.runtimeBundleDigest,
binding.workspaceIdentity
]))
.digest('hex')
}
async function productionRuntimePrerequisitesAvailable(): Promise<boolean> {
try {
await access(BWRAP_EXECUTABLE, constants.X_OK)
return true
} catch {
return false
}
}
@@ -0,0 +1,68 @@
import { chmodSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { RuntimeOwnerRegistry } from './runtime-owner-registry'
const paths: string[] = []
afterEach(() => {
for (const path of paths.splice(0)) rmSync(path, { recursive: true, force: true })
})
describe('RuntimeOwnerRegistry', () => {
it('durably tracks only active owners per installation', () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owner-registry-'))
paths.push(root)
if (process.platform !== 'win32') chmodSync(root, 0o700)
const path = join(root, 'owners.sqlite')
const registry = new RuntimeOwnerRegistry(path, { now: () => 10 })
registry.reserve(fixture())
registry.markRunning('owner-1', identity())
registry.markStopping('owner-1')
registry.remove('owner-1', 'stopping')
expect(registry.listForInstallation('installation-1')).toEqual([])
expect(registry.listForInstallation('installation-2')).toEqual([])
expect(() => registry.markStopping('owner-1')).toThrow(/transition conflict/iu)
registry.close()
const reopened = new RuntimeOwnerRegistry(path)
expect(reopened.get('owner-1')).toBeUndefined()
reopened.close()
})
it('persists running owner identity across restarts', () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owner-registry-'))
paths.push(root)
if (process.platform !== 'win32') chmodSync(root, 0o700)
const registry = new RuntimeOwnerRegistry(join(root, 'owners.sqlite'))
registry.reserve(fixture())
registry.markRunning('owner-1', identity())
registry.close()
const reopened = new RuntimeOwnerRegistry(join(root, 'owners.sqlite'))
expect(reopened.get('owner-1')).toMatchObject({
state: 'running',
processIdentity: identity()
})
reopened.close()
})
})
function fixture() {
return {
ownerId: 'owner-1',
launchId: 'launch-1',
processId: 'process-1',
installationId: 'installation-1',
ownerToken: 'a'.repeat(32)
}
}
function identity() {
return {
bootId: '11111111-1111-1111-1111-111111111111',
pid: 42,
startTimeTicks: 100n,
processGroupId: 42,
executablePath: '/usr/bin/bwrap'
}
}
+232
View File
@@ -0,0 +1,232 @@
import type { DatabaseSync, StatementSync } from 'node:sqlite'
import { agentIdentifierSchema } from '../shared/agent-protocol/contracts'
import { openPrivateSqliteDatabase } from '../shared/node/private-sqlite-database'
import {
parseSerializedLinuxRuntimeProcessIdentity,
serializeLinuxRuntimeProcessIdentity,
type LinuxRuntimeProcessIdentity
} from './runtime-process-identity'
export type RuntimeOwnerState = 'reserved' | 'running' | 'stopping'
export type RuntimeOwnerRecord = {
ownerId: string
launchId: string
processId: string
installationId: string
ownerToken: string
state: RuntimeOwnerState
processIdentity?: LinuxRuntimeProcessIdentity
createdAt: number
updatedAt: number
}
type RuntimeOwnerRow = {
owner_id: string
launch_id: string
process_id: string
installation_id: string
owner_token: string
state: RuntimeOwnerState
process_identity_json: string | null
created_at: number
updated_at: number
}
export class RuntimeOwnerRegistry {
readonly #database: DatabaseSync
readonly #now: () => number
readonly #statements: {
insert: StatementSync
get: StatementSync
listInstallation: StatementSync
transition: StatementSync
remove: StatementSync
}
constructor(path: string, options: { now?: () => number } = {}) {
this.#now = options.now ?? Date.now
this.#database = openPrivateSqliteDatabase(path)
this.#database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS active_runtime_owners (
owner_id TEXT PRIMARY KEY,
launch_id TEXT NOT NULL UNIQUE,
process_id TEXT NOT NULL UNIQUE,
installation_id TEXT NOT NULL,
owner_token TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('reserved', 'running', 'stopping')),
process_identity_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS active_runtime_owners_installation
ON active_runtime_owners(installation_id, created_at);
`)
this.#migrateLegacyRecords()
this.#statements = {
insert: this.#database.prepare(`
INSERT INTO active_runtime_owners (
owner_id, launch_id, process_id, installation_id, owner_token,
state, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'reserved', ?, ?)
`),
get: this.#database.prepare(
'SELECT * FROM active_runtime_owners WHERE owner_id = ?'
),
listInstallation: this.#database.prepare(`
SELECT * FROM active_runtime_owners
WHERE installation_id = ?
ORDER BY created_at, owner_id
`),
transition: this.#database.prepare(`
UPDATE active_runtime_owners
SET state = ?, process_identity_json = ?, updated_at = ?
WHERE owner_id = ? AND state = ?
`),
remove: this.#database.prepare(`
DELETE FROM active_runtime_owners WHERE owner_id = ? AND state = ?
`)
}
}
reserve(input: Omit<
RuntimeOwnerRecord,
'state' | 'processIdentity' | 'createdAt' | 'updatedAt'
>): RuntimeOwnerRecord {
const record = validateReservation(input)
const now = this.#now()
this.#statements.insert.run(
record.ownerId,
record.launchId,
record.processId,
record.installationId,
record.ownerToken,
now,
now
)
return { ...record, state: 'reserved', createdAt: now, updatedAt: now }
}
get(ownerIdInput: string): RuntimeOwnerRecord | undefined {
const ownerId = agentIdentifierSchema.parse(ownerIdInput)
const row = this.#statements.get.get(ownerId) as RuntimeOwnerRow | undefined
return row === undefined ? undefined : decodeRow(row)
}
listForInstallation(installationIdInput: string): RuntimeOwnerRecord[] {
const installationId = agentIdentifierSchema.parse(installationIdInput)
return (
this.#statements.listInstallation.all(installationId) as RuntimeOwnerRow[]
).map(decodeRow)
}
markRunning(
ownerId: string,
identity: LinuxRuntimeProcessIdentity
): RuntimeOwnerRecord {
return this.#transition(ownerId, 'reserved', 'running', identity)
}
markStopping(ownerId: string): RuntimeOwnerRecord {
return this.#transition(ownerId, 'running', 'stopping')
}
remove(ownerIdInput: string, from: RuntimeOwnerState): void {
const ownerId = agentIdentifierSchema.parse(ownerIdInput)
if (this.#statements.remove.run(ownerId, from).changes !== 1) {
throw new Error(`Runtime owner transition conflict: expected ${from}`)
}
}
close(): void {
this.#database.close()
}
#transition(
ownerIdInput: string,
from: RuntimeOwnerState,
to: RuntimeOwnerState,
identity?: LinuxRuntimeProcessIdentity
): RuntimeOwnerRecord {
const ownerId = agentIdentifierSchema.parse(ownerIdInput)
const existing = this.get(ownerId)
if (existing === undefined || existing.state !== from) {
throw new Error(`Runtime owner transition conflict: expected ${from}`)
}
const processIdentity = identity ?? existing.processIdentity
if (
this.#statements.transition.run(
to,
processIdentity === undefined
? null
: serializeLinuxRuntimeProcessIdentity(processIdentity),
this.#now(),
ownerId,
from
).changes !== 1
) {
throw new Error('Runtime owner transition conflict')
}
return this.get(ownerId)!
}
#migrateLegacyRecords(): void {
const legacy = this.#database.prepare(`
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = 'runtime_process_owners'
`).get()
if (legacy === undefined) return
this.#database.exec(`
BEGIN IMMEDIATE;
INSERT OR IGNORE INTO active_runtime_owners (
owner_id, launch_id, process_id, installation_id, owner_token,
state, process_identity_json, created_at, updated_at
)
SELECT owner_id, launch_id, process_id, installation_id, owner_token,
state, process_identity_json, created_at, updated_at
FROM runtime_process_owners
WHERE state IN ('reserved', 'running', 'stopping');
DROP TABLE runtime_process_owners;
COMMIT;
`)
}
}
function validateReservation(
input: Omit<
RuntimeOwnerRecord,
'state' | 'processIdentity' | 'createdAt' | 'updatedAt'
>
): typeof input {
agentIdentifierSchema.parse(input.ownerId)
agentIdentifierSchema.parse(input.launchId)
agentIdentifierSchema.parse(input.processId)
agentIdentifierSchema.parse(input.installationId)
if (!/^[a-f0-9]{32}$/u.test(input.ownerToken)) {
throw new Error('Runtime owner token marker is malformed')
}
return { ...input }
}
function decodeRow(row: RuntimeOwnerRow): RuntimeOwnerRecord {
return {
ownerId: row.owner_id,
launchId: row.launch_id,
processId: row.process_id,
installationId: row.installation_id,
ownerToken: row.owner_token,
state: row.state,
...(row.process_identity_json === null
? {}
: {
processIdentity: parseSerializedLinuxRuntimeProcessIdentity(
row.process_identity_json
)
}),
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
parseLinuxProcStat,
parseSerializedLinuxRuntimeProcessIdentity,
sameLinuxRuntimeProcessIdentity,
serializeLinuxRuntimeProcessIdentity
} from './runtime-process-identity'
describe('Linux Runtime process identity', () => {
it('parses proc stat command names containing spaces and parentheses', () => {
const fields = ['S', '10', '20', '30', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '987654', '0']
const parsed = parseLinuxProcStat(`123 (bwrap worker (one)) ${fields.join(' ')}`)
expect(parsed).toMatchObject({
pid: 123,
command: 'bwrap worker (one)',
parentPid: 10,
processGroupId: 20,
sessionId: 30,
startTimeTicks: 987654n
})
})
it('round trips the minimal owner identity and detects PID reuse', () => {
const identity = {
bootId: '11111111-1111-1111-1111-111111111111',
pid: 42,
startTimeTicks: 100n,
processGroupId: 42,
executablePath: '/usr/bin/bwrap'
}
expect(
parseSerializedLinuxRuntimeProcessIdentity(
serializeLinuxRuntimeProcessIdentity(identity)
)
).toEqual(identity)
expect(
sameLinuxRuntimeProcessIdentity(identity, {
...identity,
startTimeTicks: 101n
})
).toBe(false)
})
})
@@ -0,0 +1,164 @@
import { readFile, readlink } from 'node:fs/promises'
import { posix } from 'node:path'
export type LinuxRuntimeProcessIdentity = {
bootId: string
pid: number
startTimeTicks: bigint
processGroupId: number
executablePath: string
}
export type LinuxProcStat = {
pid: number
command: string
state: string
parentPid: number
processGroupId: number
sessionId: number
startTimeTicks: bigint
}
export function parseLinuxProcStat(value: string): LinuxProcStat {
const open = value.indexOf('(')
const close = value.lastIndexOf(')')
if (open < 1 || close <= open || value[close + 1] !== ' ') {
throw new Error('Linux process stat is malformed')
}
const pid = positiveInteger(Number(value.slice(0, open).trim()), 'PID')
const command = value.slice(open + 1, close)
const fields = value.slice(close + 2).trim().split(/\s+/u)
if (fields.length < 20 || !/^[A-Z]$/u.test(fields[0] ?? '')) {
throw new Error('Linux process stat has too few fields')
}
const integerField = (index: number, label: string): number => {
const text = fields[index]
if (text === undefined || !/^-?\d+$/u.test(text)) {
throw new Error(`Linux process ${label} is malformed`)
}
const parsed = Number(text)
if (!Number.isSafeInteger(parsed)) {
throw new Error(`Linux process ${label} is outside the safe range`)
}
return parsed
}
const startTime = fields[19]
if (startTime === undefined || !/^\d+$/u.test(startTime)) {
throw new Error('Linux process start time is malformed')
}
return {
pid,
command,
state: fields[0]!,
parentPid: nonnegativeInteger(integerField(1, 'parent PID'), 'Parent PID'),
processGroupId: positiveInteger(integerField(2, 'process group'), 'Process group'),
sessionId: positiveInteger(integerField(3, 'session'), 'Session'),
startTimeTicks: BigInt(startTime)
}
}
export async function readLinuxBootId(
path = '/proc/sys/kernel/random/boot_id'
): Promise<string> {
const value = (await readFile(path, 'utf8')).trim().toLowerCase()
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(value)) {
throw new Error('Linux boot identity is malformed')
}
return value
}
export async function readLinuxRuntimeProcessIdentity(
pidInput: number,
options: { procRoot?: string; bootId?: string } = {}
): Promise<LinuxRuntimeProcessIdentity> {
const pid = positiveInteger(pidInput, 'PID')
const procRoot = options.procRoot ?? '/proc'
if (!posix.isAbsolute(procRoot) || procRoot.includes('\0')) {
throw new Error('Linux proc root must be absolute')
}
const processRoot = posix.join(procRoot, String(pid))
const [statText, executablePath] = await Promise.all([
readFile(posix.join(processRoot, 'stat'), 'utf8'),
readlink(posix.join(processRoot, 'exe'))
])
const parsed = parseLinuxProcStat(statText)
if (parsed.pid !== pid) {
throw new Error('Linux process stat PID does not match its path')
}
return {
bootId:
options.bootId ??
await readLinuxBootId(
posix.join(procRoot, 'sys', 'kernel', 'random', 'boot_id')
),
pid,
startTimeTicks: parsed.startTimeTicks,
processGroupId: parsed.processGroupId,
executablePath
}
}
export function sameLinuxRuntimeProcessIdentity(
expected: LinuxRuntimeProcessIdentity,
actual: LinuxRuntimeProcessIdentity
): boolean {
return (
expected.bootId === actual.bootId &&
expected.pid === actual.pid &&
expected.startTimeTicks === actual.startTimeTicks &&
expected.processGroupId === actual.processGroupId &&
expected.executablePath === actual.executablePath
)
}
export function serializeLinuxRuntimeProcessIdentity(
identity: LinuxRuntimeProcessIdentity
): string {
return JSON.stringify({
...identity,
startTimeTicks: identity.startTimeTicks.toString()
})
}
export function parseSerializedLinuxRuntimeProcessIdentity(
value: string
): LinuxRuntimeProcessIdentity {
const parsed: unknown = JSON.parse(value)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Stored Runtime process identity is malformed')
}
const row = parsed as Record<string, unknown>
if (
typeof row.bootId !== 'string' ||
!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(row.bootId) ||
typeof row.pid !== 'number' ||
typeof row.processGroupId !== 'number' ||
typeof row.startTimeTicks !== 'string' ||
!/^\d+$/u.test(row.startTimeTicks) ||
typeof row.executablePath !== 'string' ||
!posix.isAbsolute(row.executablePath)
) {
throw new Error('Stored Runtime process identity is malformed')
}
return {
bootId: row.bootId,
pid: positiveInteger(row.pid, 'PID'),
startTimeTicks: BigInt(row.startTimeTicks),
processGroupId: positiveInteger(row.processGroupId, 'Process group'),
executablePath: row.executablePath
}
}
function positiveInteger(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive safe integer`)
}
return value
}
function nonnegativeInteger(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`${label} must be a nonnegative safe integer`)
}
return value
}
@@ -0,0 +1,182 @@
import { spawn } from 'node:child_process'
export type SpawnResult = {
exitCode: number
stdout: string
stderr: string
signal?: NodeJS.Signals
terminationReason?: 'timeout' | 'aborted'
}
export type StructuredCommandOptions = {
env?: Readonly<NodeJS.ProcessEnv>
signal?: AbortSignal
timeoutMs?: number
terminationGraceMs?: number
}
export interface StructuredCommandRunner {
run(
executable: string,
args: readonly string[],
options?: StructuredCommandOptions
): Promise<SpawnResult>
}
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000
const MAXIMUM_COMMAND_TIMEOUT_MS = 120_000
const DEFAULT_TERMINATION_GRACE_MS = 1_000
const MAXIMUM_TERMINATION_GRACE_MS = 10_000
export class NodeStructuredCommandRunner implements StructuredCommandRunner {
async run(
executable: string,
args: readonly string[],
options: StructuredCommandOptions = {}
): Promise<SpawnResult> {
const timeoutMs = boundedDuration(
options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
'Command timeout',
MAXIMUM_COMMAND_TIMEOUT_MS
)
const terminationGraceMs = boundedDuration(
options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS,
'Command termination grace',
MAXIMUM_TERMINATION_GRACE_MS
)
if (options.signal?.aborted === true) {
return {
exitCode: -1,
stdout: '',
stderr: '',
terminationReason: 'aborted'
}
}
return await new Promise<SpawnResult>((resolveResult) => {
const child = spawn(executable, [...args], {
shell: false,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
...(options.env === undefined ? {} : { env: options.env })
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
let stdoutBytes = 0
let stderrBytes = 0
const maximumBytes = 64 * 1024
let settled = false
let terminationReason: SpawnResult['terminationReason']
const timeout = setTimeout(() => terminate('timeout'), timeoutMs)
let escalation: NodeJS.Timeout | undefined
let closeDeadline: NodeJS.Timeout | undefined
let spawnError: Error | undefined
child.stdout.on('data', (chunk: Buffer) => {
if (stdoutBytes < maximumBytes) {
const bounded = chunk.subarray(0, maximumBytes - stdoutBytes)
stdout.push(bounded)
stdoutBytes += bounded.byteLength
}
})
child.stderr.on('data', (chunk: Buffer) => {
if (stderrBytes < maximumBytes) {
const bounded = chunk.subarray(0, maximumBytes - stderrBytes)
stderr.push(bounded)
stderrBytes += bounded.byteLength
}
})
const finish = (
exitCode: number | null,
signal: NodeJS.Signals | null
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (escalation !== undefined) {
clearTimeout(escalation)
}
if (closeDeadline !== undefined) {
clearTimeout(closeDeadline)
}
options.signal?.removeEventListener('abort', abort)
const capturedStderr = Buffer.concat(stderr).toString('utf8')
resolveResult({
exitCode: exitCode ?? -1,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr:
capturedStderr ||
(spawnError === undefined ? '' : spawnError.message),
...(signal === null ? {} : { signal }),
...(terminationReason === undefined ? {} : { terminationReason })
})
}
const terminate = (
reason: NonNullable<SpawnResult['terminationReason']>
): void => {
if (settled || terminationReason !== undefined) {
return
}
terminationReason = reason
if (child.exitCode !== null || child.signalCode !== null) {
return
}
child.kill('SIGTERM')
escalation = setTimeout(() => {
if (
settled ||
child.exitCode !== null ||
child.signalCode !== null
) {
return
}
child.kill('SIGKILL')
closeDeadline = setTimeout(
() => finish(child.exitCode, child.signalCode),
terminationGraceMs
)
}, terminationGraceMs)
}
const abort = (): void => terminate('aborted')
child.once('error', (error) => {
spawnError = error
})
child.once('close', finish)
options.signal?.addEventListener('abort', abort, { once: true })
if (options.signal?.aborted === true) {
abort()
}
})
}
}
export function formatSpawnFailure(
command: string,
result: SpawnResult
): string {
const status =
result.terminationReason === 'timeout'
? 'timed out'
: result.terminationReason === 'aborted'
? 'was cancelled'
: result.signal !== undefined
? `terminated by ${result.signal}`
: `exited with status ${result.exitCode}`
return `${command} ${status}: ${boundedError(result.stderr)}`
}
function boundedDuration(
value: number,
name: string,
maximum: number
): number {
if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) {
throw new Error(`${name} must be between 1 and ${maximum} milliseconds`)
}
return value
}
function boundedError(error: string): string {
const normalized = error.replaceAll(/\s+/gu, ' ').trim()
return normalized.slice(0, 1000) || 'unknown error'
}
@@ -0,0 +1,160 @@
import { execFileSync } from 'node:child_process'
import {
appendFileSync,
mkdirSync,
mkdtempSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
parseGitConfigListOutput,
resolveVerifiedGitExecutable,
WorkspaceGitService
} from './workspace-git-service'
import { WorkspacePathAccess } from './workspace-path-access'
const temporaryPaths: string[] = []
const linuxIt = process.platform === 'linux' ? it : it.skip
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('WorkspaceGitService', () => {
it('parses Git null-delimited key-newline-value records', () => {
expect(
parseGitConfigListOutput(
Buffer.from(
'core.bare\nfalse\0user.name\nGoodBuddy Test\0section.empty\n\0'
)
)
).toEqual([
['core.bare', 'false'],
['user.name', 'GoodBuddy Test'],
['section.empty', '']
])
})
it('rejects malformed Git null-delimited configuration records', () => {
expect(() =>
parseGitConfigListOutput(
Buffer.from('core.bare\0')
)
).toThrow('Git configuration output is malformed')
})
linuxIt('returns bounded read-only status and diff snapshots', async () => {
const root = temporaryDirectory()
const executable = await resolveVerifiedGitExecutable()
git(executable, root, 'init', '--quiet')
git(executable, root, 'config', 'user.name', 'GoodBuddy Test')
git(executable, root, 'config', 'user.email', 'test@example.invalid')
writeFileSync(join(root, 'tracked.txt'), 'before\n')
git(executable, root, 'add', 'tracked.txt')
git(executable, root, 'commit', '--quiet', '-m', 'initial')
writeFileSync(join(root, 'tracked.txt'), 'after\n')
writeFileSync(join(root, 'untracked.txt'), 'new\n')
const access = accessFor(root)
const service = new WorkspaceGitService({ gitExecutable: executable })
await expect(service.inspect(access)).resolves.toBe('available')
const status = await service.status(access, {
workspaceId: 'workspace-a',
generation: 1,
includeIgnored: false,
maximumEntries: 10
})
expect(status.repositoryIdentity).toMatch(/^repository-[a-f0-9]{64}$/u)
expect(status.entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
relativePath: 'tracked.txt',
worktree: 'modified'
}),
expect.objectContaining({
relativePath: 'untracked.txt',
index: 'untracked',
worktree: 'untracked'
})
])
)
const first = await service.diff(access, {
workspaceId: 'workspace-a',
generation: 1,
staged: false,
maximumBytes: 20
})
expect(Buffer.byteLength(first.patch)).toBeLessThanOrEqual(20)
expect(first.truncated).toBe(true)
expect(first.nextCursor).toBeDefined()
const second = await service.diff(access, {
workspaceId: 'workspace-a',
generation: 1,
staged: false,
cursor: first.nextCursor,
maximumBytes: 4 * 1024
})
expect(`${first.patch}${second.patch}`).toContain('-before')
expect(`${first.patch}${second.patch}`).toContain('+after')
})
linuxIt('rejects includes, worktrees, and object alternates', async () => {
const executable = await resolveVerifiedGitExecutable()
const unsafeConfigRoot = temporaryDirectory()
git(executable, unsafeConfigRoot, 'init', '--quiet')
appendFileSync(
join(unsafeConfigRoot, '.git', 'config'),
'\n[include]\n\tpath = /tmp/not-allowed\n'
)
const service = new WorkspaceGitService({ gitExecutable: executable })
await expect(service.inspect(accessFor(unsafeConfigRoot))).rejects.toMatchObject({
code: 'git-unsafe'
})
const alternateRoot = temporaryDirectory()
git(executable, alternateRoot, 'init', '--quiet')
const info = join(alternateRoot, '.git', 'objects', 'info')
mkdirSync(info, { recursive: true })
writeFileSync(join(info, 'alternates'), '/tmp/not-allowed\n')
await expect(service.inspect(accessFor(alternateRoot))).rejects.toMatchObject({
code: 'git-unsafe'
})
})
})
function git(executable: string, cwd: string, ...args: string[]): void {
execFileSync(executable, args, {
cwd,
env: {
HOME: cwd,
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
GIT_CONFIG_NOSYSTEM: '1',
GIT_TERMINAL_PROMPT: '0'
},
stdio: 'ignore'
})
}
function temporaryDirectory(): string {
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-git-'))
temporaryPaths.push(path)
return path
}
function accessFor(root: string): WorkspacePathAccess {
const metadata = statSync(root)
return new WorkspacePathAccess({
canonicalPath: root,
device: metadata.dev.toString(),
inode: metadata.ino.toString(),
workspaceIdentity: 'workspace-identity-test'
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
import {
mkdirSync,
mkdtempSync,
renameSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
WorkspacePathAccess,
WorkspaceServiceError
} from './workspace-path-access'
const temporaryPaths: string[] = []
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('WorkspacePathAccess', () => {
it('lists deterministically and reads bounded UTF-8 with a digest', async () => {
const root = temporaryDirectory()
mkdirSync(join(root, 'directory'))
writeFileSync(join(root, 'z.txt'), 'zero\nneedle here\n')
writeFileSync(join(root, 'a.txt'), 'alpha')
const service = accessFor(root)
const page = await service.list({
workspaceId: 'workspace-a',
generation: 1,
relativePath: '',
limit: 2
})
expect(page.entries.map((entry) => entry.name)).toEqual([
'directory',
'a.txt'
])
expect(page.nextCursor).toBe('2')
const secondPage = await service.list({
workspaceId: 'workspace-a',
generation: 1,
relativePath: '',
cursor: page.nextCursor,
limit: 2
})
expect(secondPage.entries.map((entry) => entry.name)).toEqual(['z.txt'])
const read = await service.readText({
workspaceId: 'workspace-a',
generation: 1,
relativePath: 'z.txt',
offsetBytes: 0,
maximumBytes: 5
})
expect(read).toMatchObject({
content: 'zero\n',
bytesRead: 5,
totalBytes: 17,
truncated: true
})
expect(read.digest).toMatch(/^sha256:[a-f0-9]{64}$/u)
await expect(
service.readText({
workspaceId: 'workspace-a',
generation: 1,
relativePath: 'z.txt',
offsetBytes: 0,
maximumBytes: 5,
expectedDigest: `sha256:${'0'.repeat(64)}`
})
).rejects.toMatchObject({ code: 'digest-mismatch' })
})
it('searches with result cursors and does not follow links', async () => {
const root = temporaryDirectory()
mkdirSync(join(root, 'nested'))
writeFileSync(join(root, 'nested', 'one.txt'), 'needle one\nneedle two')
const external = temporaryDirectory()
writeFileSync(join(external, 'secret.txt'), 'needle secret')
symlinkSync(
external,
join(root, 'linked'),
process.platform === 'win32' ? 'junction' : 'dir'
)
const service = accessFor(root)
const first = await service.search({
workspaceId: 'workspace-a',
generation: 1,
query: 'needle',
caseSensitive: true,
limit: 1
})
expect(first.matches).toHaveLength(1)
expect(first.matches[0]?.relativePath).toBe('nested/one.txt')
expect(first.nextCursor).toBe('1')
expect(first.truncated).toBe(true)
await expect(service.stat('linked/secret.txt')).rejects.toMatchObject({
code: 'symlink-rejected'
})
})
it('rejects cancellation and stale root replacement', async () => {
const root = temporaryDirectory()
writeFileSync(join(root, 'file.txt'), 'value')
const service = accessFor(root)
const controller = new AbortController()
controller.abort()
await expect(
service.stat('file.txt', { signal: controller.signal })
).rejects.toMatchObject({ code: 'aborted' })
await expect(
service.stat('file.txt', { deadlineAt: Date.now() - 1 })
).rejects.toMatchObject({ code: 'deadline-exceeded' })
const moved = `${root}-moved`
renameSync(root, moved)
temporaryPaths.push(moved)
mkdirSync(root)
await expect(service.stat('file.txt')).rejects.toBeInstanceOf(
WorkspaceServiceError
)
await expect(service.stat('file.txt')).rejects.toMatchObject({
code: 'stale-workspace'
})
})
})
function temporaryDirectory(): string {
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-workspace-'))
temporaryPaths.push(path)
return path
}
function accessFor(root: string): WorkspacePathAccess {
const metadata = statSync(root)
return new WorkspacePathAccess({
canonicalPath: root,
device: metadata.dev.toString(),
inode: metadata.ino.toString(),
workspaceIdentity: 'workspace-identity-test'
})
}
+765
View File
@@ -0,0 +1,765 @@
import { createHash } from 'node:crypto'
import {
constants,
type Dirent,
type Stats
} from 'node:fs'
import {
access,
lstat,
open,
opendir,
realpath,
stat
} from 'node:fs/promises'
import {
basename,
isAbsolute,
join,
relative
} from 'node:path'
import {
REMOTE_WORKSPACE_LIMITS,
remoteAbsolutePathSchema,
remoteRelativePathSchema,
type RemoteWorkspaceEntry,
type RemoteWorkspaceListRequest,
type RemoteWorkspaceListResult,
type RemoteWorkspaceReadTextRequest,
type RemoteWorkspaceReadTextResult,
type RemoteWorkspaceSearchRequest,
type RemoteWorkspaceSearchResult,
type RemoteWorkspaceStatResult
} from '../shared/remote-agent-contracts'
export const WORKSPACE_SEARCH_LIMITS = {
maximumEntries: 100_000,
maximumDepth: 64,
maximumScannedBytes: 64 * 1024 * 1024,
maximumFileBytes: REMOTE_WORKSPACE_LIMITS.maximumReadBytes
} as const
const READ_BUFFER_BYTES = 64 * 1024
const SEARCH_DIRECTORY_PAGE = 1_000
const MAXIMUM_READABLE_FILE_BYTES = 64 * 1024 * 1024
const noFollow = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
export type WorkspaceRootIdentity = {
canonicalPath: string
device: string
inode: string
workspaceIdentity: string
}
export type WorkspaceIoOptions = {
signal?: AbortSignal
deadlineAt?: number
}
export class WorkspaceServiceError extends Error {
constructor(
message: string,
readonly code:
| 'aborted'
| 'capacity-exceeded'
| 'deadline-exceeded'
| 'digest-mismatch'
| 'git-failed'
| 'git-unsafe'
| 'git-unavailable'
| 'invalid-path'
| 'invalid-utf8'
| 'not-directory'
| 'not-owner'
| 'read-only'
| 'special-file'
| 'stale-generation'
| 'stale-workspace'
| 'symlink-rejected'
| 'unavailable'
| 'workspace-not-found'
) {
super(message)
this.name = 'WorkspaceServiceError'
}
}
export async function inspectWorkspaceRoot(
rootPathInput: string,
options: WorkspaceIoOptions = {}
): Promise<WorkspaceRootIdentity> {
checkCancelled(options)
const rootPath = remoteAbsolutePathSchema.parse(rootPathInput)
await assertSafeAbsoluteRootPath(rootPath, options)
const rootMetadata = await lstat(rootPath)
checkCancelled(options)
if (rootMetadata.isSymbolicLink()) {
throw new WorkspaceServiceError(
'Workspace root cannot be a symbolic link',
'symlink-rejected'
)
}
if (!rootMetadata.isDirectory()) {
throw new WorkspaceServiceError(
'Workspace root is not a directory',
'not-directory'
)
}
const canonicalPath = await realpath(rootPath)
if (!isAbsolute(canonicalPath)) {
throw new WorkspaceServiceError(
'Workspace root did not resolve to an absolute path',
'invalid-path'
)
}
const canonicalMetadata = await stat(canonicalPath)
if (
!canonicalMetadata.isDirectory() ||
!sameFileIdentity(rootMetadata, canonicalMetadata)
) {
throw new WorkspaceServiceError(
'Workspace root changed while it was validated',
'stale-workspace'
)
}
await access(
canonicalPath,
constants.R_OK | constants.X_OK
)
const device = canonicalMetadata.dev.toString()
const inode = canonicalMetadata.ino.toString()
return {
canonicalPath,
device,
inode,
workspaceIdentity: `workspace-${createHash('sha256')
.update(`linux\0${device}\0${inode}`, 'utf8')
.digest('hex')}`
}
}
async function assertSafeAbsoluteRootPath(
rootPath: string,
options: WorkspaceIoOptions
): Promise<void> {
const rawSegments = rootPath.split('/')
const segments = rawSegments.slice(1)
if (segments.at(-1) === '') {
segments.pop()
}
if (
segments.some(
(segment) =>
segment === '' ||
segment === '.' ||
segment === '..' ||
Buffer.byteLength(segment, 'utf8') >
REMOTE_WORKSPACE_LIMITS.maximumPathSegmentBytes
)
) {
throw new WorkspaceServiceError(
'Workspace root contains traversal or empty path segments',
'invalid-path'
)
}
let current = '/'
for (const segment of segments) {
checkCancelled(options)
current = join(current, segment)
const metadata = await lstat(current)
if (metadata.isSymbolicLink()) {
throw new WorkspaceServiceError(
'Workspace root path cannot contain symbolic links',
'symlink-rejected'
)
}
if (!metadata.isDirectory()) {
throw new WorkspaceServiceError(
'Workspace root path segment is not a directory',
'not-directory'
)
}
}
}
export class WorkspacePathAccess {
readonly root: WorkspaceRootIdentity
constructor(root: WorkspaceRootIdentity) {
this.root = { ...root }
}
async assertCurrent(options: WorkspaceIoOptions = {}): Promise<void> {
checkCancelled(options)
await assertSafeAbsoluteRootPath(
this.root.canonicalPath,
options
)
let metadata: Stats
try {
metadata = await lstat(this.root.canonicalPath)
} catch (error) {
throw staleRoot(error)
}
checkCancelled(options)
if (
metadata.isSymbolicLink() ||
!metadata.isDirectory() ||
metadata.dev.toString() !== this.root.device ||
metadata.ino.toString() !== this.root.inode
) {
throw new WorkspaceServiceError(
'Workspace root identity is stale',
'stale-workspace'
)
}
}
async resolveDirectory(
relativePathInput: string,
options: WorkspaceIoOptions = {}
): Promise<string> {
return await this.#resolveExisting(
relativePathInput,
'directory',
options
)
}
async list(
request: RemoteWorkspaceListRequest,
options: WorkspaceIoOptions = {}
): Promise<RemoteWorkspaceListResult> {
const directoryPath = await this.resolveDirectory(
request.relativePath,
options
)
const entries: Dirent[] = []
const directory = await opendir(directoryPath)
try {
for await (const entry of directory) {
checkCancelled(options)
entries.push(entry)
if (
entries.length >
REMOTE_WORKSPACE_LIMITS.maximumDirectoryEntriesPerPage * 100
) {
throw new WorkspaceServiceError(
'Directory exceeds the bounded enumeration limit',
'capacity-exceeded'
)
}
}
} finally {
await directory.close().catch(() => undefined)
}
entries.sort(compareDirectoryEntries)
const offset = parseCursor(request.cursor)
if (offset > entries.length) {
throw new WorkspaceServiceError(
'Directory cursor is no longer valid',
'stale-generation'
)
}
const selected = entries.slice(offset, offset + request.limit)
const resultEntries: RemoteWorkspaceEntry[] = []
for (const entry of selected) {
checkCancelled(options)
const relativePath = appendRelative(request.relativePath, entry.name)
const metadata = await lstat(join(directoryPath, entry.name))
resultEntries.push(entryFromMetadata(relativePath, entry.name, metadata))
}
const nextOffset = offset + selected.length
return {
entries: resultEntries,
...(nextOffset < entries.length
? { nextCursor: String(nextOffset) }
: {})
}
}
async stat(
relativePathInput: string,
options: WorkspaceIoOptions = {}
): Promise<RemoteWorkspaceStatResult> {
const relativePath = remoteRelativePathSchema.parse(relativePathInput)
const candidate = await this.#resolveExisting(
relativePath,
'entry',
options
)
const metadata = await lstat(candidate)
rejectUnsafeMetadata(metadata)
return entryFromMetadata(
relativePath,
relativePath === ''
? basename(this.root.canonicalPath) || '/'
: basename(candidate),
metadata
)
}
async readText(
request: RemoteWorkspaceReadTextRequest,
options: WorkspaceIoOptions = {}
): Promise<RemoteWorkspaceReadTextResult> {
const relativePath = remoteRelativePathSchema.parse(request.relativePath)
if (relativePath === '') {
throw new WorkspaceServiceError(
'Workspace root is not a regular file',
'special-file'
)
}
const filePath = await this.#resolveExisting(
relativePath,
'file',
options
)
const handle = await open(filePath, noFollow)
try {
const before = await handle.stat()
if (!before.isFile()) {
throw new WorkspaceServiceError(
'Workspace read target is not a regular file',
'special-file'
)
}
if (
request.offsetBytes > before.size ||
before.size > Number.MAX_SAFE_INTEGER
) {
throw new WorkspaceServiceError(
'Workspace read range is invalid',
'invalid-path'
)
}
if (before.size > MAXIMUM_READABLE_FILE_BYTES) {
throw new WorkspaceServiceError(
'Workspace file exceeds the bounded read limit',
'capacity-exceeded'
)
}
const digest = createHash('sha256')
const chunks: Buffer[] = []
let position = 0
let selectedBytes = 0
while (position < before.size) {
checkCancelled(options)
const buffer = Buffer.allocUnsafe(
Math.min(READ_BUFFER_BYTES, before.size - position)
)
const result = await handle.read(
buffer,
0,
buffer.byteLength,
position
)
if (result.bytesRead === 0) {
break
}
const chunk = buffer.subarray(0, result.bytesRead)
digest.update(chunk)
const chunkStart = position
const chunkEnd = position + result.bytesRead
const selectionStart = Math.max(request.offsetBytes, chunkStart)
const selectionEnd = Math.min(
request.offsetBytes + request.maximumBytes,
chunkEnd
)
if (selectionStart < selectionEnd) {
const selected = chunk.subarray(
selectionStart - chunkStart,
selectionEnd - chunkStart
)
chunks.push(selected)
selectedBytes += selected.byteLength
}
position = chunkEnd
}
const after = await handle.stat()
if (
position !== before.size ||
!sameSnapshot(before, after)
) {
throw new WorkspaceServiceError(
'Workspace file changed while it was read',
'stale-workspace'
)
}
const contentBuffer = Buffer.concat(chunks, selectedBytes)
const content = decodeUtf8(contentBuffer)
const contentDigest = `sha256:${digest.digest('hex')}`
if (
request.expectedDigest !== undefined &&
request.expectedDigest !== contentDigest
) {
throw new WorkspaceServiceError(
'Workspace file digest does not match',
'digest-mismatch'
)
}
return {
relativePath,
content,
offsetBytes: request.offsetBytes,
bytesRead: contentBuffer.byteLength,
totalBytes: before.size,
digest: contentDigest,
truncated:
request.offsetBytes + contentBuffer.byteLength < before.size
}
} finally {
await handle.close()
}
}
async search(
request: RemoteWorkspaceSearchRequest,
options: WorkspaceIoOptions = {}
): Promise<RemoteWorkspaceSearchResult> {
const prefix = request.pathPrefix ?? ''
await this.resolveDirectory(prefix, options)
const query = request.caseSensitive
? request.query
: request.query.toLocaleLowerCase('en-US')
const cursor = parseCursor(request.cursor)
const matches: RemoteWorkspaceSearchResult['matches'] = []
const queue: Array<{ relativePath: string; depth: number }> = [
{ relativePath: prefix, depth: 0 }
]
let visitedEntries = 0
let scannedBytes = 0
let seenMatches = 0
let truncated = false
while (queue.length > 0 && !truncated) {
checkCancelled(options)
const current = queue.shift()!
if (current.depth > WORKSPACE_SEARCH_LIMITS.maximumDepth) {
truncated = true
break
}
const page = await this.list(
{
workspaceId: request.workspaceId,
generation: request.generation,
relativePath: current.relativePath,
limit: SEARCH_DIRECTORY_PAGE
},
options
)
for (const entry of page.entries) {
visitedEntries += 1
if (visitedEntries > WORKSPACE_SEARCH_LIMITS.maximumEntries) {
truncated = true
break
}
if (entry.kind === 'directory') {
if (current.depth >= WORKSPACE_SEARCH_LIMITS.maximumDepth) {
truncated = true
break
}
queue.push({
relativePath: entry.relativePath,
depth: current.depth + 1
})
continue
}
if (entry.kind !== 'file') {
continue
}
const size = entry.byteLength ?? 0
if (
size > WORKSPACE_SEARCH_LIMITS.maximumFileBytes ||
scannedBytes + size >
WORKSPACE_SEARCH_LIMITS.maximumScannedBytes
) {
truncated = true
break
}
scannedBytes += size
let text: string
try {
text = (
await this.readText(
{
workspaceId: request.workspaceId,
generation: request.generation,
relativePath: entry.relativePath,
offsetBytes: 0,
maximumBytes: Math.max(1, size)
},
options
)
).content
} catch (error) {
if (
error instanceof WorkspaceServiceError &&
(error.code === 'invalid-utf8' ||
error.code === 'special-file')
) {
continue
}
throw error
}
const lines = text.split(/\r?\n/u)
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex]!
const searchable = request.caseSensitive
? line
: line.toLocaleLowerCase('en-US')
let from = 0
while (from <= searchable.length) {
const column = searchable.indexOf(query, from)
if (column < 0) {
break
}
if (seenMatches >= cursor) {
matches.push({
relativePath: entry.relativePath,
line: lineIndex + 1,
column: column + 1,
snippet: boundedUtf8Snippet(line)
})
}
seenMatches += 1
if (matches.length > request.limit) {
truncated = true
break
}
from = column + Math.max(1, query.length)
}
if (truncated) {
break
}
}
if (truncated) {
break
}
}
if (page.nextCursor !== undefined) {
truncated = true
}
}
const hasNextPage = matches.length > request.limit
if (hasNextPage) {
matches.length = request.limit
}
return {
matches,
...((hasNextPage || truncated) && matches.length > 0
? { nextCursor: String(cursor + matches.length) }
: {}),
truncated: hasNextPage || truncated
}
}
async #resolveExisting(
relativePathInput: string,
expected: 'directory' | 'entry' | 'file',
options: WorkspaceIoOptions
): Promise<string> {
const relativePath = remoteRelativePathSchema.parse(relativePathInput)
await this.assertCurrent(options)
if (relativePath === '') {
if (expected === 'file') {
throw new WorkspaceServiceError(
'Workspace root is not a regular file',
'special-file'
)
}
return this.root.canonicalPath
}
let current = this.root.canonicalPath
const segments = relativePath.split('/')
for (let index = 0; index < segments.length; index += 1) {
checkCancelled(options)
current = join(current, segments[index]!)
const difference = relative(this.root.canonicalPath, current)
if (
difference.startsWith('..') ||
isAbsolute(difference)
) {
throw new WorkspaceServiceError(
'Workspace path escapes its root',
'invalid-path'
)
}
const metadata = await lstat(current)
if (metadata.isSymbolicLink()) {
throw new WorkspaceServiceError(
'Workspace paths cannot contain symbolic links',
'symlink-rejected'
)
}
const final = index === segments.length - 1
if (!final && !metadata.isDirectory()) {
throw new WorkspaceServiceError(
'Workspace path segment is not a directory',
'not-directory'
)
}
if (final) {
if (expected === 'directory' && !metadata.isDirectory()) {
throw new WorkspaceServiceError(
'Workspace target is not a directory',
'not-directory'
)
}
if (expected === 'file' && !metadata.isFile()) {
throw new WorkspaceServiceError(
'Workspace target is not a regular file',
'special-file'
)
}
if (expected === 'entry') {
rejectUnsafeMetadata(metadata)
}
}
}
const canonical = await realpath(current)
const difference = relative(this.root.canonicalPath, canonical)
if (difference.startsWith('..') || isAbsolute(difference)) {
throw new WorkspaceServiceError(
'Workspace path resolved outside its root',
'symlink-rejected'
)
}
return canonical
}
}
export function checkCancelled(options: WorkspaceIoOptions): void {
if (
options.deadlineAt !== undefined &&
Date.now() >= options.deadlineAt
) {
throw new WorkspaceServiceError(
'Workspace operation deadline exceeded',
'deadline-exceeded'
)
}
if (options.signal?.aborted === true) {
throw new WorkspaceServiceError(
'Workspace operation was aborted',
'aborted'
)
}
}
function staleRoot(cause: unknown): WorkspaceServiceError {
return new WorkspaceServiceError(
cause instanceof Error
? `Workspace root is stale: ${cause.message}`
: 'Workspace root is stale',
'stale-workspace'
)
}
function rejectUnsafeMetadata(metadata: Stats): void {
if (metadata.isSymbolicLink()) {
throw new WorkspaceServiceError(
'Symbolic links are not workspace entries',
'symlink-rejected'
)
}
if (!metadata.isFile() && !metadata.isDirectory()) {
throw new WorkspaceServiceError(
'Special files are not supported workspace entries',
'special-file'
)
}
}
function sameFileIdentity(left: Stats, right: Stats): boolean {
return left.dev === right.dev && left.ino === right.ino
}
function sameSnapshot(left: Stats, right: Stats): boolean {
return (
sameFileIdentity(left, right) &&
left.size === right.size &&
left.mtimeMs === right.mtimeMs &&
left.ctimeMs === right.ctimeMs
)
}
function entryFromMetadata(
relativePath: string,
name: string,
metadata: Stats
): RemoteWorkspaceEntry {
const kind = metadata.isSymbolicLink()
? 'symlink'
: metadata.isDirectory()
? 'directory'
: metadata.isFile()
? 'file'
: 'other'
return {
relativePath,
name,
kind,
...(metadata.isFile() ? { byteLength: metadata.size } : {}),
modifiedAt: metadata.mtime.toISOString(),
executable: metadata.isFile() && (metadata.mode & 0o111) !== 0
}
}
function compareDirectoryEntries(left: Dirent, right: Dirent): number {
const leftDirectory = left.isDirectory() && !left.isSymbolicLink()
const rightDirectory = right.isDirectory() && !right.isSymbolicLink()
if (leftDirectory !== rightDirectory) {
return leftDirectory ? -1 : 1
}
return Buffer.from(left.name).compare(Buffer.from(right.name))
}
function parseCursor(value: string | undefined): number {
if (value === undefined) {
return 0
}
const cursor = Number(value)
if (!Number.isSafeInteger(cursor) || cursor < 0) {
throw new WorkspaceServiceError(
'Workspace cursor is invalid',
'invalid-path'
)
}
return cursor
}
function appendRelative(parent: string, name: string): string {
return remoteRelativePathSchema.parse(parent === '' ? name : `${parent}/${name}`)
}
function decodeUtf8(value: Uint8Array): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(value)
} catch (error) {
throw new WorkspaceServiceError(
error instanceof Error
? `Workspace file is not valid UTF-8: ${error.message}`
: 'Workspace file is not valid UTF-8',
'invalid-utf8'
)
}
}
function boundedUtf8Snippet(line: string): string {
const maximum = REMOTE_WORKSPACE_LIMITS.maximumSearchSnippetBytes
const encoded = Buffer.from(line, 'utf8')
if (encoded.byteLength <= maximum) {
return line
}
let end = maximum
while (end > 0) {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(
encoded.subarray(0, end)
)
} catch {
end -= 1
}
}
return ''
}
@@ -0,0 +1,133 @@
import {
mkdtempSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ControllerRegistry } from './controller-registry'
import { WorkspaceGitService } from './workspace-git-service'
import { createWorkspaceProtocolMethods } from './workspace-protocol-methods'
import { WorkspaceRegistry } from './workspace-registry'
const temporaryPaths: string[] = []
const linuxIt = process.platform === 'linux' ? it : it.skip
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('workspace protocol methods', () => {
linuxIt('registers typed reads and typed read-only failures', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-methods-'))
temporaryPaths.push(root)
writeFileSync(join(root, 'hello.txt'), 'hello')
const controllers = new ControllerRegistry()
const controller = controllers.attach('controller-test')
const git = new WorkspaceGitService({
gitExecutable: '/bin/false'
})
const workspaces = new WorkspaceRegistry({
controllers,
inspectGit: async () => 'not-a-repository'
})
const methods = createWorkspaceProtocolMethods({
workspaces,
git,
requestTimeoutMs: 1_000
})
const context = { controller, channelId: 'channel-test' }
const validated = (await methods['workspace/validate'](
{
remoteRootPath: root,
requestedAccess: 'read-only',
requiredCapabilities: ['read-text']
},
context
)) as {
handle: {
workspaceId: string
generation: number
}
}
await expect(
methods['workspace/readText'](
{
workspaceId: validated.handle.workspaceId,
generation: validated.handle.generation,
relativePath: 'hello.txt',
offsetBytes: 0,
maximumBytes: 100
},
context
)
).resolves.toMatchObject({ content: 'hello' })
await expect(
methods['workspace/writeTextAtomic']({}, context)
).rejects.toMatchObject({ code: 'read-only' })
await expect(
methods['git/operation']({}, context)
).rejects.toMatchObject({ code: 'read-only' })
})
linuxIt('revokes all connection grants when the connection closes', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-methods-'))
temporaryPaths.push(root)
writeFileSync(join(root, 'hello.txt'), 'hello')
const controllers = new ControllerRegistry()
const controller = controllers.attach('controller-test')
const workspaces = new WorkspaceRegistry({
controllers,
inspectGit: async () => 'not-a-repository'
})
const methods = createWorkspaceProtocolMethods({
workspaces,
git: new WorkspaceGitService({ gitExecutable: '/bin/false' })
})
const connection = new AbortController()
const context = {
controller,
channelId: 'channel-test',
signal: connection.signal
}
const validated = (await methods['workspace/validate'](
{
remoteRootPath: root,
requestedAccess: 'read-only',
requiredCapabilities: []
},
context
)) as {
handle: {
workspaceId: string
workspaceIdentity: string
generation: number
}
}
const opened = (await methods['workspace/open'](
{
workspaceIdentity: validated.handle.workspaceIdentity,
requestedAccess: 'read-only'
},
context
)) as {
workspaceId: string
generation: number
}
expect(workspaces.activeHandleCount()).toBe(2)
connection.abort()
expect(workspaces.activeHandleCount()).toBe(0)
await expect(
workspaces.get(
opened.workspaceId,
opened.generation,
controller
)
).rejects.toMatchObject({ code: 'workspace-not-found' })
})
})
@@ -0,0 +1,309 @@
import {
remoteGitDiffRequestSchema,
remoteGitDiffResultSchema,
remoteGitStatusRequestSchema,
remoteGitStatusResultSchema,
remoteWorkspaceCloseRequestSchema,
remoteWorkspaceCloseResultSchema,
remoteWorkspaceListRequestSchema,
remoteWorkspaceListResultSchema,
remoteWorkspaceOpenRequestSchema,
remoteWorkspaceOpenResultSchema,
remoteWorkspaceReadTextRequestSchema,
remoteWorkspaceReadTextResultSchema,
remoteWorkspaceResumeRequestSchema,
remoteWorkspaceResumeResultSchema,
remoteWorkspaceSearchRequestSchema,
remoteWorkspaceSearchResultSchema,
remoteWorkspaceStatRequestSchema,
remoteWorkspaceStatResultSchema,
remoteWorkspaceValidateRequestSchema,
remoteWorkspaceValidateResultSchema
} from '../shared/remote-agent-contracts'
import type {
ProtocolMethodContext,
ProtocolMethodHandler
} from './protocol-server'
import type { WorkspaceGitService } from './workspace-git-service'
import type { WorkspaceRegistry } from './workspace-registry'
import {
WorkspaceServiceError,
type WorkspaceIoOptions
} from './workspace-path-access'
const DEFAULT_WORKSPACE_REQUEST_TIMEOUT_MS = 30_000
const MAXIMUM_WORKSPACE_REQUEST_TIMEOUT_MS = 120_000
export const WORKSPACE_PROTOCOL_METHODS = [
'workspace/validate',
'workspace/open',
'workspace/resume',
'workspace/close',
'workspace/list',
'workspace/stat',
'workspace/readText',
'workspace/search',
'git/status',
'git/diff',
'workspace/writeTextAtomic',
'workspace/applyChangeSet',
'git/operation'
] as const
export type WorkspaceProtocolMethodMap = Readonly<
Record<(typeof WORKSPACE_PROTOCOL_METHODS)[number], ProtocolMethodHandler>
>
export function createWorkspaceProtocolMethods(options: {
workspaces: WorkspaceRegistry
git: WorkspaceGitService
requestTimeoutMs?: number
}): WorkspaceProtocolMethodMap {
const timeoutMs = boundedTimeout(
options.requestTimeoutMs ??
DEFAULT_WORKSPACE_REQUEST_TIMEOUT_MS
)
const boundConnectionSignals = new WeakSet<AbortSignal>()
const invoke = async <T>(
context: ProtocolMethodContext,
action: (io: WorkspaceIoOptions) => Promise<T>
): Promise<T> => {
bindControllerLifetime(
options.workspaces,
context,
boundConnectionSignals
)
return await withDeadline(timeoutMs, action, context.signal)
}
return {
'workspace/validate': async (params, context) =>
await invoke(context, async (io) =>
remoteWorkspaceValidateResultSchema.parse(
await options.workspaces.validate(
remoteWorkspaceValidateRequestSchema.parse(params),
context.controller,
io
)
)
),
'workspace/open': async (params, context) =>
await invoke(context, async (io) =>
remoteWorkspaceOpenResultSchema.parse(
await options.workspaces.open(
remoteWorkspaceOpenRequestSchema.parse(params),
context.controller,
io
)
)
),
'workspace/resume': async (params, context) =>
await invoke(context, async (io) =>
remoteWorkspaceResumeResultSchema.parse(
await options.workspaces.resume(
remoteWorkspaceResumeRequestSchema.parse(params),
context.controller,
io
)
)
),
'workspace/close': async (params, context) => {
const request = remoteWorkspaceCloseRequestSchema.parse(params)
return await invoke(context, async () =>
remoteWorkspaceCloseResultSchema.parse(
await options.workspaces.close(
request.workspaceId,
request.generation,
context.controller
)
)
)
},
'workspace/list': async (params, context) => {
const request = remoteWorkspaceListRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
return remoteWorkspaceListResultSchema.parse(
await workspace.access.list(request, io)
)
})
},
'workspace/stat': async (params, context) => {
const request = remoteWorkspaceStatRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
return remoteWorkspaceStatResultSchema.parse(
await workspace.access.stat(request.relativePath, io)
)
})
},
'workspace/readText': async (params, context) => {
const request = remoteWorkspaceReadTextRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
return remoteWorkspaceReadTextResultSchema.parse(
await workspace.access.readText(request, io)
)
})
},
'workspace/search': async (params, context) => {
const request = remoteWorkspaceSearchRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
return remoteWorkspaceSearchResultSchema.parse(
await workspace.access.search(request, io)
)
})
},
'git/status': async (params, context) => {
const request = remoteGitStatusRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
assertGitAvailable(workspace.handle.git)
return remoteGitStatusResultSchema.parse(
await options.git.status(workspace.access, request, io)
)
})
},
'git/diff': async (params, context) => {
const request = remoteGitDiffRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
assertGitAvailable(workspace.handle.git)
return remoteGitDiffResultSchema.parse(
await options.git.diff(workspace.access, request, io)
)
})
},
'workspace/writeTextAtomic': readOnlyUnavailable,
'workspace/applyChangeSet': readOnlyUnavailable,
'git/operation': readOnlyUnavailable
}
}
function bindControllerLifetime(
workspaces: WorkspaceRegistry,
context: ProtocolMethodContext,
boundSignals: WeakSet<AbortSignal>
): void {
const signal = context.signal
if (signal === undefined || boundSignals.has(signal)) {
return
}
boundSignals.add(signal)
const closeController = (): void => {
workspaces.closeController(context.controller)
}
if (signal.aborted) {
closeController()
} else {
signal.addEventListener('abort', closeController, { once: true })
}
}
async function readOnlyUnavailable(): Promise<never> {
throw new WorkspaceServiceError(
'Remote workspace mutation is unavailable in this read-only service',
'read-only'
)
}
function assertGitAvailable(
availability: 'available' | 'not-a-repository' | 'unavailable'
): void {
if (availability !== 'available') {
throw new WorkspaceServiceError(
availability === 'not-a-repository'
? 'Workspace is not a Git repository'
: 'Git read service is unavailable',
'git-unavailable'
)
}
}
async function withDeadline<T>(
timeoutMs: number,
action: (options: WorkspaceIoOptions) => Promise<T>,
upstreamSignal?: AbortSignal
): Promise<T> {
const controller = new AbortController()
const abortFromUpstream = (): void => {
controller.abort(
new WorkspaceServiceError(
'Workspace operation was aborted',
'aborted'
)
)
}
if (upstreamSignal?.aborted === true) {
abortFromUpstream()
} else {
upstreamSignal?.addEventListener('abort', abortFromUpstream, {
once: true
})
}
const deadlineAt = Date.now() + timeoutMs
const timer = setTimeout(() => {
controller.abort(
new WorkspaceServiceError(
'Workspace operation deadline exceeded',
'deadline-exceeded'
)
)
}, timeoutMs)
timer.unref()
try {
const result = await action({
signal: controller.signal,
deadlineAt
})
if (controller.signal.aborted) {
throw controller.signal.reason
}
return result
} finally {
clearTimeout(timer)
upstreamSignal?.removeEventListener('abort', abortFromUpstream)
}
}
function boundedTimeout(value: number): number {
if (
!Number.isSafeInteger(value) ||
value < 1 ||
value > MAXIMUM_WORKSPACE_REQUEST_TIMEOUT_MS
) {
throw new RangeError('Invalid workspace request timeout')
}
return value
}
+411
View File
@@ -0,0 +1,411 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ControllerRegistry } from './controller-registry'
import { WorkspaceRegistry } from './workspace-registry'
const temporaryPaths: string[] = []
const linuxIt = process.platform === 'linux' ? it : it.skip
afterEach(() => {
vi.restoreAllMocks()
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('WorkspaceRegistry', () => {
linuxIt('binds ephemeral handles to owner and connection generation', async () => {
const root = temporaryDirectory()
const controllers = new ControllerRegistry()
const owner = controllers.attach('controller-owner')
const other = controllers.attach('controller-other')
const registry = workspaceRegistry(controllers)
const validated = await validate(registry, root, owner)
await expect(
registry.get(
validated.handle.workspaceId,
validated.handle.generation,
other
)
).rejects.toMatchObject({ code: 'not-owner' })
controllers.disconnect(owner.controllerId, owner.generation)
const replacement = controllers.attach(owner.controllerId)
await expect(
registry.resume(
{
workspaceId: validated.handle.workspaceId,
generation: validated.handle.generation,
workspaceIdentity: validated.handle.workspaceIdentity
},
replacement
)
).rejects.toMatchObject({ code: 'workspace-not-found' })
await expect(
registry.open(
{
workspaceIdentity: validated.handle.workspaceIdentity,
requestedAccess: 'read-only'
},
replacement
)
).rejects.toMatchObject({ code: 'workspace-not-found' })
})
linuxIt('resolves only a current Workspace lease owned by the controller', async () => {
const root = temporaryDirectory()
const controllers = new ControllerRegistry()
const owner = controllers.attach('controller-owner')
const other = controllers.attach('controller-other')
const registry = workspaceRegistry(controllers)
const validated = await validate(registry, root, owner)
await expect(
registry.getCurrentByIdentity(
validated.handle.workspaceIdentity,
owner
)
).resolves.toMatchObject({
controllerId: owner.controllerId,
controllerGeneration: owner.generation,
handle: {
workspaceIdentity: validated.handle.workspaceIdentity
}
})
await expect(
registry.getCurrentByIdentity(
validated.handle.workspaceIdentity,
other
)
).rejects.toMatchObject({ code: 'not-owner' })
controllers.disconnect(owner.controllerId, owner.generation)
const replacement = controllers.attach(owner.controllerId)
await expect(
registry.getCurrentByIdentity(
validated.handle.workspaceIdentity,
replacement
)
).rejects.toMatchObject({ code: 'workspace-not-found' })
})
linuxIt('revokes a validation grant and all handles opened from it', async () => {
const root = temporaryDirectory()
const controllers = new ControllerRegistry()
const controller = controllers.attach('controller-owner')
const registry = workspaceRegistry(controllers)
const validated = await validate(registry, root, controller)
const opened = await registry.open(
{
workspaceIdentity: validated.handle.workspaceIdentity,
requestedAccess: 'read-only'
},
controller
)
await expect(
registry.close(
validated.handle.workspaceId,
validated.handle.generation,
controller
)
).resolves.toMatchObject({ closed: true })
expect(registry.activeHandleCount()).toBe(0)
await expect(
registry.get(opened.workspaceId, opened.generation, controller)
).rejects.toMatchObject({ code: 'workspace-not-found' })
await expect(
registry.open(
{
workspaceIdentity: validated.handle.workspaceIdentity,
requestedAccess: 'read-only'
},
controller
)
).rejects.toMatchObject({ code: 'workspace-not-found' })
})
linuxIt('enforces read-only policy, caps, and symlink-root rejection', async () => {
const root = temporaryDirectory()
const linked = join(tmpdir(), `goodbuddy-linked-${Date.now()}`)
symlinkSync(root, linked, 'dir')
temporaryPaths.push(linked)
const controllers = new ControllerRegistry()
const controller = controllers.attach('controller-owner')
const registry = new WorkspaceRegistry({
controllers,
maximumControllerHandles: 1,
inspectGit: async () => 'not-a-repository'
})
await expect(
registry.validate(
{
remoteRootPath: root,
requestedAccess: 'read-write',
requiredCapabilities: []
},
controller
)
).rejects.toMatchObject({ code: 'read-only' })
await expect(
registry.validate(
{
remoteRootPath: linked,
requestedAccess: 'read-only',
requiredCapabilities: []
},
controller
)
).rejects.toMatchObject({ code: 'symlink-rejected' })
const validated = await validate(registry, root, controller)
await expect(
registry.open(
{
workspaceIdentity: validated.handle.workspaceIdentity,
requestedAccess: 'read-only'
},
controller
)
).rejects.toMatchObject({ code: 'capacity-exceeded' })
})
linuxIt('does not mutate workspace or registry storage during validation', async () => {
const state = temporaryDirectory()
const root = join(state, 'workspace')
mkdirSync(root)
writeFileSync(join(root, 'existing.txt'), 'unchanged')
const before = readdirSync(root)
const storagePath = join(state, 'workspaces.json')
const controllers = new ControllerRegistry()
const controller = controllers.attach('controller-owner')
const registry = new WorkspaceRegistry({
controllers,
storagePath,
inspectGit: async () => 'not-a-repository'
})
const validated = await validate(registry, root, controller)
expect(existsSync(storagePath)).toBe(false)
expect(readdirSync(root)).toEqual(before)
expect(readFileSync(join(root, 'existing.txt'), 'utf8')).toBe(
'unchanged'
)
await registry.close(
validated.handle.workspaceId,
validated.handle.generation,
controller
)
expect(existsSync(storagePath)).toBe(false)
})
linuxIt('leaves no grant when validation is cancelled', async () => {
const state = temporaryDirectory()
const root = join(state, 'workspace')
mkdirSync(root)
const storagePath = join(state, 'workspaces.json')
const controllers = new ControllerRegistry()
const controller = controllers.attach('controller-owner')
const abort = new AbortController()
let releaseInspection: ((value: 'not-a-repository') => void) | undefined
let markInspectionStarted: (() => void) | undefined
const inspectionStarted = new Promise<void>((resolve) => {
markInspectionStarted = resolve
})
const registry = new WorkspaceRegistry({
controllers,
storagePath,
inspectGit: async () => {
markInspectionStarted?.()
return await new Promise<'not-a-repository'>((resolve) => {
releaseInspection = resolve
})
}
})
const pending = validate(registry, root, controller, {
signal: abort.signal
})
await inspectionStarted
abort.abort(new Error('cancelled'))
releaseInspection?.('not-a-repository')
await expect(pending).rejects.toThrow('cancelled')
expect(registry.activeHandleCount()).toBe(0)
expect(existsSync(storagePath)).toBe(false)
})
linuxIt('requires path revalidation after daemon restart', async () => {
const state = temporaryDirectory()
const root = join(state, 'workspace')
mkdirSync(root)
const storagePath = join(state, 'workspaces.json')
const firstControllers = new ControllerRegistry()
const firstController = firstControllers.attach('controller-owner')
const firstRegistry = new WorkspaceRegistry({
controllers: firstControllers,
storagePath,
inspectGit: async () => 'not-a-repository'
})
const first = await validate(firstRegistry, root, firstController)
firstRegistry.closeAll()
const replacementControllers = new ControllerRegistry()
const replacement = replacementControllers.attach('controller-owner')
const replacementRegistry = new WorkspaceRegistry({
controllers: replacementControllers,
storagePath,
inspectGit: async () => 'not-a-repository'
})
await expect(
replacementRegistry.resume(
{
workspaceId: first.handle.workspaceId,
generation: first.handle.generation,
workspaceIdentity: first.handle.workspaceIdentity
},
replacement
)
).rejects.toMatchObject({ code: 'workspace-not-found' })
await expect(
replacementRegistry.open(
{
workspaceIdentity: first.handle.workspaceIdentity,
requestedAccess: 'read-only'
},
replacement
)
).rejects.toMatchObject({ code: 'workspace-not-found' })
const revalidated = await validate(
replacementRegistry,
root,
replacement
)
expect(revalidated.handle.workspaceIdentity).toBe(
first.handle.workspaceIdentity
)
expect(revalidated.handle.workspaceId).not.toBe(
first.handle.workspaceId
)
})
it('clears legacy persisted roots and never authorizes them', async () => {
const state = temporaryDirectory()
const root = join(state, 'workspace')
mkdirSync(root)
const storagePath = join(state, 'workspaces.json')
writeFileSync(
storagePath,
JSON.stringify({
version: 1,
roots: [
{
canonicalPath: root,
device: '1',
inode: '2',
workspaceIdentity: 'workspace-legacy',
git: 'not-a-repository',
trustAttestationRevision: 1,
authorizedControllers: ['controller-owner']
}
],
leases: [
{
handle: {
workspaceId: 'workspace-legacy-handle',
workspaceIdentity: 'workspace-legacy',
canonicalDisplayPath: root,
access: 'read-only',
git: 'not-a-repository',
capabilities: ['list', 'stat', 'read-text', 'search'],
generation: 1
},
controllerId: 'controller-owner',
controllerGeneration: controllerGenerationForLegacyFixture
}
]
}),
{ mode: 0o600 }
)
const controllers = new ControllerRegistry()
const controller = controllers.attach('controller-owner')
const registry = new WorkspaceRegistry({
controllers,
storagePath,
inspectGit: async () => 'not-a-repository'
})
expect(JSON.parse(readFileSync(storagePath, 'utf8'))).toEqual({
version: 2,
authorization: 'ephemeral'
})
await expect(
registry.open(
{
workspaceIdentity: 'workspace-legacy',
requestedAccess: 'read-only'
},
controller
)
).rejects.toMatchObject({ code: 'workspace-not-found' })
await expect(
registry.resume(
{
workspaceId: 'workspace-legacy-handle',
workspaceIdentity: 'workspace-legacy',
generation: 1
},
controller
)
).rejects.toMatchObject({ code: 'workspace-not-found' })
expect(registry.activeHandleCount()).toBe(0)
})
})
function workspaceRegistry(
controllers: ControllerRegistry
): WorkspaceRegistry {
return new WorkspaceRegistry({
controllers,
inspectGit: async () => 'not-a-repository'
})
}
const controllerGenerationForLegacyFixture = 1
async function validate(
registry: WorkspaceRegistry,
root: string,
controller: ReturnType<ControllerRegistry['attach']>,
options: { signal?: AbortSignal } = {}
) {
return await registry.validate(
{
remoteRootPath: root,
requestedAccess: 'read-only',
requiredCapabilities: []
},
controller,
options
)
}
function temporaryDirectory(): string {
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-registry-'))
temporaryPaths.push(path)
return path
}
+622
View File
@@ -0,0 +1,622 @@
import { randomBytes } from 'node:crypto'
import { readFileSync } from 'node:fs'
import {
REMOTE_WORKSPACE_READ_CAPABILITIES,
type RemoteWorkspaceCloseResult,
type RemoteWorkspaceHandle,
type RemoteWorkspaceOpenRequest,
type RemoteWorkspaceResumeRequest,
type RemoteWorkspaceResumeResult,
type RemoteWorkspaceValidateRequest,
type RemoteWorkspaceValidateResult
} from '../shared/remote-agent-contracts'
import {
type ControllerLease,
type ControllerRegistry
} from './controller-registry'
import {
inspectWorkspaceRoot,
WorkspacePathAccess,
WorkspaceServiceError,
type WorkspaceIoOptions
} from './workspace-path-access'
import {
assertPrivateRegularFile,
writePrivateFileAtomic
} from './managed-paths'
const DEFAULT_MAXIMUM_CONTROLLER_HANDLES = 32
const DEFAULT_MAXIMUM_DAEMON_HANDLES = 256
const DEFAULT_MAXIMUM_VALIDATED_ROOTS = 256
const EPHEMERAL_REGISTRY_MARKER = Object.freeze({
version: 2,
authorization: 'ephemeral'
})
type GitAvailability =
| 'available'
| 'not-a-repository'
| 'unavailable'
type ValidatedRoot = {
access: WorkspacePathAccess
git: GitAvailability
}
export type WorkspaceLease = {
handle: RemoteWorkspaceHandle
controllerId: string
controllerGeneration: number
access: WorkspacePathAccess
}
type MutableWorkspaceLease = WorkspaceLease & {
closed: boolean
authorizesOpen: boolean
grantWorkspaceId: string
}
export type WorkspaceRegistryOptions = {
controllers: ControllerRegistry
maximumControllerHandles?: number
maximumDaemonHandles?: number
maximumValidatedRoots?: number
inspectGit?: (
access: WorkspacePathAccess,
options?: WorkspaceIoOptions
) => Promise<GitAvailability>
now?: () => number
storagePath?: string
}
export class WorkspaceRegistry {
readonly #controllers: ControllerRegistry
readonly #maximumControllerHandles: number
readonly #maximumDaemonHandles: number
readonly #maximumValidatedRoots: number
readonly #inspectGit: NonNullable<WorkspaceRegistryOptions['inspectGit']>
readonly #now: () => number
readonly #storagePath?: string
readonly #validatedRoots = new Map<string, ValidatedRoot>()
readonly #leases = new Map<string, MutableWorkspaceLease>()
readonly #nextGenerationByIdentity = new Map<string, number>()
constructor(options: WorkspaceRegistryOptions) {
this.#controllers = options.controllers
this.#maximumControllerHandles = boundedCap(
options.maximumControllerHandles ??
DEFAULT_MAXIMUM_CONTROLLER_HANDLES,
'per-controller workspace handle'
)
this.#maximumDaemonHandles = boundedCap(
options.maximumDaemonHandles ?? DEFAULT_MAXIMUM_DAEMON_HANDLES,
'daemon workspace handle'
)
this.#maximumValidatedRoots = boundedCap(
options.maximumValidatedRoots ?? DEFAULT_MAXIMUM_VALIDATED_ROOTS,
'validated workspace root'
)
this.#inspectGit =
options.inspectGit ??
(async () => 'unavailable')
this.#now = options.now ?? Date.now
this.#storagePath = options.storagePath
this.#load()
}
async validate(
request: RemoteWorkspaceValidateRequest,
controller: ControllerLease,
options: WorkspaceIoOptions = {}
): Promise<RemoteWorkspaceValidateResult> {
this.#assertController(controller)
this.#assertReadOnly(
request.requestedAccess,
request.requiredCapabilities
)
const rootIdentity = await inspectWorkspaceRoot(
request.remoteRootPath,
options
)
const access = new WorkspacePathAccess(rootIdentity)
const existing = this.#validatedRoots.get(
rootIdentity.workspaceIdentity
)
if (
existing !== undefined &&
existing.access.root.canonicalPath !== rootIdentity.canonicalPath
) {
throw new WorkspaceServiceError(
'Workspace file identity is already bound to another canonical root',
'stale-workspace'
)
}
const git = await this.#inspectGit(access, options)
const capabilities = capabilitiesFor(git)
for (const capability of request.requiredCapabilities) {
if (!capabilities.includes(capability)) {
throw new WorkspaceServiceError(
`Required workspace capability is unavailable: ${capability}`,
'unavailable'
)
}
}
if (
existing === undefined &&
this.#validatedRoots.size >= this.#maximumValidatedRoots
) {
throw new WorkspaceServiceError(
'Validated workspace root capacity reached',
'capacity-exceeded'
)
}
this.#validatedRoots.set(rootIdentity.workspaceIdentity, {
access,
git
})
let lease: WorkspaceLease
try {
lease = await this.#createLease(
this.#validatedRoots.get(rootIdentity.workspaceIdentity)!,
controller,
true,
undefined,
options
)
} catch (error) {
if (existing === undefined) {
this.#validatedRoots.delete(rootIdentity.workspaceIdentity)
} else {
this.#validatedRoots.set(rootIdentity.workspaceIdentity, existing)
}
throw error
}
return {
handle: lease.handle,
validatedAt: new Date(this.#now()).toISOString()
}
}
async open(
request: RemoteWorkspaceOpenRequest,
controller: ControllerLease,
options: WorkspaceIoOptions = {}
): Promise<RemoteWorkspaceHandle> {
this.#assertController(controller)
this.#assertReadOnly(request.requestedAccess, [])
const root = this.#validatedRoots.get(request.workspaceIdentity)
if (root === undefined) {
throw new WorkspaceServiceError(
'Workspace identity has no live validation grant',
'workspace-not-found'
)
}
const liveGrants = [...this.#leases.values()].filter(
(lease) =>
!lease.closed &&
lease.authorizesOpen &&
lease.handle.workspaceIdentity === request.workspaceIdentity
)
if (
!liveGrants.some(
(lease) =>
lease.controllerId === controller.controllerId &&
lease.controllerGeneration === controller.generation
)
) {
if (
liveGrants.some(
(lease) => lease.controllerId !== controller.controllerId
)
) {
throw new WorkspaceServiceError(
'Workspace identity belongs to another controller',
'not-owner'
)
}
throw new WorkspaceServiceError(
'Workspace identity has no live validation grant',
'workspace-not-found'
)
}
return (
await this.#createLease(
root,
controller,
false,
liveGrants.find(
(lease) =>
lease.controllerId === controller.controllerId &&
lease.controllerGeneration === controller.generation
)?.grantWorkspaceId,
options
)
).handle
}
async resume(
request: RemoteWorkspaceResumeRequest,
controller: ControllerLease,
options: WorkspaceIoOptions = {}
): Promise<RemoteWorkspaceResumeResult> {
this.#assertController(controller)
const lease = this.#leases.get(request.workspaceId)
if (
lease === undefined ||
lease.closed ||
lease.handle.workspaceIdentity !== request.workspaceIdentity
) {
throw new WorkspaceServiceError(
'Workspace handle is unavailable',
'workspace-not-found'
)
}
if (lease.controllerId !== controller.controllerId) {
throw new WorkspaceServiceError(
'Workspace handle belongs to another controller',
'not-owner'
)
}
if (lease.controllerGeneration !== controller.generation) {
throw new WorkspaceServiceError(
'Workspace handle is unavailable after reconnect',
'workspace-not-found'
)
}
if (lease.handle.generation !== request.generation) {
throw new WorkspaceServiceError(
'Workspace handle generation is stale',
'stale-generation'
)
}
await lease.access.assertCurrent(options)
return { resumed: true, handle: { ...lease.handle } }
}
async close(
workspaceId: string,
generation: number,
controller: ControllerLease
): Promise<RemoteWorkspaceCloseResult> {
const lease = this.#assertLease(
workspaceId,
generation,
controller
)
if (lease.authorizesOpen) {
this.#revokeGrant(lease.grantWorkspaceId)
} else {
lease.closed = true
this.#leases.delete(workspaceId)
this.#removeUnusedRoot(lease.handle.workspaceIdentity)
}
return { workspaceId, generation, closed: true }
}
async get(
workspaceId: string,
generation: number,
controller: ControllerLease,
options: WorkspaceIoOptions = {}
): Promise<WorkspaceLease> {
const lease = this.#assertLease(
workspaceId,
generation,
controller
)
await lease.access.assertCurrent(options)
return snapshotLease(lease)
}
async getCurrentByIdentity(
workspaceIdentity: string,
controller: ControllerLease,
options: WorkspaceIoOptions = {}
): Promise<WorkspaceLease> {
this.#assertController(controller)
const leases = [...this.#leases.values()].filter(
(lease) =>
!lease.closed &&
lease.handle.workspaceIdentity === workspaceIdentity &&
lease.controllerId === controller.controllerId &&
lease.controllerGeneration === controller.generation
)
if (leases.length === 0) {
const ownedByAnotherController = [...this.#leases.values()].some(
(lease) =>
!lease.closed &&
lease.handle.workspaceIdentity === workspaceIdentity &&
lease.controllerId !== controller.controllerId
)
throw new WorkspaceServiceError(
ownedByAnotherController
? 'Workspace identity belongs to another controller'
: 'Workspace identity has no current controller lease',
ownedByAnotherController ? 'not-owner' : 'workspace-not-found'
)
}
const lease =
leases.find((candidate) => candidate.authorizesOpen) ??
leases[0]!
await lease.access.assertCurrent(options)
return snapshotLease(lease)
}
closeAll(): void {
for (const lease of this.#leases.values()) {
lease.closed = true
}
this.#leases.clear()
this.#validatedRoots.clear()
}
closeController(
controller: Pick<ControllerLease, 'controllerId' | 'generation'>
): void {
const affectedIdentities = new Set<string>()
for (const [workspaceId, lease] of this.#leases) {
if (
lease.controllerId !== controller.controllerId ||
lease.controllerGeneration !== controller.generation
) {
continue
}
lease.closed = true
this.#leases.delete(workspaceId)
affectedIdentities.add(lease.handle.workspaceIdentity)
}
for (const workspaceIdentity of affectedIdentities) {
this.#removeUnusedRoot(workspaceIdentity)
}
}
activeHandleCount(): number {
return this.#leases.size
}
#assertLease(
workspaceId: string,
generation: number,
controller: ControllerLease
): MutableWorkspaceLease {
this.#assertController(controller)
const lease = this.#leases.get(workspaceId)
if (lease === undefined || lease.closed) {
throw new WorkspaceServiceError(
'Workspace handle is unavailable',
'workspace-not-found'
)
}
if (lease.controllerId !== controller.controllerId) {
throw new WorkspaceServiceError(
'Workspace handle belongs to another controller',
'not-owner'
)
}
if (lease.controllerGeneration !== controller.generation) {
throw new WorkspaceServiceError(
'Workspace handle is unavailable after reconnect',
'workspace-not-found'
)
}
if (lease.handle.generation !== generation) {
throw new WorkspaceServiceError(
'Workspace handle generation is stale',
'stale-generation'
)
}
return lease
}
async #createLease(
root: ValidatedRoot,
controller: ControllerLease,
authorizesOpen: boolean,
grantWorkspaceId: string | undefined,
options: WorkspaceIoOptions
): Promise<WorkspaceLease> {
this.#assertCapacity(controller.controllerId)
await root.access.assertCurrent(options)
throwIfAborted(options.signal)
const workspaceIdentity = root.access.root.workspaceIdentity
const generation =
(this.#nextGenerationByIdentity.get(workspaceIdentity) ?? 0) + 1
if (generation > 0xffff_ffff) {
throw new WorkspaceServiceError(
'Workspace generation is exhausted',
'capacity-exceeded'
)
}
this.#nextGenerationByIdentity.set(workspaceIdentity, generation)
const workspaceId = `workspace-${randomBytes(18).toString('base64url')}`
if (!authorizesOpen && grantWorkspaceId === undefined) {
throw new WorkspaceServiceError(
'Workspace identity has no live validation grant',
'workspace-not-found'
)
}
const handle: RemoteWorkspaceHandle = {
workspaceId,
workspaceIdentity,
canonicalDisplayPath: root.access.root.canonicalPath,
access: 'read-only',
git: root.git,
capabilities: capabilitiesFor(root.git),
generation
}
const lease: MutableWorkspaceLease = {
handle,
controllerId: controller.controllerId,
controllerGeneration: controller.generation,
access: root.access,
closed: false,
authorizesOpen,
grantWorkspaceId: grantWorkspaceId ?? workspaceId
}
this.#leases.set(workspaceId, lease)
return snapshotLease(lease)
}
#assertCapacity(controllerId: string): void {
if (this.#leases.size >= this.#maximumDaemonHandles) {
throw new WorkspaceServiceError(
'Daemon workspace handle capacity reached',
'capacity-exceeded'
)
}
let owned = 0
for (const lease of this.#leases.values()) {
if (!lease.closed && lease.controllerId === controllerId) {
owned += 1
}
}
if (owned >= this.#maximumControllerHandles) {
throw new WorkspaceServiceError(
'Controller workspace handle capacity reached',
'capacity-exceeded'
)
}
}
#assertController(controller: ControllerLease): void {
this.#controllers.assertCurrent(
controller.controllerId,
controller.generation
)
}
#assertReadOnly(
requestedAccess: 'read-only' | 'read-write',
capabilities: readonly string[]
): void {
if (
requestedAccess !== 'read-only' ||
capabilities.includes('write-text-atomic') ||
capabilities.includes('apply-change-set')
) {
throw new WorkspaceServiceError(
'Remote workspace writes are not available',
'read-only'
)
}
}
#load(): void {
if (this.#storagePath === undefined) {
return
}
let parsed: unknown
try {
assertPrivateRegularFile(this.#storagePath)
parsed = JSON.parse(readFileSync(this.#storagePath, 'utf8'))
} catch (error) {
if (isErrno(error, 'ENOENT')) {
return
}
throw error
}
if (
parsed === null ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
throw new Error('Workspace registry is corrupt')
}
const value = parsed as Record<string, unknown>
if (
value.version === EPHEMERAL_REGISTRY_MARKER.version &&
value.authorization === EPHEMERAL_REGISTRY_MARKER.authorization &&
Object.keys(value).length === 2
) {
return
}
if (
value.version === 1 &&
Array.isArray(value.roots) &&
Array.isArray(value.leases)
) {
this.#persistMarker()
return
}
throw new Error('Workspace registry is corrupt')
}
#persistMarker(): void {
if (this.#storagePath === undefined) {
return
}
writePrivateFileAtomic(
this.#storagePath,
JSON.stringify(EPHEMERAL_REGISTRY_MARKER)
)
}
#removeUnusedRoot(workspaceIdentity: string): void {
for (const lease of this.#leases.values()) {
if (
!lease.closed &&
lease.handle.workspaceIdentity === workspaceIdentity
) {
return
}
}
this.#validatedRoots.delete(workspaceIdentity)
}
#revokeGrant(grantWorkspaceId: string): void {
const affectedIdentities = new Set<string>()
for (const [workspaceId, lease] of this.#leases) {
if (lease.grantWorkspaceId !== grantWorkspaceId) {
continue
}
lease.closed = true
this.#leases.delete(workspaceId)
affectedIdentities.add(lease.handle.workspaceIdentity)
}
for (const workspaceIdentity of affectedIdentities) {
this.#removeUnusedRoot(workspaceIdentity)
}
}
}
function capabilitiesFor(
git: GitAvailability
): RemoteWorkspaceHandle['capabilities'] {
return [
...REMOTE_WORKSPACE_READ_CAPABILITIES,
...(git === 'available'
? (['git-status', 'git-diff'] as const)
: [])
]
}
function isErrno(error: unknown, code: string): boolean {
return (
error !== null &&
typeof error === 'object' &&
'code' in error &&
error.code === code
)
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted === true) {
throw signal.reason
}
}
function boundedCap(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value < 1 || value > 100_000) {
throw new RangeError(`Invalid ${label} capacity`)
}
return value
}
function snapshotLease(lease: MutableWorkspaceLease): WorkspaceLease {
return {
handle: { ...lease.handle, capabilities: [...lease.handle.capabilities] },
controllerId: lease.controllerId,
controllerGeneration: lease.controllerGeneration,
access: lease.access
}
}