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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from '../shared/agent-protocol/channel-state'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user