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,387 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ClientChannel } from 'ssh2'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_PROTOCOL_FAILURE_STDERR_PREFIX,
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
AGENT_PROTOCOL_VERSION,
|
||||
encodeAgentFrame,
|
||||
type AgentFrame,
|
||||
type AttachPreface,
|
||||
type AttachWelcome
|
||||
} from '../../shared/agent-protocol'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
import {
|
||||
AgentAttachTransport,
|
||||
AgentAttachTransportError
|
||||
} from './agent-attach-transport'
|
||||
|
||||
class FakeChannel extends EventEmitter {
|
||||
readonly stderr = new EventEmitter()
|
||||
readonly writes: Buffer[] = []
|
||||
readonly callbacks: Array<(error?: Error | null) => void> = []
|
||||
destroyed = false
|
||||
paused = false
|
||||
autoComplete = true
|
||||
onWrite?: (bytes: Buffer) => void
|
||||
|
||||
write(
|
||||
bytes: Uint8Array,
|
||||
callback?: (error?: Error | null) => void
|
||||
): boolean {
|
||||
const buffer = Buffer.from(bytes)
|
||||
this.writes.push(buffer)
|
||||
this.onWrite?.(buffer)
|
||||
if (callback !== undefined) {
|
||||
if (this.autoComplete) {
|
||||
queueMicrotask(() => callback())
|
||||
} else {
|
||||
this.callbacks.push(callback)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
pause(): this {
|
||||
this.paused = true
|
||||
return this
|
||||
}
|
||||
|
||||
resume(): this {
|
||||
this.paused = false
|
||||
return this
|
||||
}
|
||||
|
||||
destroy(): this {
|
||||
if (!this.destroyed) {
|
||||
this.destroyed = true
|
||||
this.emit('close')
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
completeWrite(error?: Error | null): void {
|
||||
this.callbacks.shift()?.(error)
|
||||
}
|
||||
|
||||
remote(bytes: Uint8Array): void {
|
||||
this.emit('data', Buffer.from(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
const preface: AttachPreface = {
|
||||
type: 'goodbuddy-agent-attach',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
goodBuddyVersion: '0.11.0',
|
||||
controllerId: 'controller-1',
|
||||
clientNonce: 'client-nonce-1',
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3
|
||||
}
|
||||
|
||||
const welcome: AttachWelcome = {
|
||||
type: 'goodbuddy-agent-welcome',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
connectionId: 'connection-1',
|
||||
generation: 2,
|
||||
installationId: 'agent-v1',
|
||||
binaryDigest: `sha256:${'a'.repeat(64)}`,
|
||||
daemonBootId: 'boot-1',
|
||||
serverNonce: 'server-nonce-1'
|
||||
}
|
||||
|
||||
function packet(value: unknown): Buffer {
|
||||
const body = Buffer.from(JSON.stringify(value), 'utf8')
|
||||
const bytes = Buffer.alloc(4 + body.byteLength)
|
||||
bytes.writeUInt32BE(body.byteLength, 0)
|
||||
body.copy(bytes, 4)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function frame(
|
||||
overrides: Partial<AgentFrame['header']> = {},
|
||||
payload = Buffer.from('payload')
|
||||
): AgentFrame {
|
||||
return {
|
||||
header: {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: 'connection-1',
|
||||
generation: 2,
|
||||
channelId: 'channel-1',
|
||||
channelEpoch: '1',
|
||||
direction: 'agent-to-main',
|
||||
sequence: '1',
|
||||
kind: 'acp',
|
||||
payloadLength: payload.byteLength,
|
||||
...overrides
|
||||
},
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
function connect(
|
||||
channel: FakeChannel,
|
||||
options: { timeoutMs?: number } = {}
|
||||
): Promise<AgentAttachTransport> {
|
||||
return AgentAttachTransport.connect({
|
||||
sshLease: {
|
||||
openAgentAttach: vi.fn(async () => channel as unknown as ClientChannel)
|
||||
},
|
||||
installationId: verifyAgentInstallationId('agent-v1'),
|
||||
preface,
|
||||
timeoutMs: options.timeoutMs
|
||||
})
|
||||
}
|
||||
|
||||
describe('AgentAttachTransport', () => {
|
||||
it('handles a split welcome and a coalesced first binary frame', async () => {
|
||||
const channel = new FakeChannel()
|
||||
const encodedFrame = encodeAgentFrame(frame())
|
||||
channel.onWrite = () => {
|
||||
const response = Buffer.concat([packet(welcome), encodedFrame])
|
||||
queueMicrotask(() => {
|
||||
channel.remote(response.subarray(0, 2))
|
||||
channel.remote(response.subarray(2, 9))
|
||||
channel.remote(response.subarray(9))
|
||||
})
|
||||
}
|
||||
const transport = await connect(channel)
|
||||
expect(transport.welcome).toEqual(welcome)
|
||||
expect((await transport.receive()).payload).toEqual(
|
||||
Buffer.from('payload')
|
||||
)
|
||||
transport.dispose()
|
||||
})
|
||||
|
||||
it('rejects oversized welcome packets and attach timeouts', async () => {
|
||||
const oversized = new FakeChannel()
|
||||
oversized.onWrite = () => {
|
||||
const length = Buffer.alloc(4)
|
||||
length.writeUInt32BE(4_097)
|
||||
queueMicrotask(() => oversized.remote(length))
|
||||
}
|
||||
await expect(connect(oversized)).rejects.toMatchObject({
|
||||
code: 'malformed'
|
||||
})
|
||||
|
||||
const stalled = new FakeChannel()
|
||||
await expect(connect(stalled, { timeoutMs: 5 })).rejects.toMatchObject({
|
||||
code: 'timeout'
|
||||
})
|
||||
expect(stalled.destroyed).toBe(true)
|
||||
|
||||
let observedSignal: AbortSignal | undefined
|
||||
const bootstrap = AgentAttachTransport.connect({
|
||||
sshLease: {
|
||||
openAgentAttach: vi.fn(
|
||||
async (_installationId, signal?: AbortSignal) => {
|
||||
observedSignal = signal
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(signal.reason),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
)
|
||||
},
|
||||
installationId: verifyAgentInstallationId('agent-v1'),
|
||||
preface,
|
||||
timeoutMs: 5
|
||||
})
|
||||
await expect(bootstrap).rejects.toMatchObject({
|
||||
code: 'timeout'
|
||||
})
|
||||
expect(observedSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on a stale generation frame', async () => {
|
||||
const channel = new FakeChannel()
|
||||
channel.onWrite = () => {
|
||||
queueMicrotask(() => channel.remote(packet(welcome)))
|
||||
}
|
||||
const transport = await connect(channel)
|
||||
channel.remote(
|
||||
encodeAgentFrame(frame({ generation: 1 }))
|
||||
)
|
||||
expect(() => transport.receive()).toThrow(
|
||||
AgentAttachTransportError
|
||||
)
|
||||
expect(transport.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('extracts only a bounded Agent protocol failure category', async () => {
|
||||
const channel = new FakeChannel()
|
||||
channel.onWrite = () => {
|
||||
queueMicrotask(() => channel.remote(packet(welcome)))
|
||||
}
|
||||
const transport = await connect(channel)
|
||||
const closed = new Promise<AgentAttachTransportError>((resolve) => {
|
||||
transport.onClose(resolve)
|
||||
})
|
||||
channel.stderr.emit(
|
||||
'data',
|
||||
`${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}dispatch/process\n`
|
||||
)
|
||||
channel.emit('close')
|
||||
await expect(closed).resolves.toMatchObject({
|
||||
code: 'closed',
|
||||
diagnostic: 'dispatch/process'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not copy remote stderr into attach diagnostics', async () => {
|
||||
const channel = new FakeChannel()
|
||||
channel.onWrite = () => {
|
||||
queueMicrotask(() => {
|
||||
channel.stderr.emit(
|
||||
'data',
|
||||
'secret-value host-00000000-0000-4000-8000-000000000001'
|
||||
)
|
||||
channel.emit('close')
|
||||
})
|
||||
}
|
||||
|
||||
await expect(connect(channel)).rejects.toMatchObject({
|
||||
code: 'closed',
|
||||
message:
|
||||
'Agent attach closed during handshake with diagnostic output'
|
||||
})
|
||||
})
|
||||
|
||||
it('writes every frame in caller FIFO order', async () => {
|
||||
const channel = new FakeChannel()
|
||||
channel.onWrite = () => {
|
||||
if (channel.writes.length === 1) {
|
||||
queueMicrotask(() => channel.remote(packet(welcome)))
|
||||
}
|
||||
}
|
||||
const transport = await connect(channel)
|
||||
channel.autoComplete = false
|
||||
const firstFrame = frame(
|
||||
{ direction: 'main-to-agent', kind: 'blob', channelId: 'one' },
|
||||
Buffer.from('one')
|
||||
)
|
||||
const secondFrame = frame(
|
||||
{ direction: 'main-to-agent', kind: 'blob', channelId: 'two' },
|
||||
Buffer.from('two')
|
||||
)
|
||||
const controlFrame = frame(
|
||||
{ direction: 'main-to-agent', kind: 'control', channelId: 'three' },
|
||||
Buffer.from('{}')
|
||||
)
|
||||
const first = transport.send(firstFrame)
|
||||
const second = transport.send(secondFrame)
|
||||
const control = transport.send(controlFrame)
|
||||
|
||||
expect(channel.writes).toHaveLength(2)
|
||||
expect(channel.writes[1]).toEqual(Buffer.from(encodeAgentFrame(firstFrame)))
|
||||
channel.completeWrite()
|
||||
await vi.waitFor(() => expect(channel.writes).toHaveLength(3))
|
||||
expect(channel.writes[2]).toEqual(Buffer.from(encodeAgentFrame(secondFrame)))
|
||||
channel.completeWrite()
|
||||
await vi.waitFor(() => expect(channel.writes).toHaveLength(4))
|
||||
expect(channel.writes[3]).toEqual(Buffer.from(encodeAgentFrame(controlFrame)))
|
||||
channel.completeWrite()
|
||||
await Promise.all([first, second, control])
|
||||
transport.dispose()
|
||||
})
|
||||
|
||||
it('rejects an active write when the SSH transport fails', async () => {
|
||||
const channel = new FakeChannel()
|
||||
channel.onWrite = () => {
|
||||
if (channel.writes.length === 1) {
|
||||
queueMicrotask(() => channel.remote(packet(welcome)))
|
||||
}
|
||||
}
|
||||
const transport = await connect(channel)
|
||||
channel.autoComplete = false
|
||||
const pending = transport.send(
|
||||
frame(
|
||||
{
|
||||
direction: 'main-to-agent',
|
||||
kind: 'blob'
|
||||
},
|
||||
Buffer.of(1)
|
||||
)
|
||||
)
|
||||
|
||||
channel.emit('error', new Error('SSH channel failed'))
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ code: 'closed' })
|
||||
expect(transport.closed).toBe(true)
|
||||
channel.completeWrite()
|
||||
})
|
||||
|
||||
it('normalizes an active SSH write callback failure', async () => {
|
||||
const channel = new FakeChannel()
|
||||
channel.onWrite = () => {
|
||||
if (channel.writes.length === 1) {
|
||||
queueMicrotask(() => channel.remote(packet(welcome)))
|
||||
}
|
||||
}
|
||||
const transport = await connect(channel)
|
||||
channel.autoComplete = false
|
||||
const pending = transport.send(
|
||||
frame(
|
||||
{
|
||||
direction: 'main-to-agent',
|
||||
kind: 'blob'
|
||||
},
|
||||
Buffer.of(1)
|
||||
)
|
||||
)
|
||||
|
||||
channel.completeWrite(new Error('SSH write failed'))
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
code: 'closed',
|
||||
message: 'Failed to write an Agent frame'
|
||||
})
|
||||
expect(transport.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('writes one maximum-sized blob frame in one SSH write', async () => {
|
||||
const channel = new FakeChannel()
|
||||
channel.onWrite = () => {
|
||||
if (channel.writes.length === 1) {
|
||||
queueMicrotask(() => channel.remote(packet(welcome)))
|
||||
}
|
||||
}
|
||||
const transport = await connect(channel)
|
||||
const blob = frame(
|
||||
{ direction: 'main-to-agent', kind: 'blob' },
|
||||
Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes)
|
||||
)
|
||||
|
||||
await transport.send(blob)
|
||||
|
||||
expect(channel.writes).toHaveLength(2)
|
||||
expect(channel.writes[1]).toEqual(Buffer.from(encodeAgentFrame(blob)))
|
||||
transport.dispose()
|
||||
})
|
||||
|
||||
it('rejects an oversized blob before writing to SSH', async () => {
|
||||
const channel = new FakeChannel()
|
||||
channel.onWrite = () => {
|
||||
if (channel.writes.length === 1) {
|
||||
queueMicrotask(() => channel.remote(packet(welcome)))
|
||||
}
|
||||
}
|
||||
const transport = await connect(channel)
|
||||
|
||||
await expect(
|
||||
transport.send(
|
||||
frame(
|
||||
{ direction: 'main-to-agent', kind: 'blob' },
|
||||
Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes + 1)
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'malformed' })
|
||||
expect(channel.writes).toHaveLength(1)
|
||||
transport.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,643 @@
|
||||
import type { ClientChannel } from 'ssh2'
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
AGENT_PROTOCOL_FAILURE_STDERR_PREFIX,
|
||||
agentProtocolFailureCategorySchema,
|
||||
attachPrefaceSchema,
|
||||
attachWelcomeSchema,
|
||||
type AgentFrame,
|
||||
type AttachPreface,
|
||||
type AttachWelcome
|
||||
} from '../../shared/agent-protocol'
|
||||
import {
|
||||
AGENT_FRAME_FIXED_HEADER_BYTES,
|
||||
AgentFrameError,
|
||||
MAXIMUM_ENCODED_AGENT_FRAME_BYTES,
|
||||
decodeAgentFrame,
|
||||
encodeAgentFrame,
|
||||
inspectAgentFramePrefix
|
||||
} from '../../shared/agent-protocol/frame'
|
||||
import type { SshConnectionLease } from '../ssh/ssh-connection-pool'
|
||||
import type { VerifiedAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
|
||||
const HANDSHAKE_MAXIMUM_BYTES = 4 * 1024
|
||||
const MAXIMUM_INCOMING_ITEMS = 256
|
||||
const MAXIMUM_PENDING_READS = 256
|
||||
const MAXIMUM_OUTGOING_ITEMS = 512
|
||||
const MAXIMUM_BUFFERED_BYTES =
|
||||
AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes
|
||||
const MAXIMUM_STDERR_BYTES = 64 * 1024
|
||||
const DEFAULT_ATTACH_TIMEOUT_MS = 10_000
|
||||
|
||||
type OutgoingWrite = {
|
||||
bytes: Uint8Array
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
|
||||
type FrameWaiter = {
|
||||
resolve: (frame: AgentFrame) => void
|
||||
reject: (error: unknown) => void
|
||||
signal?: AbortSignal
|
||||
abort?: () => void
|
||||
}
|
||||
|
||||
export type AgentAttachTransportOptions = {
|
||||
sshLease: Pick<SshConnectionLease, 'openAgentAttach'>
|
||||
installationId: VerifiedAgentInstallationId
|
||||
preface: AttachPreface
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export class AgentAttachTransportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code:
|
||||
| 'timeout'
|
||||
| 'aborted'
|
||||
| 'closed'
|
||||
| 'stderr'
|
||||
| 'malformed'
|
||||
| 'overflow',
|
||||
readonly cause?: unknown,
|
||||
readonly diagnostic?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AgentAttachTransportError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one fixed SSH attach channel. The initial packet and every subsequent
|
||||
* frame are length bounded before allocation. Ordering and backpressure are
|
||||
* delegated to the SSH channel; this layer adds no scheduler or flow window.
|
||||
*/
|
||||
export class AgentAttachTransport {
|
||||
readonly welcome: AttachWelcome
|
||||
readonly #channel: ClientChannel
|
||||
#input = Buffer.alloc(0)
|
||||
#incoming: AgentFrame[] = []
|
||||
#incomingBytes = 0
|
||||
#waiters: FrameWaiter[] = []
|
||||
#writes: OutgoingWrite[] = []
|
||||
#outgoingBytes = 0
|
||||
#writing = false
|
||||
#activeWrite?: OutgoingWrite
|
||||
#closed = false
|
||||
#closeError?: AgentAttachTransportError
|
||||
#stderr = Buffer.alloc(0)
|
||||
#closeListeners = new Set<(error: AgentAttachTransportError) => void>()
|
||||
|
||||
private constructor(channel: ClientChannel, welcome: AttachWelcome) {
|
||||
this.#channel = channel
|
||||
this.welcome = welcome
|
||||
this.#channel.on('data', this.#onData)
|
||||
this.#channel.stderr.on('data', this.#onStderr)
|
||||
this.#channel.once('error', this.#onError)
|
||||
this.#channel.once('close', this.#onClose)
|
||||
}
|
||||
|
||||
static async connect(
|
||||
options: AgentAttachTransportOptions
|
||||
): Promise<AgentAttachTransport> {
|
||||
const preface = attachPrefaceSchema.parse(options.preface)
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_ATTACH_TIMEOUT_MS
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) {
|
||||
throw new RangeError('Invalid Agent attach timeout')
|
||||
}
|
||||
options.signal?.throwIfAborted()
|
||||
const controller = new AbortController()
|
||||
const abort = (): void => controller.abort(options.signal?.reason)
|
||||
options.signal?.addEventListener('abort', abort, { once: true })
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort(
|
||||
new AgentAttachTransportError(
|
||||
'GoodBuddy Agent attach handshake timed out',
|
||||
'timeout'
|
||||
)
|
||||
)
|
||||
}, timeoutMs)
|
||||
try {
|
||||
const channel = await options.sshLease.openAgentAttach(
|
||||
options.installationId,
|
||||
controller.signal
|
||||
)
|
||||
const handshake = readWelcome(channel, preface, controller.signal)
|
||||
const { welcome, trailingBytes, stderr } = await handshake
|
||||
const transport = new AgentAttachTransport(channel, welcome)
|
||||
transport.#stderr = Buffer.from(stderr)
|
||||
if (trailingBytes.byteLength > 0) {
|
||||
transport.#acceptBytes(trailingBytes)
|
||||
}
|
||||
return transport
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
const reason = controller.signal.reason
|
||||
if (reason instanceof AgentAttachTransportError) {
|
||||
throw reason
|
||||
}
|
||||
throw new AgentAttachTransportError(
|
||||
'GoodBuddy Agent attach was aborted',
|
||||
'aborted',
|
||||
reason
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
options.signal?.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.#closed
|
||||
}
|
||||
|
||||
async send(frame: AgentFrame): Promise<void> {
|
||||
this.#assertOpen()
|
||||
let bytes: Uint8Array
|
||||
try {
|
||||
bytes = encodeAgentFrame(frame)
|
||||
} catch (error) {
|
||||
throw new AgentAttachTransportError(
|
||||
'Refused malformed outgoing Agent frame',
|
||||
'malformed',
|
||||
error
|
||||
)
|
||||
}
|
||||
await this.#enqueueWrite(bytes)
|
||||
}
|
||||
|
||||
receive(signal?: AbortSignal): Promise<AgentFrame> {
|
||||
this.#assertOpen()
|
||||
signal?.throwIfAborted()
|
||||
const frame = this.#incoming.shift()
|
||||
if (frame !== undefined) {
|
||||
this.#incomingBytes -= encodedFrameLength(frame)
|
||||
this.#resumeInputIfPossible()
|
||||
return Promise.resolve(frame)
|
||||
}
|
||||
if (this.#waiters.length >= MAXIMUM_PENDING_READS) {
|
||||
return Promise.reject(
|
||||
new AgentAttachTransportError(
|
||||
'Too many pending Agent frame readers',
|
||||
'overflow'
|
||||
)
|
||||
)
|
||||
}
|
||||
return new Promise<AgentFrame>((resolve, reject) => {
|
||||
const waiter: FrameWaiter = { resolve, reject, signal }
|
||||
if (signal !== undefined) {
|
||||
waiter.abort = (): void => {
|
||||
const index = this.#waiters.indexOf(waiter)
|
||||
if (index >= 0) {
|
||||
this.#waiters.splice(index, 1)
|
||||
}
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'))
|
||||
}
|
||||
signal.addEventListener('abort', waiter.abort, { once: true })
|
||||
}
|
||||
this.#waiters.push(waiter)
|
||||
})
|
||||
}
|
||||
|
||||
onClose(
|
||||
listener: (error: AgentAttachTransportError) => void
|
||||
): () => void {
|
||||
if (this.#closeError !== undefined) {
|
||||
queueMicrotask(() => listener(this.#closeError!))
|
||||
return () => undefined
|
||||
}
|
||||
this.#closeListeners.add(listener)
|
||||
return () => this.#closeListeners.delete(listener)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#fail(
|
||||
new AgentAttachTransportError(
|
||||
'GoodBuddy Agent attach transport was disposed',
|
||||
'closed'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
readonly #onData = (chunk: Buffer | string): void => {
|
||||
if (!this.#closed) {
|
||||
this.#acceptBytes(Buffer.from(chunk))
|
||||
}
|
||||
}
|
||||
|
||||
readonly #onStderr = (chunk: Buffer | string): void => {
|
||||
if (this.#closed) {
|
||||
return
|
||||
}
|
||||
const bytes = Buffer.from(chunk)
|
||||
if (this.#stderr.byteLength + bytes.byteLength > MAXIMUM_STDERR_BYTES) {
|
||||
this.#fail(
|
||||
new AgentAttachTransportError(
|
||||
'GoodBuddy Agent attach stderr exceeded its limit',
|
||||
'stderr'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
this.#stderr = Buffer.concat([this.#stderr, bytes])
|
||||
}
|
||||
|
||||
readonly #onError = (error: Error): void => {
|
||||
this.#fail(
|
||||
new AgentAttachTransportError(
|
||||
'GoodBuddy Agent attach channel failed',
|
||||
'closed',
|
||||
error
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
readonly #onClose = (): void => {
|
||||
const diagnostic = protocolFailureDiagnostic(this.#stderr)
|
||||
this.#fail(
|
||||
new AgentAttachTransportError(
|
||||
this.#stderr.byteLength > 0
|
||||
? 'GoodBuddy Agent attach closed with diagnostic output'
|
||||
: 'GoodBuddy Agent attach channel closed',
|
||||
'closed',
|
||||
undefined,
|
||||
diagnostic
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#acceptBytes(chunk: Buffer): void {
|
||||
if (
|
||||
this.#input.byteLength + chunk.byteLength >
|
||||
MAXIMUM_BUFFERED_BYTES + MAXIMUM_ENCODED_AGENT_FRAME_BYTES
|
||||
) {
|
||||
this.#fail(
|
||||
new AgentAttachTransportError(
|
||||
'Agent attach input buffer exceeded its limit',
|
||||
'overflow'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
this.#input = Buffer.concat([this.#input, chunk])
|
||||
try {
|
||||
while (
|
||||
!this.#closed &&
|
||||
this.#input.byteLength >= AGENT_FRAME_FIXED_HEADER_BYTES
|
||||
) {
|
||||
const frameLength =
|
||||
inspectAgentFramePrefix(this.#input).encodedByteLength
|
||||
if (this.#input.byteLength < frameLength) {
|
||||
break
|
||||
}
|
||||
const encoded = this.#input.subarray(0, frameLength)
|
||||
const frame = decodeAgentFrame(encoded, {
|
||||
protocolMajor: this.welcome.protocol.major,
|
||||
maximumProtocolMinor: this.welcome.protocol.minor,
|
||||
connectionId: this.welcome.connectionId,
|
||||
generation: this.welcome.generation
|
||||
})
|
||||
if (frame.header.direction !== 'agent-to-main') {
|
||||
throw new AgentFrameError(
|
||||
'Agent sent a frame in the wrong direction',
|
||||
'invalid'
|
||||
)
|
||||
}
|
||||
this.#input = this.#input.subarray(frameLength)
|
||||
this.#enqueueIncoming(frame)
|
||||
if (
|
||||
this.#incoming.length >= MAXIMUM_INCOMING_ITEMS ||
|
||||
this.#incomingBytes >= MAXIMUM_BUFFERED_BYTES
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.#fail(
|
||||
new AgentAttachTransportError(
|
||||
'Received a malformed Agent frame',
|
||||
'malformed',
|
||||
error
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#enqueueIncoming(frame: AgentFrame): void {
|
||||
const waiter = this.#waiters.shift()
|
||||
if (waiter !== undefined) {
|
||||
if (waiter.abort !== undefined) {
|
||||
waiter.signal?.removeEventListener('abort', waiter.abort)
|
||||
}
|
||||
waiter.resolve(frame)
|
||||
return
|
||||
}
|
||||
const byteLength = encodedFrameLength(frame)
|
||||
if (
|
||||
this.#incoming.length >= MAXIMUM_INCOMING_ITEMS ||
|
||||
this.#incomingBytes + byteLength > MAXIMUM_BUFFERED_BYTES
|
||||
) {
|
||||
this.#channel.pause()
|
||||
throw new AgentAttachTransportError(
|
||||
'Agent attach incoming queue is full',
|
||||
'overflow'
|
||||
)
|
||||
}
|
||||
this.#incoming.push(frame)
|
||||
this.#incomingBytes += byteLength
|
||||
if (
|
||||
this.#incoming.length === MAXIMUM_INCOMING_ITEMS ||
|
||||
this.#incomingBytes === MAXIMUM_BUFFERED_BYTES
|
||||
) {
|
||||
this.#channel.pause()
|
||||
}
|
||||
}
|
||||
|
||||
#resumeInputIfPossible(): void {
|
||||
if (
|
||||
!this.#closed &&
|
||||
this.#incoming.length < MAXIMUM_INCOMING_ITEMS &&
|
||||
this.#incomingBytes < MAXIMUM_BUFFERED_BYTES
|
||||
) {
|
||||
if (this.#input.byteLength > 0) {
|
||||
this.#acceptBytes(Buffer.alloc(0))
|
||||
}
|
||||
if (
|
||||
this.#incoming.length < MAXIMUM_INCOMING_ITEMS &&
|
||||
this.#incomingBytes < MAXIMUM_BUFFERED_BYTES
|
||||
) {
|
||||
this.#channel.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pumpWrites(): void {
|
||||
if (this.#writing || this.#closed) {
|
||||
return
|
||||
}
|
||||
const write = this.#writes.shift()
|
||||
if (write === undefined) {
|
||||
return
|
||||
}
|
||||
this.#writing = true
|
||||
this.#activeWrite = write
|
||||
this.#channel.write(write.bytes, (error?: Error | null) => {
|
||||
if (this.#activeWrite !== write) {
|
||||
return
|
||||
}
|
||||
this.#activeWrite = undefined
|
||||
this.#writing = false
|
||||
this.#outgoingBytes -= write.bytes.byteLength
|
||||
if (error !== undefined && error !== null) {
|
||||
const transportError = new AgentAttachTransportError(
|
||||
'Failed to write an Agent frame',
|
||||
'closed',
|
||||
error
|
||||
)
|
||||
write.reject(transportError)
|
||||
this.#fail(transportError)
|
||||
return
|
||||
}
|
||||
write.resolve()
|
||||
this.#pumpWrites()
|
||||
})
|
||||
}
|
||||
|
||||
#enqueueWrite(bytes: Uint8Array): Promise<void> {
|
||||
if (
|
||||
this.#writes.length >= MAXIMUM_OUTGOING_ITEMS ||
|
||||
this.#outgoingBytes + bytes.byteLength > MAXIMUM_BUFFERED_BYTES
|
||||
) {
|
||||
return Promise.reject(
|
||||
new AgentAttachTransportError(
|
||||
'Agent attach outgoing queue is full',
|
||||
'overflow'
|
||||
)
|
||||
)
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const write = {
|
||||
bytes,
|
||||
resolve,
|
||||
reject
|
||||
}
|
||||
this.#writes.push(write)
|
||||
this.#outgoingBytes += bytes.byteLength
|
||||
this.#pumpWrites()
|
||||
})
|
||||
}
|
||||
|
||||
#assertOpen(): void {
|
||||
if (this.#closed) {
|
||||
throw (
|
||||
this.#closeError ??
|
||||
new AgentAttachTransportError(
|
||||
'GoodBuddy Agent attach transport is closed',
|
||||
'closed'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#fail(error: AgentAttachTransportError): void {
|
||||
if (this.#closed) {
|
||||
return
|
||||
}
|
||||
this.#closed = true
|
||||
this.#closeError = error
|
||||
this.#channel.off('data', this.#onData)
|
||||
this.#channel.stderr.off('data', this.#onStderr)
|
||||
this.#channel.destroy()
|
||||
const activeWrite = this.#activeWrite
|
||||
this.#activeWrite = undefined
|
||||
this.#writing = false
|
||||
activeWrite?.reject(error)
|
||||
for (const waiter of this.#waiters.splice(0)) {
|
||||
if (waiter.abort !== undefined) {
|
||||
waiter.signal?.removeEventListener('abort', waiter.abort)
|
||||
}
|
||||
waiter.reject(error)
|
||||
}
|
||||
for (const write of this.#writes.splice(0)) {
|
||||
write.reject(error)
|
||||
}
|
||||
this.#incoming = []
|
||||
this.#input = Buffer.alloc(0)
|
||||
this.#incomingBytes = 0
|
||||
this.#outgoingBytes = 0
|
||||
for (const listener of this.#closeListeners) {
|
||||
listener(error)
|
||||
}
|
||||
this.#closeListeners.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function protocolFailureDiagnostic(
|
||||
stderr: Uint8Array
|
||||
): string | undefined {
|
||||
for (const line of Buffer.from(stderr).toString('utf8').split(/\r?\n/u)) {
|
||||
if (!line.startsWith(AGENT_PROTOCOL_FAILURE_STDERR_PREFIX)) {
|
||||
continue
|
||||
}
|
||||
const category = agentProtocolFailureCategorySchema.safeParse(
|
||||
line.slice(AGENT_PROTOCOL_FAILURE_STDERR_PREFIX.length)
|
||||
)
|
||||
if (category.success) {
|
||||
return category.data
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function readWelcome(
|
||||
channel: ClientChannel,
|
||||
preface: AttachPreface,
|
||||
signal: AbortSignal
|
||||
): Promise<{
|
||||
welcome: AttachWelcome
|
||||
trailingBytes: Buffer
|
||||
stderr: Buffer
|
||||
}> {
|
||||
let input = Buffer.alloc(0)
|
||||
let stderr = Buffer.alloc(0)
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const cleanup = (): void => {
|
||||
channel.off('data', onData)
|
||||
channel.stderr.off('data', onStderr)
|
||||
channel.off('error', onError)
|
||||
channel.off('close', onClose)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const fail = (error: unknown): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
channel.destroy()
|
||||
reject(error)
|
||||
}
|
||||
const onAbort = (): void => fail(signal.reason)
|
||||
const onError = (error: Error): void => fail(error)
|
||||
const onClose = (): void =>
|
||||
fail(
|
||||
new AgentAttachTransportError(
|
||||
stderr.byteLength > 0
|
||||
? 'Agent attach closed during handshake with diagnostic output'
|
||||
: 'Agent attach closed during handshake',
|
||||
'closed'
|
||||
)
|
||||
)
|
||||
const onStderr = (chunk: Buffer | string): void => {
|
||||
const bytes = Buffer.from(chunk)
|
||||
if (stderr.byteLength + bytes.byteLength > MAXIMUM_STDERR_BYTES) {
|
||||
fail(
|
||||
new AgentAttachTransportError(
|
||||
'Agent attach handshake stderr exceeded its limit',
|
||||
'stderr'
|
||||
)
|
||||
)
|
||||
} else {
|
||||
stderr = Buffer.concat([stderr, bytes])
|
||||
}
|
||||
}
|
||||
const onData = (chunk: Buffer | string): void => {
|
||||
const bytes = Buffer.from(chunk)
|
||||
if (
|
||||
input.byteLength + bytes.byteLength >
|
||||
4 +
|
||||
HANDSHAKE_MAXIMUM_BYTES +
|
||||
MAXIMUM_BUFFERED_BYTES +
|
||||
MAXIMUM_ENCODED_AGENT_FRAME_BYTES
|
||||
) {
|
||||
fail(
|
||||
new AgentAttachTransportError(
|
||||
'Agent attach handshake input exceeded its limit',
|
||||
'overflow'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
input = Buffer.concat([input, bytes])
|
||||
if (input.byteLength < 4) {
|
||||
return
|
||||
}
|
||||
const length = input.readUInt32BE(0)
|
||||
if (length < 2 || length > HANDSHAKE_MAXIMUM_BYTES) {
|
||||
fail(
|
||||
new AgentAttachTransportError(
|
||||
'Agent attach welcome is oversized',
|
||||
'malformed'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (input.byteLength < 4 + length) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
input.subarray(4, 4 + length).toString('utf8')
|
||||
)
|
||||
const welcome = attachWelcomeSchema.parse(value)
|
||||
if (
|
||||
welcome.protocol.major !== preface.protocol.major ||
|
||||
welcome.protocol.minor > preface.protocol.minor
|
||||
) {
|
||||
throw new Error('Agent attach protocol version is incompatible')
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve({
|
||||
welcome,
|
||||
trailingBytes: input.subarray(4 + length),
|
||||
stderr
|
||||
})
|
||||
} catch (error) {
|
||||
fail(
|
||||
new AgentAttachTransportError(
|
||||
'Agent attach welcome is malformed',
|
||||
'malformed',
|
||||
error
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
channel.on('data', onData)
|
||||
channel.stderr.on('data', onStderr)
|
||||
channel.once('error', onError)
|
||||
channel.once('close', onClose)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
const contents = Buffer.from(JSON.stringify(preface), 'utf8')
|
||||
if (contents.byteLength > HANDSHAKE_MAXIMUM_BYTES) {
|
||||
fail(
|
||||
new AgentAttachTransportError(
|
||||
'Agent attach preface is oversized',
|
||||
'malformed'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
const packet = Buffer.allocUnsafe(contents.byteLength + 4)
|
||||
packet.writeUInt32BE(contents.byteLength, 0)
|
||||
contents.copy(packet, 4)
|
||||
channel.write(packet, (error?: Error | null) => {
|
||||
if (error !== undefined && error !== null) {
|
||||
fail(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function encodedFrameLength(frame: AgentFrame): number {
|
||||
return (
|
||||
AGENT_FRAME_FIXED_HEADER_BYTES +
|
||||
Buffer.byteLength(frame.header.connectionId) +
|
||||
Buffer.byteLength(frame.header.channelId) +
|
||||
frame.payload.byteLength
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
sign,
|
||||
type KeyObject
|
||||
} from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest'
|
||||
import type {
|
||||
AgentBundleManifest,
|
||||
AgentReleaseKeyRegistry,
|
||||
AgentRuntimeLock
|
||||
} from '../../shared/agent-installation-contracts'
|
||||
import {
|
||||
agentManifestSignaturePayload,
|
||||
canonicalAgentManifestBytes,
|
||||
verifyAgentBundleDirectory,
|
||||
verifyAgentManifestSignature,
|
||||
type VerifiedAgentBundle
|
||||
} from './agent-bundle-verifier'
|
||||
|
||||
type BuildAgentBundleModule = {
|
||||
canonicalManifestBytes(
|
||||
manifest: AgentBundleManifest
|
||||
): Buffer
|
||||
createManifest(
|
||||
directory: string,
|
||||
metadata: {
|
||||
agentVersion: string
|
||||
nodeVersion: string
|
||||
zodVersion: string
|
||||
koffiVersion: string
|
||||
koffiNativePackage: string
|
||||
arch: 'x64' | 'arm64'
|
||||
protocol: { major: number; minor: number }
|
||||
signingKeyId: string
|
||||
}
|
||||
): AgentBundleManifest
|
||||
signaturePayload(manifestBytes: Buffer): Buffer
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const buildAgentBundle = require(
|
||||
'../../../build/agent-bundle.cjs'
|
||||
) as BuildAgentBundleModule
|
||||
|
||||
let directory: string
|
||||
let privateKey: KeyObject
|
||||
let registry: AgentReleaseKeyRegistry
|
||||
let runtimeLock: AgentRuntimeLock
|
||||
|
||||
function elf(architecture: 'x64' | 'arm64'): Buffer {
|
||||
const contents = Buffer.alloc(64)
|
||||
contents.set([0x7f, 0x45, 0x4c, 0x46])
|
||||
contents[5] = 1
|
||||
contents.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18)
|
||||
return contents
|
||||
}
|
||||
|
||||
function writeKoffiPayload(
|
||||
bundleDirectory: string,
|
||||
architecture: 'x64' | 'arm64'
|
||||
): void {
|
||||
const packageRoot = join(
|
||||
bundleDirectory,
|
||||
'lib',
|
||||
'node_modules',
|
||||
'koffi'
|
||||
)
|
||||
const nativePackage = `@koromix/koffi-linux-${architecture}`
|
||||
const nativeRoot = join(
|
||||
bundleDirectory,
|
||||
'lib',
|
||||
'node_modules',
|
||||
...nativePackage.split('/')
|
||||
)
|
||||
mkdirSync(join(packageRoot, 'src', 'koffi', 'src'), {
|
||||
recursive: true
|
||||
})
|
||||
mkdirSync(join(nativeRoot, `linux_${architecture}`), {
|
||||
recursive: true
|
||||
})
|
||||
mkdirSync(join(nativeRoot, `musl_${architecture}`), {
|
||||
recursive: true
|
||||
})
|
||||
const files = new Map<string, string | Buffer>([
|
||||
[
|
||||
join(packageRoot, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'koffi',
|
||||
version: '3.1.4',
|
||||
type: 'module',
|
||||
exports: { '.': { import: './index.js' } }
|
||||
})
|
||||
],
|
||||
[
|
||||
join(packageRoot, 'index.js'),
|
||||
'export { default } from "./src/koffi/index.js";\n'
|
||||
],
|
||||
[
|
||||
join(packageRoot, 'src', 'koffi', 'index.js'),
|
||||
'export default {};\n'
|
||||
],
|
||||
[
|
||||
join(packageRoot, 'src', 'koffi', 'src', 'static.js'),
|
||||
'export function loadStatic() { return null }\n'
|
||||
],
|
||||
[
|
||||
join(nativeRoot, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: nativePackage,
|
||||
version: '3.1.4',
|
||||
main: './index.js',
|
||||
os: ['linux'],
|
||||
cpu: [architecture]
|
||||
})
|
||||
],
|
||||
[
|
||||
join(nativeRoot, 'index.js'),
|
||||
`module.exports = require('./linux_${architecture}/koffi.node');\n`
|
||||
],
|
||||
[
|
||||
join(nativeRoot, `linux_${architecture}`, 'koffi.node'),
|
||||
elf(architecture)
|
||||
],
|
||||
[
|
||||
join(nativeRoot, `musl_${architecture}`, 'koffi.node'),
|
||||
elf(architecture)
|
||||
]
|
||||
])
|
||||
for (const [filePath, contents] of files) {
|
||||
writeFileSync(filePath, contents)
|
||||
chmodSync(filePath, 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
function writeBundle(
|
||||
signingKeyId = 'generated-test-key'
|
||||
): AgentBundleManifest {
|
||||
mkdirSync(join(directory, 'lib'), { recursive: true })
|
||||
mkdirSync(join(directory, 'licenses'), { recursive: true })
|
||||
writeFileSync(join(directory, 'node'), elf('x64'))
|
||||
writeFileSync(join(directory, 'goodbuddy-agent'), '#!/bin/sh\n')
|
||||
writeFileSync(
|
||||
join(directory, 'lib', 'agent.cjs'),
|
||||
'module.exports = {}\n'
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, 'licenses', 'GoodBuddy-0BSD.txt'),
|
||||
'0BSD'
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, 'licenses', 'Node.js-MIT.txt'),
|
||||
'MIT'
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, 'licenses', 'zod-MIT.txt'),
|
||||
'MIT'
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, 'licenses', 'koffi-MIT.txt'),
|
||||
'MIT'
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, 'licenses', 'koffi-native-MIT.txt'),
|
||||
'MIT'
|
||||
)
|
||||
writeKoffiPayload(directory, 'x64')
|
||||
for (const filePath of ['node', 'goodbuddy-agent']) {
|
||||
chmodSync(join(directory, filePath), 0o755)
|
||||
}
|
||||
for (const filePath of [
|
||||
'lib/agent.cjs',
|
||||
'licenses/GoodBuddy-0BSD.txt',
|
||||
'licenses/Node.js-MIT.txt',
|
||||
'licenses/zod-MIT.txt',
|
||||
'licenses/koffi-MIT.txt',
|
||||
'licenses/koffi-native-MIT.txt'
|
||||
]) {
|
||||
chmodSync(join(directory, ...filePath.split('/')), 0o644)
|
||||
}
|
||||
|
||||
const manifest = buildAgentBundle.createManifest(directory, {
|
||||
agentVersion: '0.11.0',
|
||||
nodeVersion: '24.19.0',
|
||||
zodVersion: '4.4.3',
|
||||
koffiVersion: '3.1.4',
|
||||
koffiNativePackage: '@koromix/koffi-linux-x64',
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
signingKeyId
|
||||
})
|
||||
const manifestBytes =
|
||||
buildAgentBundle.canonicalManifestBytes(manifest)
|
||||
writeFileSync(join(directory, 'manifest.json'), manifestBytes)
|
||||
writeFileSync(
|
||||
join(directory, 'manifest.sig'),
|
||||
`${sign(
|
||||
null,
|
||||
buildAgentBundle.signaturePayload(manifestBytes),
|
||||
privateKey
|
||||
).toString('base64')}\n`
|
||||
)
|
||||
chmodSync(join(directory, 'manifest.json'), 0o644)
|
||||
chmodSync(join(directory, 'manifest.sig'), 0o644)
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function verifyBundle(): Promise<VerifiedAgentBundle> {
|
||||
return verifyAgentBundleDirectory(directory, {
|
||||
architecture: 'x64',
|
||||
registry,
|
||||
runtimeLock,
|
||||
verificationEnvironment: 'test',
|
||||
enforceFilesystemMode: process.platform !== 'win32'
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-runtime-agent-bundle-')
|
||||
)
|
||||
const generated = generateKeyPairSync('ed25519')
|
||||
privateKey = generated.privateKey
|
||||
registry = {
|
||||
formatVersion: 1,
|
||||
keys: [
|
||||
{
|
||||
keyId: 'generated-test-key',
|
||||
publicKeySpkiBase64: generated.publicKey
|
||||
.export({ format: 'der', type: 'spki' })
|
||||
.toString('base64'),
|
||||
environment: 'test'
|
||||
}
|
||||
],
|
||||
revocations: []
|
||||
}
|
||||
const target = {
|
||||
archive: 'node-v24.19.0-linux.tar.gz',
|
||||
sha256: 'a'.repeat(64),
|
||||
binaryPath: 'node/bin/node',
|
||||
licensePath: 'node/LICENSE'
|
||||
}
|
||||
runtimeLock = {
|
||||
formatVersion: 1,
|
||||
agentVersion: '0.11.0',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
node: {
|
||||
version: '24.19.0',
|
||||
source: 'https://nodejs.org/dist/v24.19.0/',
|
||||
targets: {
|
||||
'linux-x64': target,
|
||||
'linux-arm64': target
|
||||
}
|
||||
},
|
||||
koffi: { version: '3.1.4' }
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('runtime and build-time Agent verifier parity', () => {
|
||||
it('uses the exact build-time canonical bytes and signature domain', async () => {
|
||||
const manifest = writeBundle()
|
||||
const manifestBytes = readFileSync(
|
||||
join(directory, 'manifest.json')
|
||||
)
|
||||
|
||||
expect(canonicalAgentManifestBytes(manifest)).toEqual(
|
||||
buildAgentBundle.canonicalManifestBytes(manifest)
|
||||
)
|
||||
expect(agentManifestSignaturePayload(manifestBytes)).toEqual(
|
||||
buildAgentBundle.signaturePayload(manifestBytes)
|
||||
)
|
||||
await expect(verifyBundle()).resolves.toMatchObject({
|
||||
manifest: {
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects corrupt, undeclared, missing, and architecture-mismatched payloads', async () => {
|
||||
writeBundle()
|
||||
writeFileSync(join(directory, 'lib', 'agent.cjs'), 'corrupt')
|
||||
await expect(verifyBundle()).rejects.toThrow(
|
||||
/size mismatch|hash mismatch/u
|
||||
)
|
||||
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
directory = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-runtime-agent-bundle-')
|
||||
)
|
||||
writeBundle()
|
||||
writeFileSync(join(directory, 'undeclared.txt'), 'no')
|
||||
await expect(verifyBundle()).rejects.toThrow(
|
||||
'undeclared or missing files'
|
||||
)
|
||||
|
||||
rmSync(join(directory, 'licenses', 'zod-MIT.txt'))
|
||||
await expect(verifyBundle()).rejects.toThrow()
|
||||
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
directory = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-runtime-agent-bundle-')
|
||||
)
|
||||
writeBundle()
|
||||
writeFileSync(join(directory, 'node'), elf('arm64'))
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(directory, 'manifest.json'), 'utf8')
|
||||
) as AgentBundleManifest
|
||||
const nodeFile = manifest.files.find((file) => file.path === 'node')
|
||||
if (!nodeFile) {
|
||||
throw new Error('Fixture Node entry is missing')
|
||||
}
|
||||
nodeFile.sha256 = createHash('sha256')
|
||||
.update(elf('arm64'))
|
||||
.digest('hex')
|
||||
const bytes = canonicalAgentManifestBytes(manifest)
|
||||
writeFileSync(join(directory, 'manifest.json'), bytes)
|
||||
writeFileSync(
|
||||
join(directory, 'manifest.sig'),
|
||||
`${sign(
|
||||
null,
|
||||
agentManifestSignaturePayload(bytes),
|
||||
privateKey
|
||||
).toString('base64')}\n`
|
||||
)
|
||||
await expect(verifyBundle()).rejects.toThrow(
|
||||
'architecture mismatch'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses symlink payloads', async () => {
|
||||
writeBundle()
|
||||
const targetDirectory = join(directory, '.lib-target')
|
||||
renameSync(
|
||||
join(directory, 'lib'),
|
||||
targetDirectory
|
||||
)
|
||||
symlinkSync(
|
||||
targetDirectory,
|
||||
join(directory, 'lib'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
|
||||
await expect(verifyBundle()).rejects.toThrow(
|
||||
/symlink|regular file/u
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects non-canonical manifests and signature corruption', () => {
|
||||
const manifest = writeBundle()
|
||||
const canonical = canonicalAgentManifestBytes(manifest)
|
||||
const nonCanonical = Buffer.from(JSON.stringify(manifest), 'utf8')
|
||||
const nonCanonicalSignature = sign(
|
||||
null,
|
||||
agentManifestSignaturePayload(nonCanonical),
|
||||
privateKey
|
||||
)
|
||||
expect(() =>
|
||||
verifyAgentManifestSignature(
|
||||
nonCanonical,
|
||||
nonCanonicalSignature,
|
||||
registry,
|
||||
'test'
|
||||
)
|
||||
).toThrow('not in canonical deterministic form')
|
||||
|
||||
const signature = sign(
|
||||
null,
|
||||
agentManifestSignaturePayload(canonical),
|
||||
privateKey
|
||||
)
|
||||
signature[0] = (signature[0] ?? 0) ^ 1
|
||||
expect(() =>
|
||||
verifyAgentManifestSignature(
|
||||
canonical,
|
||||
signature,
|
||||
registry,
|
||||
'test'
|
||||
)
|
||||
).toThrow('signature verification failed')
|
||||
})
|
||||
|
||||
it('enforces key revocation and production keys', () => {
|
||||
const manifest = writeBundle()
|
||||
const manifestBytes = canonicalAgentManifestBytes(manifest)
|
||||
const signature = sign(
|
||||
null,
|
||||
agentManifestSignaturePayload(manifestBytes),
|
||||
privateKey
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
verifyAgentManifestSignature(
|
||||
manifestBytes,
|
||||
signature,
|
||||
{ ...registry, keys: [] }
|
||||
)
|
||||
).toThrow('production key registry is empty')
|
||||
expect(() =>
|
||||
verifyAgentManifestSignature(
|
||||
manifestBytes,
|
||||
signature,
|
||||
{
|
||||
...registry,
|
||||
revocations: [
|
||||
{
|
||||
keyId: 'generated-test-key'
|
||||
}
|
||||
]
|
||||
},
|
||||
'test'
|
||||
)
|
||||
).toThrow('is revoked')
|
||||
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,639 @@
|
||||
import {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
verify
|
||||
} from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
readFile,
|
||||
readdir
|
||||
} from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import {
|
||||
agentBundleManifestSchema,
|
||||
agentReleaseKeyRegistrySchema,
|
||||
agentRuntimeLockSchema,
|
||||
type AgentArchitecture,
|
||||
type AgentBundleManifest,
|
||||
type AgentReleaseKey,
|
||||
type AgentReleaseKeyRegistry,
|
||||
type AgentRuntimeLock
|
||||
} from '../../shared/agent-installation-contracts'
|
||||
import {
|
||||
getBundledAgentDirectory,
|
||||
type BundledAgentResourcePaths
|
||||
} from './bundled-agent-resources'
|
||||
|
||||
const AGENT_MANIFEST_SIGNATURE_DOMAIN = Buffer.from(
|
||||
'GoodBuddy Agent Bundle Manifest Signature v1\0',
|
||||
'utf8'
|
||||
)
|
||||
const MANIFEST_FILE_NAME = 'manifest.json'
|
||||
const SIGNATURE_FILE_NAME = 'manifest.sig'
|
||||
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
||||
|
||||
export type AgentVerificationEnvironment = 'production' | 'test'
|
||||
|
||||
export type VerifiedAgentBundle = {
|
||||
bundleDirectory: string
|
||||
manifest: AgentBundleManifest
|
||||
manifestSha256: string
|
||||
}
|
||||
|
||||
export type VerifyAgentBundleOptions = {
|
||||
architecture: AgentArchitecture
|
||||
registry: AgentReleaseKeyRegistry
|
||||
runtimeLock: AgentRuntimeLock
|
||||
verificationEnvironment?: AgentVerificationEnvironment
|
||||
enforceFilesystemMode?: boolean
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
export type VerifyBundledAgentOptions = {
|
||||
verificationEnvironment?: AgentVerificationEnvironment
|
||||
enforceFilesystemMode?: boolean
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
export async function readAgentReleaseKeyRegistry(
|
||||
filePath: string
|
||||
): Promise<AgentReleaseKeyRegistry> {
|
||||
const value = await readJsonMetadata(
|
||||
filePath,
|
||||
'Agent trusted key registry'
|
||||
)
|
||||
return parseAgentReleaseKeyRegistry(value)
|
||||
}
|
||||
|
||||
export async function readAgentRuntimeLock(
|
||||
filePath: string
|
||||
): Promise<AgentRuntimeLock> {
|
||||
const value = await readJsonMetadata(filePath, 'Agent runtime lock')
|
||||
return parseContract(
|
||||
agentRuntimeLockSchema,
|
||||
value,
|
||||
'Agent runtime lock contract is invalid'
|
||||
)
|
||||
}
|
||||
|
||||
export async function verifyBundledAgentResources(
|
||||
paths: BundledAgentResourcePaths,
|
||||
architecture: AgentArchitecture,
|
||||
options: VerifyBundledAgentOptions = {}
|
||||
): Promise<VerifiedAgentBundle> {
|
||||
const [registry, runtimeLock] = await Promise.all([
|
||||
readAgentReleaseKeyRegistry(paths.keyRegistryPath),
|
||||
readAgentRuntimeLock(paths.runtimeLockPath)
|
||||
])
|
||||
return verifyAgentBundleDirectory(
|
||||
getBundledAgentDirectory(paths, architecture),
|
||||
{
|
||||
...options,
|
||||
architecture,
|
||||
registry,
|
||||
runtimeLock
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function verifyAgentBundleDirectory(
|
||||
bundleDirectory: string,
|
||||
options: VerifyAgentBundleOptions
|
||||
): Promise<VerifiedAgentBundle> {
|
||||
const registry = parseAgentReleaseKeyRegistry(options.registry)
|
||||
const runtimeLock = parseContract(
|
||||
agentRuntimeLockSchema,
|
||||
options.runtimeLock,
|
||||
'Agent runtime lock contract is invalid'
|
||||
)
|
||||
const verificationEnvironment =
|
||||
options.verificationEnvironment ?? 'production'
|
||||
assertProductionRegistryAvailable(registry, verificationEnvironment)
|
||||
await assertDirectoryWithoutSymlink(
|
||||
bundleDirectory,
|
||||
'Agent bundle directory'
|
||||
)
|
||||
|
||||
const manifestBytes = await readBoundedFile(
|
||||
join(bundleDirectory, MANIFEST_FILE_NAME),
|
||||
MAXIMUM_METADATA_BYTES,
|
||||
'Agent manifest'
|
||||
)
|
||||
const signatureBytes = await readDetachedSignature(
|
||||
join(bundleDirectory, SIGNATURE_FILE_NAME)
|
||||
)
|
||||
const manifest = verifyAgentManifestSignature(
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
registry,
|
||||
verificationEnvironment
|
||||
)
|
||||
assertAgentManifestMatchesRuntimeLock(
|
||||
manifest,
|
||||
runtimeLock,
|
||||
options.architecture
|
||||
)
|
||||
|
||||
const declaredPaths = new Set(
|
||||
manifest.files.map((file) => file.path)
|
||||
)
|
||||
for (const file of manifest.files) {
|
||||
const absolutePath = join(
|
||||
bundleDirectory,
|
||||
...file.path.split('/')
|
||||
)
|
||||
const fileStat = await lstat(absolutePath)
|
||||
if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Agent payload is not a regular file: ${file.path}`
|
||||
)
|
||||
}
|
||||
if (fileStat.size !== file.size) {
|
||||
throw new Error(`Agent payload size mismatch: ${file.path}`)
|
||||
}
|
||||
if ((await sha256File(absolutePath)) !== file.sha256) {
|
||||
throw new Error(`Agent payload hash mismatch: ${file.path}`)
|
||||
}
|
||||
if (
|
||||
shouldEnforceFilesystemMode(options) &&
|
||||
modeString(fileStat.mode) !== file.mode
|
||||
) {
|
||||
throw new Error(`Agent payload mode mismatch: ${file.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
const actualPaths = new Set(
|
||||
await listRegularBundleFiles(bundleDirectory)
|
||||
)
|
||||
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 bundle contains undeclared or missing files')
|
||||
}
|
||||
|
||||
if (shouldEnforceFilesystemMode(options)) {
|
||||
for (const metadataName of [
|
||||
MANIFEST_FILE_NAME,
|
||||
SIGNATURE_FILE_NAME
|
||||
]) {
|
||||
const metadataStat = await lstat(
|
||||
join(bundleDirectory, metadataName)
|
||||
)
|
||||
if (
|
||||
!metadataStat.isFile() ||
|
||||
metadataStat.isSymbolicLink() ||
|
||||
modeString(metadataStat.mode) !== '0644'
|
||||
) {
|
||||
throw new Error(
|
||||
`Agent payload mode mismatch: ${metadataName}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await assertElfArchitecture(
|
||||
join(bundleDirectory, manifest.entrypoint.runtimePath),
|
||||
manifest.arch,
|
||||
'Agent Node runtime'
|
||||
)
|
||||
const koffiPaths = requiredKoffiPayloadPaths(manifest.arch)
|
||||
assertKoffiManifest(
|
||||
manifest,
|
||||
runtimeLock.koffi.version,
|
||||
koffiPaths.required
|
||||
)
|
||||
for (const nativePath of koffiPaths.native) {
|
||||
await assertElfArchitecture(
|
||||
join(bundleDirectory, ...nativePath.split('/')),
|
||||
manifest.arch,
|
||||
'Agent Koffi native binding'
|
||||
)
|
||||
}
|
||||
return {
|
||||
bundleDirectory,
|
||||
manifest,
|
||||
manifestSha256: sha256Bytes(manifestBytes)
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalAgentManifestBytes(
|
||||
manifest: AgentBundleManifest
|
||||
): Buffer {
|
||||
return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export function agentManifestSignaturePayload(
|
||||
manifestBytes: Uint8Array
|
||||
): Buffer {
|
||||
return Buffer.concat([
|
||||
AGENT_MANIFEST_SIGNATURE_DOMAIN,
|
||||
Buffer.from(manifestBytes)
|
||||
])
|
||||
}
|
||||
|
||||
export function verifyAgentManifestSignature(
|
||||
manifestBytes: Buffer,
|
||||
signatureBytes: Buffer,
|
||||
registryInput: AgentReleaseKeyRegistry,
|
||||
verificationEnvironment: AgentVerificationEnvironment = 'production'
|
||||
): AgentBundleManifest {
|
||||
const registry = parseAgentReleaseKeyRegistry(registryInput)
|
||||
assertProductionRegistryAvailable(registry, verificationEnvironment)
|
||||
|
||||
let untrustedManifest: unknown
|
||||
try {
|
||||
untrustedManifest = JSON.parse(manifestBytes.toString('utf8'))
|
||||
} catch (error) {
|
||||
throw new Error('Agent manifest JSON is invalid', { cause: error })
|
||||
}
|
||||
const manifest = parseContract(
|
||||
agentBundleManifestSchema,
|
||||
untrustedManifest,
|
||||
'Agent manifest contract is invalid'
|
||||
)
|
||||
if (!canonicalAgentManifestBytes(manifest).equals(manifestBytes)) {
|
||||
throw new Error(
|
||||
'Agent manifest is not in canonical deterministic form'
|
||||
)
|
||||
}
|
||||
const trustedKey = trustedKeyForManifest(
|
||||
manifest,
|
||||
registry,
|
||||
verificationEnvironment
|
||||
)
|
||||
const publicKey = importEd25519PublicKey(trustedKey)
|
||||
if (
|
||||
signatureBytes.length !== 64 ||
|
||||
!verify(
|
||||
null,
|
||||
agentManifestSignaturePayload(manifestBytes),
|
||||
publicKey,
|
||||
signatureBytes
|
||||
)
|
||||
) {
|
||||
throw new Error('Agent manifest signature verification failed')
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
export function assertAgentManifestMatchesRuntimeLock(
|
||||
manifest: AgentBundleManifest,
|
||||
lock: AgentRuntimeLock,
|
||||
architecture: AgentArchitecture
|
||||
): void {
|
||||
if (
|
||||
manifest.agentVersion !== lock.agentVersion
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent manifest version does not match the runtime lock'
|
||||
)
|
||||
}
|
||||
if (manifest.arch !== architecture) {
|
||||
throw new Error(
|
||||
'Agent manifest architecture does not match the selected bundle'
|
||||
)
|
||||
}
|
||||
if (
|
||||
manifest.protocol.major !== lock.protocol.major ||
|
||||
manifest.protocol.minor !== lock.protocol.minor
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent manifest protocol does not match the runtime lock'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function requiredKoffiPayloadPaths(
|
||||
architecture: AgentArchitecture
|
||||
): {
|
||||
required: string[]
|
||||
native: string[]
|
||||
} {
|
||||
const packageRoot = 'lib/node_modules/koffi'
|
||||
const nativePackage =
|
||||
`@koromix/koffi-linux-${architecture}`
|
||||
const nativeRoot = `lib/node_modules/${nativePackage}`
|
||||
return {
|
||||
required: [
|
||||
`${packageRoot}/package.json`,
|
||||
`${packageRoot}/index.js`,
|
||||
`${packageRoot}/src/koffi/index.js`,
|
||||
`${packageRoot}/src/koffi/src/static.js`,
|
||||
`${nativeRoot}/package.json`,
|
||||
`${nativeRoot}/index.js`,
|
||||
`${nativeRoot}/linux_${architecture}/koffi.node`,
|
||||
`${nativeRoot}/musl_${architecture}/koffi.node`
|
||||
],
|
||||
native: [
|
||||
`${nativeRoot}/linux_${architecture}/koffi.node`,
|
||||
`${nativeRoot}/musl_${architecture}/koffi.node`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function assertKoffiManifest(
|
||||
manifest: AgentBundleManifest,
|
||||
version: string,
|
||||
requiredPaths: readonly string[]
|
||||
): void {
|
||||
const declaredPaths = new Set(
|
||||
manifest.files.map((file) => file.path)
|
||||
)
|
||||
for (const path of requiredPaths) {
|
||||
if (!declaredPaths.has(path)) {
|
||||
throw new Error(`Agent Koffi payload is missing: ${path}`)
|
||||
}
|
||||
}
|
||||
const nativePackage =
|
||||
`@koromix/koffi-linux-${manifest.arch}`
|
||||
const koffiLicense = manifest.licenses.find(
|
||||
(license) => license.package === 'koffi'
|
||||
)
|
||||
const nativeLicense = manifest.licenses.find(
|
||||
(license) => license.package === nativePackage
|
||||
)
|
||||
if (
|
||||
koffiLicense?.version !== version ||
|
||||
koffiLicense.spdx !== 'MIT' ||
|
||||
koffiLicense.path !== 'licenses/koffi-MIT.txt' ||
|
||||
nativeLicense?.version !== version ||
|
||||
nativeLicense.spdx !== 'MIT' ||
|
||||
nativeLicense.path !== 'licenses/koffi-native-MIT.txt'
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent Koffi dependencies do not match the runtime lock'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function trustedKeyForManifest(
|
||||
manifest: AgentBundleManifest,
|
||||
registry: AgentReleaseKeyRegistry,
|
||||
verificationEnvironment: AgentVerificationEnvironment
|
||||
): AgentReleaseKey {
|
||||
const key = registry.keys.find(
|
||||
(candidate) => candidate.keyId === manifest.signingKeyId
|
||||
)
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`Agent manifest uses an unknown signing key: ${manifest.signingKeyId}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
verificationEnvironment === '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 assertProductionRegistryAvailable(
|
||||
registry: AgentReleaseKeyRegistry,
|
||||
verificationEnvironment: AgentVerificationEnvironment
|
||||
): void {
|
||||
if (
|
||||
verificationEnvironment === 'production' &&
|
||||
!registry.keys.some((key) => key.environment === 'production')
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent production key registry is empty; bundled Agent verification is unavailable'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function importEd25519PublicKey(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
|
||||
}
|
||||
|
||||
function parseAgentReleaseKeyRegistry(
|
||||
value: unknown
|
||||
): AgentReleaseKeyRegistry {
|
||||
const registry = parseContract(
|
||||
agentReleaseKeyRegistrySchema,
|
||||
value,
|
||||
'Agent trusted key registry contract is invalid'
|
||||
)
|
||||
for (const key of registry.keys) {
|
||||
importEd25519PublicKey(key)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
async function readDetachedSignature(filePath: string): Promise<Buffer> {
|
||||
const signatureText = (
|
||||
await readBoundedFile(
|
||||
filePath,
|
||||
128,
|
||||
'Agent detached signature'
|
||||
)
|
||||
).toString('utf8')
|
||||
if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(signatureText)) {
|
||||
throw new Error('Agent detached signature encoding is invalid')
|
||||
}
|
||||
return Buffer.from(signatureText.trim(), 'base64')
|
||||
}
|
||||
|
||||
async function readJsonMetadata(
|
||||
filePath: string,
|
||||
description: string
|
||||
): Promise<unknown> {
|
||||
const contents = await readBoundedFile(
|
||||
filePath,
|
||||
MAXIMUM_METADATA_BYTES,
|
||||
description
|
||||
)
|
||||
try {
|
||||
return JSON.parse(contents.toString('utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(`${description} is invalid JSON`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedFile(
|
||||
filePath: string,
|
||||
maximumBytes: number,
|
||||
description: string
|
||||
): Promise<Buffer> {
|
||||
const fileStat = await lstat(filePath)
|
||||
if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
|
||||
throw new Error(`${description} is not a regular file`)
|
||||
}
|
||||
if (fileStat.size > maximumBytes) {
|
||||
throw new Error(`${description} exceeds its size limit`)
|
||||
}
|
||||
return readFile(filePath)
|
||||
}
|
||||
|
||||
async function listRegularBundleFiles(
|
||||
bundleDirectory: string
|
||||
): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
const pending = [bundleDirectory]
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop()
|
||||
if (current === undefined) {
|
||||
continue
|
||||
}
|
||||
const entries = await readdir(current, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const absolutePath = join(current, entry.name)
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Agent bundle cannot contain a symlink: ${absolutePath}`
|
||||
)
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(absolutePath)
|
||||
} else if (entry.isFile()) {
|
||||
files.push(
|
||||
relative(bundleDirectory, absolutePath)
|
||||
.split(sep)
|
||||
.join('/')
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Agent bundle contains an unsupported file: ${absolutePath}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function assertDirectoryWithoutSymlink(
|
||||
directory: string,
|
||||
description: string
|
||||
): Promise<void> {
|
||||
const directoryStat = await lstat(directory)
|
||||
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
|
||||
throw new Error(`${description} is not a regular directory`)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
function sha256Bytes(contents: Uint8Array): string {
|
||||
return createHash('sha256').update(contents).digest('hex')
|
||||
}
|
||||
|
||||
function shouldEnforceFilesystemMode(
|
||||
options: Pick<
|
||||
VerifyAgentBundleOptions,
|
||||
'enforceFilesystemMode' | 'filesystemPlatform'
|
||||
>
|
||||
): boolean {
|
||||
return (
|
||||
options.enforceFilesystemMode !== false &&
|
||||
(options.filesystemPlatform ?? process.platform) !== 'win32'
|
||||
)
|
||||
}
|
||||
|
||||
function modeString(mode: number): string {
|
||||
return (mode & 0o777).toString(8).padStart(4, '0')
|
||||
}
|
||||
|
||||
async function assertElfArchitecture(
|
||||
filePath: string,
|
||||
expectedArchitecture: AgentArchitecture,
|
||||
description: 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 actualArchitecture = detectElfArchitecture(
|
||||
header.subarray(0, bytesRead)
|
||||
)
|
||||
if (actualArchitecture !== expectedArchitecture) {
|
||||
throw new Error(
|
||||
`${description} architecture mismatch: expected ${expectedArchitecture}, received ${actualArchitecture ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function detectElfArchitecture(
|
||||
buffer: Buffer
|
||||
): AgentArchitecture | undefined {
|
||||
if (
|
||||
buffer.length < 20 ||
|
||||
buffer[0] !== 0x7f ||
|
||||
buffer.toString('ascii', 1, 4) !== 'ELF' ||
|
||||
(buffer[5] !== 1 && buffer[5] !== 2)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const machine =
|
||||
buffer[5] === 2
|
||||
? buffer.readUInt16BE(18)
|
||||
: buffer.readUInt16LE(18)
|
||||
if (machine === 62) {
|
||||
return 'x64'
|
||||
}
|
||||
if (machine === 183) {
|
||||
return 'arm64'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseContract<T>(
|
||||
schema: { safeParse(value: unknown): {
|
||||
success: true
|
||||
data: T
|
||||
} | {
|
||||
success: false
|
||||
error: { message: string }
|
||||
} },
|
||||
value: unknown,
|
||||
message: string
|
||||
): T {
|
||||
const result = schema.safeParse(value)
|
||||
if (!result.success) {
|
||||
throw new Error(`${message}: ${result.error.message}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,569 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
AGENT_PROTOCOL_VERSION,
|
||||
type AgentFrame,
|
||||
type AttachWelcome
|
||||
} from '../../shared/agent-protocol'
|
||||
import type { AgentAttachTransport } from './agent-attach-transport'
|
||||
import {
|
||||
AgentProtocolClient,
|
||||
AgentProtocolClientError
|
||||
} from './agent-protocol-client'
|
||||
|
||||
class FakeTransport {
|
||||
readonly welcome: AttachWelcome = {
|
||||
type: 'goodbuddy-agent-welcome',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
connectionId: 'connection-1',
|
||||
generation: 3,
|
||||
installationId: 'agent-v1',
|
||||
binaryDigest: `sha256:${'a'.repeat(64)}`,
|
||||
daemonBootId: 'boot-1',
|
||||
serverNonce: 'server-nonce'
|
||||
}
|
||||
readonly sent: AgentFrame[] = []
|
||||
#frames: AgentFrame[] = []
|
||||
#readers: Array<{
|
||||
resolve: (frame: AgentFrame) => void
|
||||
reject: (error: unknown) => void
|
||||
}> = []
|
||||
#close?: (error: Error) => void
|
||||
|
||||
send(frame: AgentFrame): Promise<void> {
|
||||
this.sent.push(frame)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
receive(): Promise<AgentFrame> {
|
||||
const frame = this.#frames.shift()
|
||||
if (frame !== undefined) {
|
||||
return Promise.resolve(frame)
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#readers.push({ resolve, reject })
|
||||
})
|
||||
}
|
||||
|
||||
remote(frame: AgentFrame): void {
|
||||
const reader = this.#readers.shift()
|
||||
if (reader !== undefined) {
|
||||
reader.resolve(frame)
|
||||
} else {
|
||||
this.#frames.push(frame)
|
||||
}
|
||||
}
|
||||
|
||||
onClose(listener: (error: Error) => void): () => void {
|
||||
this.#close = listener
|
||||
return () => {
|
||||
this.#close = undefined
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
const error = new Error('disposed')
|
||||
this.#close?.(error)
|
||||
for (const reader of this.#readers.splice(0)) {
|
||||
reader.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inbound(
|
||||
outbound: AgentFrame,
|
||||
sequence: string,
|
||||
kind: AgentFrame['header']['kind'],
|
||||
value: unknown
|
||||
): AgentFrame {
|
||||
const payload =
|
||||
value instanceof Uint8Array
|
||||
? value
|
||||
: Buffer.from(JSON.stringify(value), 'utf8')
|
||||
return {
|
||||
header: {
|
||||
...outbound.header,
|
||||
direction: 'agent-to-main',
|
||||
sequence,
|
||||
kind,
|
||||
payloadLength: payload.byteLength
|
||||
},
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSent(
|
||||
transport: FakeTransport,
|
||||
count: number
|
||||
): Promise<void> {
|
||||
await vi.waitFor(() => expect(transport.sent.length).toBeGreaterThanOrEqual(count))
|
||||
}
|
||||
|
||||
describe('AgentProtocolClient', () => {
|
||||
it('strictly resolves a typed response only after matching channel close', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const result = client.request('agent/status', {})
|
||||
await waitForSent(transport, 1)
|
||||
const requestFrame = transport.sent[0]!
|
||||
const request = JSON.parse(
|
||||
Buffer.from(requestFrame.payload).toString('utf8')
|
||||
) as { id: string }
|
||||
transport.remote(
|
||||
inbound(requestFrame, '1', 'control', {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
state: 'ready',
|
||||
installationId: 'agent-v1',
|
||||
binaryDigest: `sha256:${'a'.repeat(64)}`,
|
||||
daemonBootId: 'boot-1',
|
||||
agentVersion: '1.0.0',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: 'user-1',
|
||||
draining: false
|
||||
}
|
||||
})
|
||||
)
|
||||
let settled = false
|
||||
void result.finally(() => {
|
||||
settled = true
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(settled).toBe(false)
|
||||
transport.remote(
|
||||
inbound(requestFrame, '2', 'control', {
|
||||
jsonrpc: '2.0',
|
||||
method: 'channel/close',
|
||||
params: {
|
||||
channelId: requestFrame.header.channelId,
|
||||
channelEpoch: requestFrame.header.channelEpoch
|
||||
}
|
||||
})
|
||||
)
|
||||
await expect(result).resolves.toMatchObject({ state: 'ready' })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('bounds abandoned waiters within request capacity', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
const controller = new AbortController()
|
||||
const request = client.request('agent/status', {}, {
|
||||
signal: controller.signal
|
||||
})
|
||||
controller.abort(new Error('cancelled waiter'))
|
||||
await expect(request).rejects.toThrow('cancelled waiter')
|
||||
}
|
||||
await expect(
|
||||
client.request('agent/status', {})
|
||||
).rejects.toMatchObject({
|
||||
code: 'capacity'
|
||||
})
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('retains response sequence state when cancellation races channel close', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const closed = vi.fn()
|
||||
client.onClose(closed)
|
||||
const controller = new AbortController()
|
||||
const result = client.request('agent/status', {}, {
|
||||
signal: controller.signal
|
||||
})
|
||||
await waitForSent(transport, 1)
|
||||
const requestFrame = transport.sent[0]!
|
||||
const request = JSON.parse(
|
||||
Buffer.from(requestFrame.payload).toString('utf8')
|
||||
) as { id: string }
|
||||
transport.remote(
|
||||
inbound(requestFrame, '1', 'control', {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
state: 'ready',
|
||||
installationId: 'agent-v1',
|
||||
binaryDigest: `sha256:${'a'.repeat(64)}`,
|
||||
daemonBootId: 'boot-1',
|
||||
agentVersion: '1.0.0',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: 'user-1',
|
||||
draining: false
|
||||
}
|
||||
})
|
||||
)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
controller.abort(new Error('cancelled after response'))
|
||||
await expect(result).rejects.toThrow('cancelled after response')
|
||||
|
||||
transport.remote(
|
||||
inbound(requestFrame, '2', 'control', {
|
||||
jsonrpc: '2.0',
|
||||
method: 'channel/close',
|
||||
params: {
|
||||
channelId: requestFrame.header.channelId,
|
||||
channelEpoch: requestFrame.header.channelEpoch
|
||||
}
|
||||
})
|
||||
)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
expect(closed).not.toHaveBeenCalled()
|
||||
const nextRequest = client.request('agent/status', {})
|
||||
void nextRequest.catch(() => undefined)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('acknowledges durable ACP output only after consumption', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const channel = client.registerBinaryChannel({
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
kind: 'acp'
|
||||
})
|
||||
const payload = Buffer.from('runtime event')
|
||||
transport.remote({
|
||||
header: {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: 'connection-1',
|
||||
generation: 3,
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
direction: 'agent-to-main',
|
||||
sequence: '1',
|
||||
kind: 'acp',
|
||||
payloadLength: payload.byteLength
|
||||
},
|
||||
payload
|
||||
})
|
||||
const received = await channel.receive()
|
||||
expect(transport.sent).toHaveLength(0)
|
||||
await received.consume()
|
||||
expect(transport.sent).toHaveLength(1)
|
||||
expect(transport.sent[0]!.header.kind).toBe('ack')
|
||||
expect(transport.sent[0]!.header.sequence).toBe('1')
|
||||
expect(
|
||||
JSON.parse(Buffer.from(transport.sent[0]!.payload).toString('utf8'))
|
||||
).toEqual({
|
||||
acknowledgedSequence: '1'
|
||||
})
|
||||
|
||||
await channel.send(Buffer.from('next input'))
|
||||
expect(transport.sent[1]!.header.kind).toBe('acp')
|
||||
expect(transport.sent[1]!.header.sequence).toBe('1')
|
||||
|
||||
const nextPayload = Buffer.from('next runtime event')
|
||||
transport.remote({
|
||||
header: {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: 'connection-1',
|
||||
generation: 3,
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
direction: 'agent-to-main',
|
||||
sequence: '2',
|
||||
kind: 'acp',
|
||||
payloadLength: nextPayload.byteLength
|
||||
},
|
||||
payload: nextPayload
|
||||
})
|
||||
await expect(channel.receive()).resolves.toMatchObject({
|
||||
sequence: '2',
|
||||
payload: nextPayload
|
||||
})
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('seeds resumed ACP data sequences from authoritative cursors', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const channel = client.registerBinaryChannel({
|
||||
channelId: 'binding-resumed',
|
||||
channelEpoch: '12',
|
||||
kind: 'acp',
|
||||
nextInboundSequence: '7',
|
||||
nextOutboundSequence: '5'
|
||||
})
|
||||
await channel.send(Buffer.from('new-only-input'))
|
||||
expect(transport.sent[0]!.header.sequence).toBe('5')
|
||||
|
||||
const payload = Buffer.from('replayed-output')
|
||||
transport.remote({
|
||||
header: {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: 'connection-1',
|
||||
generation: 3,
|
||||
channelId: 'binding-resumed',
|
||||
channelEpoch: '12',
|
||||
direction: 'agent-to-main',
|
||||
sequence: '7',
|
||||
kind: 'acp',
|
||||
payloadLength: payload.byteLength
|
||||
},
|
||||
payload
|
||||
})
|
||||
await expect(channel.receive()).resolves.toMatchObject({
|
||||
sequence: '7',
|
||||
payload
|
||||
})
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('notifies binary channel close listeners exactly once', () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const channel = client.registerBinaryChannel({
|
||||
channelId: 'binding-close',
|
||||
channelEpoch: '11',
|
||||
kind: 'acp'
|
||||
})
|
||||
const closed = vi.fn()
|
||||
channel.onClose(closed)
|
||||
channel.close()
|
||||
channel.close()
|
||||
expect(closed).toHaveBeenCalledOnce()
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('allocates unique Main-owned blob channels and closes with a matching notification', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const first = client.allocateBinaryChannel({ kind: 'blob' })
|
||||
const second = client.allocateBinaryChannel({ kind: 'blob' })
|
||||
expect(first.channelId).not.toBe(second.channelId)
|
||||
expect(first.channelEpoch).not.toBe(second.channelEpoch)
|
||||
|
||||
await first.closeWithNotification()
|
||||
expect(transport.sent).toHaveLength(1)
|
||||
expect(transport.sent[0]!.header).toMatchObject({
|
||||
channelId: first.channelId,
|
||||
channelEpoch: first.channelEpoch,
|
||||
kind: 'control',
|
||||
direction: 'main-to-agent'
|
||||
})
|
||||
expect(
|
||||
JSON.parse(Buffer.from(transport.sent[0]!.payload).toString('utf8'))
|
||||
).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
method: 'channel/close',
|
||||
params: {
|
||||
channelId: first.channelId,
|
||||
channelEpoch: first.channelEpoch
|
||||
}
|
||||
})
|
||||
expect(() =>
|
||||
client.registerBinaryChannel({
|
||||
channelId: first.channelId,
|
||||
channelEpoch: first.channelEpoch,
|
||||
kind: 'blob'
|
||||
})
|
||||
).toThrow()
|
||||
second.close()
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('sends one bounded blob message as one protocol frame', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const channel = client.allocateBinaryChannel({ kind: 'blob' })
|
||||
const payload = Buffer.alloc(
|
||||
AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes
|
||||
)
|
||||
|
||||
await channel.send(payload)
|
||||
|
||||
expect(transport.sent).toHaveLength(1)
|
||||
expect(transport.sent[0]!.header).toMatchObject({
|
||||
kind: 'blob',
|
||||
sequence: '1'
|
||||
})
|
||||
expect(transport.sent[0]!.payload.byteLength).toBe(
|
||||
payload.byteLength
|
||||
)
|
||||
await expect(
|
||||
channel.send(
|
||||
Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes + 1)
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'protocol' })
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('cancelled'))
|
||||
await expect(
|
||||
channel.send(Buffer.of(1), controller.signal)
|
||||
).rejects.toThrow(/cancelled/iu)
|
||||
expect(transport.sent).toHaveLength(1)
|
||||
channel.close()
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('receives sequential blob messages without transport ACK traffic', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const channel = client.allocateBinaryChannel({ kind: 'blob' })
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
const payload = Buffer.alloc(128 * 1024, index)
|
||||
transport.remote({
|
||||
header: {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: 'connection-1',
|
||||
generation: 3,
|
||||
channelId: channel.channelId,
|
||||
channelEpoch: channel.channelEpoch,
|
||||
direction: 'agent-to-main',
|
||||
sequence: String(index),
|
||||
kind: 'blob',
|
||||
payloadLength: payload.byteLength
|
||||
},
|
||||
payload
|
||||
})
|
||||
const frame = await channel.receive()
|
||||
expect(frame.payload[0]).toBe(index)
|
||||
await frame.consume()
|
||||
}
|
||||
expect(transport.sent).toHaveLength(0)
|
||||
channel.close()
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'unknown Agent-first channel',
|
||||
frame: {
|
||||
channelId: 'agent-first',
|
||||
channelEpoch: '90',
|
||||
generation: 3,
|
||||
kind: 'blob' as const
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'cross-kind frame',
|
||||
frame: {
|
||||
channelId: 'allocated',
|
||||
channelEpoch: 'allocated',
|
||||
generation: 3,
|
||||
kind: 'acp' as const
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'stale epoch',
|
||||
frame: {
|
||||
channelId: 'allocated',
|
||||
channelEpoch: '91',
|
||||
generation: 3,
|
||||
kind: 'blob' as const
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'stale generation',
|
||||
frame: {
|
||||
channelId: 'allocated',
|
||||
channelEpoch: 'allocated',
|
||||
generation: 2,
|
||||
kind: 'blob' as const
|
||||
}
|
||||
}
|
||||
])('rejects a $label', async ({ frame }) => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const closed = new Promise<AgentProtocolClientError>((resolve) => {
|
||||
client.onClose(resolve)
|
||||
})
|
||||
const allocated = client.allocateBinaryChannel({ kind: 'blob' })
|
||||
const payload = Buffer.from('invalid')
|
||||
transport.remote({
|
||||
header: {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: 'connection-1',
|
||||
generation: frame.generation,
|
||||
channelId:
|
||||
frame.channelId === 'allocated'
|
||||
? allocated.channelId
|
||||
: frame.channelId,
|
||||
channelEpoch:
|
||||
frame.channelEpoch === 'allocated'
|
||||
? allocated.channelEpoch
|
||||
: frame.channelEpoch,
|
||||
direction: 'agent-to-main',
|
||||
sequence: '1',
|
||||
kind: frame.kind,
|
||||
payloadLength: payload.byteLength
|
||||
},
|
||||
payload
|
||||
})
|
||||
await expect(closed).resolves.toBeInstanceOf(
|
||||
AgentProtocolClientError
|
||||
)
|
||||
expect(() => client.request('agent/status', {})).toThrow(
|
||||
AgentProtocolClientError
|
||||
)
|
||||
})
|
||||
|
||||
it('relies on SSH transport backpressure instead of peer credit', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const channel = client.registerBinaryChannel({
|
||||
channelId: 'binding-2',
|
||||
channelEpoch: '10',
|
||||
kind: 'acp'
|
||||
})
|
||||
await channel.send(Buffer.alloc(300 * 1024))
|
||||
expect(transport.sent[0]!.payload).toHaveLength(300 * 1024)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('fails closed on a mismatched response ID', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const request = client.request('agent/status', {})
|
||||
await waitForSent(transport, 1)
|
||||
transport.remote(
|
||||
inbound(transport.sent[0]!, '1', 'control', {
|
||||
jsonrpc: '2.0',
|
||||
id: 'wrong-request',
|
||||
result: null
|
||||
})
|
||||
)
|
||||
await expect(request).rejects.toBeInstanceOf(
|
||||
AgentProtocolClientError
|
||||
)
|
||||
expect(() => client.request('agent/status', {})).toThrow(
|
||||
AgentProtocolClientError
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getBundledAgentDirectory,
|
||||
resolveBundledAgentResourcePaths
|
||||
} from './bundled-agent-resources'
|
||||
|
||||
describe('bundled Agent resource paths', () => {
|
||||
it('resolves development resources from injected application paths', () => {
|
||||
const paths = resolveBundledAgentResourcePaths({
|
||||
appPath: join('workspace', 'goodbuddy'),
|
||||
resourcesPath: join('electron', 'resources'),
|
||||
packaged: false
|
||||
})
|
||||
|
||||
expect(paths).toEqual({
|
||||
keyRegistryPath: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'resources',
|
||||
'agent-release-keys.json'
|
||||
),
|
||||
runtimeLockPath: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'agent-runtime-lock.json'
|
||||
),
|
||||
bundleDirectories: {
|
||||
x64: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'.agent-resources',
|
||||
'linux-x64'
|
||||
),
|
||||
arm64: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'.agent-resources',
|
||||
'linux-arm64'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves packaged metadata and both Agent bundles outside ASAR', () => {
|
||||
const paths = resolveBundledAgentResourcePaths({
|
||||
appPath: join('installed', 'resources', 'app.asar'),
|
||||
resourcesPath: join('installed', 'resources'),
|
||||
packaged: true
|
||||
})
|
||||
|
||||
expect(paths.keyRegistryPath).toBe(
|
||||
join('installed', 'resources', 'agent-release-keys.json')
|
||||
)
|
||||
expect(paths.runtimeLockPath).toBe(
|
||||
join('installed', 'resources', 'agent-runtime-lock.json')
|
||||
)
|
||||
expect(getBundledAgentDirectory(paths, 'x64')).toBe(
|
||||
join('installed', 'resources', 'agents', 'linux-x64')
|
||||
)
|
||||
expect(getBundledAgentDirectory(paths, 'arm64')).toBe(
|
||||
join('installed', 'resources', 'agents', 'linux-arm64')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { join } from 'node:path'
|
||||
import type { AgentArchitecture } from '../../shared/agent-installation-contracts'
|
||||
|
||||
export type BundledAgentResourcePaths = {
|
||||
keyRegistryPath: string
|
||||
runtimeLockPath: string
|
||||
bundleDirectories: Record<AgentArchitecture, string>
|
||||
}
|
||||
|
||||
export type BundledAgentResourceEnvironment = {
|
||||
appPath: string
|
||||
resourcesPath: string
|
||||
packaged: boolean
|
||||
}
|
||||
|
||||
export function resolveBundledAgentResourcePaths(
|
||||
environment: BundledAgentResourceEnvironment
|
||||
): BundledAgentResourcePaths {
|
||||
const metadataRoot = environment.packaged
|
||||
? environment.resourcesPath
|
||||
: environment.appPath
|
||||
const bundleRoot = environment.packaged
|
||||
? join(environment.resourcesPath, 'agents')
|
||||
: join(environment.appPath, '.agent-resources')
|
||||
|
||||
return {
|
||||
keyRegistryPath: environment.packaged
|
||||
? join(metadataRoot, 'agent-release-keys.json')
|
||||
: join(metadataRoot, 'resources', 'agent-release-keys.json'),
|
||||
runtimeLockPath: join(metadataRoot, 'agent-runtime-lock.json'),
|
||||
bundleDirectories: {
|
||||
x64: join(bundleRoot, 'linux-x64'),
|
||||
arm64: join(bundleRoot, 'linux-arm64')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getBundledAgentDirectory(
|
||||
paths: BundledAgentResourcePaths,
|
||||
architecture: AgentArchitecture
|
||||
): string {
|
||||
return paths.bundleDirectories[architecture]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getBundledRemoteRuntimeRoot,
|
||||
resolveBundledRemoteRuntimeResourcePaths
|
||||
} from './bundled-remote-runtime-resources'
|
||||
|
||||
describe('bundled remote Runtime resource paths', () => {
|
||||
it('resolves development metadata and architecture roots', () => {
|
||||
const paths = resolveBundledRemoteRuntimeResourcePaths({
|
||||
appPath: join('workspace', 'goodbuddy'),
|
||||
resourcesPath: join('electron', 'resources'),
|
||||
packaged: false
|
||||
})
|
||||
|
||||
expect(paths).toEqual({
|
||||
keyRegistryPath: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'resources',
|
||||
'agent-release-keys.json'
|
||||
),
|
||||
runtimeLockPath: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'remote-runtime-lock.json'
|
||||
),
|
||||
runtimeRoots: {
|
||||
x64: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'.remote-runtime-resources',
|
||||
'linux-x64',
|
||||
'opencode'
|
||||
),
|
||||
arm64: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'.remote-runtime-resources',
|
||||
'linux-arm64',
|
||||
'opencode'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves packaged metadata and Runtime roots outside ASAR', () => {
|
||||
const paths = resolveBundledRemoteRuntimeResourcePaths({
|
||||
appPath: join('installed', 'resources', 'app.asar'),
|
||||
resourcesPath: join('installed', 'resources'),
|
||||
packaged: true
|
||||
})
|
||||
|
||||
expect(paths.keyRegistryPath).toBe(
|
||||
join('installed', 'resources', 'agent-release-keys.json')
|
||||
)
|
||||
expect(paths.runtimeLockPath).toBe(
|
||||
join('installed', 'resources', 'remote-runtime-lock.json')
|
||||
)
|
||||
expect(getBundledRemoteRuntimeRoot(paths, 'x64')).toBe(
|
||||
join(
|
||||
'installed',
|
||||
'resources',
|
||||
'remote-runtimes',
|
||||
'linux-x64',
|
||||
'opencode'
|
||||
)
|
||||
)
|
||||
expect(getBundledRemoteRuntimeRoot(paths, 'arm64')).toBe(
|
||||
join(
|
||||
'installed',
|
||||
'resources',
|
||||
'remote-runtimes',
|
||||
'linux-arm64',
|
||||
'opencode'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { join } from 'node:path'
|
||||
import type { AgentArchitecture } from '../../shared/agent-installation-contracts'
|
||||
|
||||
export type BundledRemoteRuntimeResourcePaths = {
|
||||
keyRegistryPath: string
|
||||
runtimeLockPath: string
|
||||
runtimeRoots: Record<AgentArchitecture, string>
|
||||
}
|
||||
|
||||
export type BundledRemoteRuntimeResourceEnvironment = {
|
||||
appPath: string
|
||||
resourcesPath: string
|
||||
packaged: boolean
|
||||
}
|
||||
|
||||
export function resolveBundledRemoteRuntimeResourcePaths(
|
||||
environment: BundledRemoteRuntimeResourceEnvironment
|
||||
): BundledRemoteRuntimeResourcePaths {
|
||||
if (environment.packaged) {
|
||||
return {
|
||||
keyRegistryPath: join(
|
||||
environment.resourcesPath,
|
||||
'agent-release-keys.json'
|
||||
),
|
||||
runtimeLockPath: join(
|
||||
environment.resourcesPath,
|
||||
'remote-runtime-lock.json'
|
||||
),
|
||||
runtimeRoots: {
|
||||
x64: join(
|
||||
environment.resourcesPath,
|
||||
'remote-runtimes',
|
||||
'linux-x64',
|
||||
'opencode'
|
||||
),
|
||||
arm64: join(
|
||||
environment.resourcesPath,
|
||||
'remote-runtimes',
|
||||
'linux-arm64',
|
||||
'opencode'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
keyRegistryPath: join(
|
||||
environment.appPath,
|
||||
'resources',
|
||||
'agent-release-keys.json'
|
||||
),
|
||||
runtimeLockPath: join(
|
||||
environment.appPath,
|
||||
'remote-runtime-lock.json'
|
||||
),
|
||||
runtimeRoots: {
|
||||
x64: join(
|
||||
environment.appPath,
|
||||
'.remote-runtime-resources',
|
||||
'linux-x64',
|
||||
'opencode'
|
||||
),
|
||||
arm64: join(
|
||||
environment.appPath,
|
||||
'.remote-runtime-resources',
|
||||
'linux-arm64',
|
||||
'opencode'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getBundledRemoteRuntimeRoot(
|
||||
paths: BundledRemoteRuntimeResourcePaths,
|
||||
architecture: AgentArchitecture
|
||||
): string {
|
||||
return paths.runtimeRoots[architecture]
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ControllerStateStore,
|
||||
type ControllerConnectionState,
|
||||
type ControllerStateFile,
|
||||
type PersistedControllerState
|
||||
} from './controller-state-store'
|
||||
|
||||
class MemoryStateFile implements ControllerStateFile {
|
||||
value?: unknown
|
||||
writes = 0
|
||||
|
||||
async read(): Promise<unknown | undefined> {
|
||||
return structuredClone(this.value)
|
||||
}
|
||||
|
||||
async write(value: PersistedControllerState): Promise<void> {
|
||||
this.value = structuredClone(value)
|
||||
this.writes += 1
|
||||
}
|
||||
}
|
||||
|
||||
const connection: ControllerConnectionState = {
|
||||
cacheKey: 'a'.repeat(64),
|
||||
hostId: 'host-1',
|
||||
installationId: 'agent-v1',
|
||||
protocolMajor: 1,
|
||||
previousConnectionId: 'connection-1',
|
||||
previousGeneration: 2,
|
||||
daemonBootId: 'boot-1',
|
||||
capabilityGeneration: 3,
|
||||
acpBindings: [
|
||||
{
|
||||
bindingId: 'binding-1',
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '7',
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '4',
|
||||
lastOutboundDeliveredSequence: '4',
|
||||
lastInboundJournaledSequence: '6',
|
||||
lastMainAckSequence: '5'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('ControllerStateStore', () => {
|
||||
it('creates one stable controller ID and persists only active recovery state', async () => {
|
||||
const file = new MemoryStateFile()
|
||||
const first = new ControllerStateStore(file)
|
||||
const controllerId = await first.getControllerId()
|
||||
await first.updateConnection(connection)
|
||||
first.dispose()
|
||||
|
||||
const second = new ControllerStateStore(file)
|
||||
expect(await second.getControllerId()).toBe(controllerId)
|
||||
expect(await second.getConnection(connection.cacheKey)).toEqual(
|
||||
connection
|
||||
)
|
||||
const serialized = JSON.stringify(file.value)
|
||||
expect(serialized).not.toMatch(
|
||||
/password|secret|token|payload|result|hostname/iu
|
||||
)
|
||||
})
|
||||
|
||||
it('isolates host invalidation from other cached identities', async () => {
|
||||
const file = new MemoryStateFile()
|
||||
const store = new ControllerStateStore(file)
|
||||
await store.updateConnection(connection)
|
||||
await store.updateConnection({
|
||||
...connection,
|
||||
cacheKey: 'b'.repeat(64),
|
||||
hostId: 'host-2',
|
||||
previousConnectionId: 'connection-2'
|
||||
})
|
||||
await store.invalidateHost('host-1')
|
||||
expect(await store.getConnection('a'.repeat(64))).toBeUndefined()
|
||||
expect(await store.getConnection('b'.repeat(64))).toMatchObject({
|
||||
hostId: 'host-2'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads legacy synchronization fields but omits them from new writes', async () => {
|
||||
const file = new MemoryStateFile()
|
||||
file.value = {
|
||||
version: 1,
|
||||
controllerId: 'controller-1',
|
||||
connections: [
|
||||
{
|
||||
...connection,
|
||||
eventCursors: [
|
||||
{
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
acknowledgedSequence: '8'
|
||||
}
|
||||
],
|
||||
nonterminalOperationIds: ['operation-1'],
|
||||
acpBindingIds: ['binding-1']
|
||||
}
|
||||
]
|
||||
}
|
||||
const store = new ControllerStateStore(file)
|
||||
await expect(store.getConnection(connection.cacheKey)).resolves.toEqual(
|
||||
connection
|
||||
)
|
||||
await store.updateConnection(connection)
|
||||
expect(file.value).toEqual({
|
||||
version: 1,
|
||||
controllerId: 'controller-1',
|
||||
connections: [connection]
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate ACP bindings and unknown persisted fields', async () => {
|
||||
const file = new MemoryStateFile()
|
||||
const store = new ControllerStateStore(file)
|
||||
expect(() =>
|
||||
store.updateConnection({
|
||||
...connection,
|
||||
acpBindings: [
|
||||
connection.acpBindings[0]!,
|
||||
connection.acpBindings[0]!
|
||||
]
|
||||
})
|
||||
).toThrow(/unique/iu)
|
||||
file.value = {
|
||||
version: 1,
|
||||
controllerId: 'controller-1',
|
||||
connections: [],
|
||||
password: 'must-not-load'
|
||||
}
|
||||
const malformed = new ControllerStateStore(file)
|
||||
await expect(malformed.getControllerId()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('atomically advances and removes one ACP recovery binding', async () => {
|
||||
const file = new MemoryStateFile()
|
||||
const store = new ControllerStateStore(file)
|
||||
await store.updateConnection(connection)
|
||||
await store.updateAcpBinding(
|
||||
connection.cacheKey,
|
||||
'binding-1',
|
||||
{
|
||||
...connection.acpBindings[0]!,
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '8',
|
||||
lastOutboundDeliveredSequence: '8',
|
||||
lastInboundJournaledSequence: '9',
|
||||
lastMainAckSequence: '9'
|
||||
}
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
store.getConnection(connection.cacheKey)
|
||||
).resolves.toMatchObject({
|
||||
acpBindings: [
|
||||
{
|
||||
bindingId: 'binding-1',
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '8',
|
||||
lastMainAckSequence: '9'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await store.updateAcpBinding(
|
||||
connection.cacheKey,
|
||||
'binding-1',
|
||||
undefined
|
||||
)
|
||||
await expect(
|
||||
store.getConnection(connection.cacheKey)
|
||||
).resolves.toMatchObject({
|
||||
acpBindings: []
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces many ACP cursor advances into one bounded write', async () => {
|
||||
const file = new MemoryStateFile()
|
||||
const store = new ControllerStateStore(file)
|
||||
await store.updateConnection(connection)
|
||||
const writesBeforeCursors = file.writes
|
||||
|
||||
for (let sequence = 10; sequence <= 100; sequence += 1) {
|
||||
const value = String(sequence)
|
||||
await store.updateAcpBinding(connection.cacheKey, 'binding-1', {
|
||||
...connection.acpBindings[0]!,
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: value,
|
||||
lastOutboundDeliveredSequence: value,
|
||||
lastInboundJournaledSequence: value,
|
||||
lastMainAckSequence: value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
expect(file.writes).toBe(writesBeforeCursors)
|
||||
await store.flush()
|
||||
expect(file.writes).toBe(writesBeforeCursors + 1)
|
||||
expect(
|
||||
(
|
||||
await store.getConnection(connection.cacheKey)
|
||||
)?.acpBindings[0]?.cursors.lastMainAckSequence
|
||||
).toBe('100')
|
||||
})
|
||||
|
||||
it('enforces cursor monotonicity against pending in-memory state', async () => {
|
||||
const file = new MemoryStateFile()
|
||||
const store = new ControllerStateStore(file)
|
||||
await store.updateConnection(connection)
|
||||
await store.updateAcpBinding(connection.cacheKey, 'binding-1', {
|
||||
...connection.acpBindings[0]!,
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '12',
|
||||
lastOutboundDeliveredSequence: '12',
|
||||
lastInboundJournaledSequence: '12',
|
||||
lastMainAckSequence: '12'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.updateAcpBinding(connection.cacheKey, 'binding-1', {
|
||||
...connection.acpBindings[0]!,
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '11',
|
||||
lastOutboundDeliveredSequence: '11',
|
||||
lastInboundJournaledSequence: '12',
|
||||
lastMainAckSequence: '12'
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/cannot regress/iu)
|
||||
await store.flush()
|
||||
expect(
|
||||
(
|
||||
await store.getConnection(connection.cacheKey)
|
||||
)?.acpBindings[0]?.cursors.lastOutboundJournaledSequence
|
||||
).toBe('12')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,432 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
acpBindingCursorsSchema,
|
||||
agentIdentifierSchema,
|
||||
agentSequenceSchema,
|
||||
positiveAgentSequenceSchema
|
||||
} from '../../shared/agent-protocol'
|
||||
import {
|
||||
isMissingFileError,
|
||||
writeJsonFileAtomically
|
||||
} from '../settings-file-utils'
|
||||
|
||||
const MAXIMUM_CONNECTION_RECORDS = 128
|
||||
const MAXIMUM_EVENT_CURSORS = 1_024
|
||||
const MAXIMUM_NONTERMINAL_OPERATIONS = 4_096
|
||||
const MAXIMUM_ACP_BINDINGS = 128
|
||||
const ACP_BINDING_WRITE_DELAY_MS = 25
|
||||
|
||||
const eventCursorSchema = z
|
||||
.object({
|
||||
streamId: agentIdentifierSchema,
|
||||
streamEpoch: positiveAgentSequenceSchema,
|
||||
acknowledgedSequence: agentSequenceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const acpRecoveryBindingSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
channelId: agentIdentifierSchema,
|
||||
channelEpoch: positiveAgentSequenceSchema,
|
||||
cursors: acpBindingCursorsSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const connectionStateSchema = z
|
||||
.object({
|
||||
cacheKey: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
hostId: agentIdentifierSchema,
|
||||
installationId: agentIdentifierSchema,
|
||||
protocolMajor: z.number().int().min(0).max(65_535),
|
||||
previousConnectionId: agentIdentifierSchema,
|
||||
previousGeneration: z.number().int().min(1).max(0xffff_ffff),
|
||||
daemonBootId: agentIdentifierSchema,
|
||||
capabilityGeneration: z.number().int().min(1).max(0xffff_ffff),
|
||||
acpBindings: z
|
||||
.array(acpRecoveryBindingSchema)
|
||||
.max(MAXIMUM_ACP_BINDINGS)
|
||||
.default([])
|
||||
})
|
||||
.strict()
|
||||
.superRefine((state, context) => {
|
||||
const bindingIds = new Set<string>()
|
||||
for (let index = 0; index < state.acpBindings.length; index += 1) {
|
||||
const bindingId = state.acpBindings[index]!.bindingId
|
||||
if (bindingIds.has(bindingId)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['acpBindings', index, 'bindingId'],
|
||||
message: 'ACP recovery binding identities must be unique'
|
||||
})
|
||||
}
|
||||
bindingIds.add(bindingId)
|
||||
}
|
||||
})
|
||||
|
||||
const compatibleConnectionStateSchema = z
|
||||
.object({
|
||||
eventCursors: z
|
||||
.array(eventCursorSchema)
|
||||
.max(MAXIMUM_EVENT_CURSORS)
|
||||
.optional(),
|
||||
nonterminalOperationIds: z
|
||||
.array(agentIdentifierSchema)
|
||||
.max(MAXIMUM_NONTERMINAL_OPERATIONS)
|
||||
.optional(),
|
||||
acpBindingIds: z
|
||||
.array(agentIdentifierSchema)
|
||||
.max(MAXIMUM_ACP_BINDINGS)
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
.transform((state) => {
|
||||
const migrated = { ...state }
|
||||
delete migrated.eventCursors
|
||||
delete migrated.nonterminalOperationIds
|
||||
delete migrated.acpBindingIds
|
||||
return connectionStateSchema.parse(migrated)
|
||||
})
|
||||
|
||||
const persistedControllerStateSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
controllerId: agentIdentifierSchema,
|
||||
connections: z
|
||||
.array(connectionStateSchema)
|
||||
.max(MAXIMUM_CONNECTION_RECORDS)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((state, context) => {
|
||||
const keys = new Set<string>()
|
||||
for (let index = 0; index < state.connections.length; index += 1) {
|
||||
const key = state.connections[index]!.cacheKey
|
||||
if (keys.has(key)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['connections', index, 'cacheKey'],
|
||||
message: 'Controller connection cache keys must be unique'
|
||||
})
|
||||
}
|
||||
keys.add(key)
|
||||
}
|
||||
})
|
||||
|
||||
const persistedControllerStateReadSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
controllerId: agentIdentifierSchema,
|
||||
connections: z
|
||||
.array(compatibleConnectionStateSchema)
|
||||
.max(MAXIMUM_CONNECTION_RECORDS)
|
||||
})
|
||||
.strict()
|
||||
.transform((state) => persistedControllerStateSchema.parse(state))
|
||||
|
||||
export type ControllerAcpRecoveryBinding = z.infer<
|
||||
typeof acpRecoveryBindingSchema
|
||||
>
|
||||
export type ControllerConnectionState = z.infer<
|
||||
typeof connectionStateSchema
|
||||
>
|
||||
export type PersistedControllerState = z.infer<
|
||||
typeof persistedControllerStateSchema
|
||||
>
|
||||
|
||||
export interface ControllerStateFile {
|
||||
read(): Promise<unknown | undefined>
|
||||
write(value: PersistedControllerState): Promise<void>
|
||||
}
|
||||
|
||||
export class JsonControllerStateFile implements ControllerStateFile {
|
||||
constructor(readonly filePath: string) {}
|
||||
|
||||
async read(): Promise<unknown | undefined> {
|
||||
try {
|
||||
return JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async write(value: PersistedControllerState): Promise<void> {
|
||||
await writeJsonFileAtomically(this.filePath, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted shape deliberately excludes SSH targets, credentials,
|
||||
* operation payloads/results, event payloads, capabilities, and tokens.
|
||||
*/
|
||||
export class ControllerStateStore {
|
||||
readonly #file: ControllerStateFile
|
||||
#state?: PersistedControllerState
|
||||
#load?: Promise<PersistedControllerState>
|
||||
#updates: Promise<void> = Promise.resolve()
|
||||
#acpBindingWriteTimer?: ReturnType<typeof setTimeout>
|
||||
#acpBindingsDirty = false
|
||||
#disposed = false
|
||||
|
||||
constructor(file: string | ControllerStateFile) {
|
||||
this.#file =
|
||||
typeof file === 'string' ? new JsonControllerStateFile(file) : file
|
||||
}
|
||||
|
||||
async getControllerId(): Promise<string> {
|
||||
return (await this.#ensureLoaded()).controllerId
|
||||
}
|
||||
|
||||
async getConnection(
|
||||
cacheKey: string
|
||||
): Promise<ControllerConnectionState | undefined> {
|
||||
assertCacheKey(cacheKey)
|
||||
const state = await this.#ensureLoaded()
|
||||
const connection = state.connections.find(
|
||||
(candidate) => candidate.cacheKey === cacheKey
|
||||
)
|
||||
return connection === undefined
|
||||
? undefined
|
||||
: structuredClone(connection)
|
||||
}
|
||||
|
||||
updateConnection(connection: ControllerConnectionState): Promise<void> {
|
||||
const parsed = connectionStateSchema.parse(connection)
|
||||
return this.#queue(async () => {
|
||||
const state = structuredClone(await this.#ensureLoaded())
|
||||
const index = state.connections.findIndex(
|
||||
(candidate) => candidate.cacheKey === parsed.cacheKey
|
||||
)
|
||||
if (index >= 0) {
|
||||
state.connections[index] = parsed
|
||||
} else {
|
||||
if (state.connections.length >= MAXIMUM_CONNECTION_RECORDS) {
|
||||
throw new Error('Controller connection state limit reached')
|
||||
}
|
||||
state.connections.push(parsed)
|
||||
}
|
||||
await this.#writeState(state)
|
||||
})
|
||||
}
|
||||
|
||||
updateConnectionPreservingRecovery(
|
||||
connection: ControllerConnectionState
|
||||
): Promise<void> {
|
||||
const parsed = connectionStateSchema.parse(connection)
|
||||
return this.#queue(async () => {
|
||||
const state = structuredClone(await this.#ensureLoaded())
|
||||
const index = state.connections.findIndex(
|
||||
(candidate) => candidate.cacheKey === parsed.cacheKey
|
||||
)
|
||||
if (index < 0) {
|
||||
throw new Error('Controller connection state is unavailable')
|
||||
}
|
||||
const current = state.connections[index]!
|
||||
state.connections[index] = connectionStateSchema.parse({
|
||||
...parsed,
|
||||
acpBindings: current.acpBindings
|
||||
})
|
||||
await this.#writeState(state)
|
||||
})
|
||||
}
|
||||
|
||||
updateAcpBinding(
|
||||
cacheKey: string,
|
||||
bindingId: string,
|
||||
binding: ControllerAcpRecoveryBinding | undefined
|
||||
): Promise<void> {
|
||||
assertCacheKey(cacheKey)
|
||||
const parsedBindingId = agentIdentifierSchema.parse(bindingId)
|
||||
const parsed =
|
||||
binding === undefined
|
||||
? undefined
|
||||
: acpRecoveryBindingSchema.parse(binding)
|
||||
if (parsed !== undefined && parsed.bindingId !== parsedBindingId) {
|
||||
throw new Error('Controller ACP recovery binding identity mismatch')
|
||||
}
|
||||
return this.#queue(async () => {
|
||||
const state = await this.#ensureLoaded()
|
||||
const connection = state.connections.find(
|
||||
(candidate) => candidate.cacheKey === cacheKey
|
||||
)
|
||||
if (connection === undefined) {
|
||||
throw new Error('Controller connection state is unavailable')
|
||||
}
|
||||
const previous = connection.acpBindings.find(
|
||||
(candidate) => candidate.bindingId === parsedBindingId
|
||||
)
|
||||
if (
|
||||
parsed !== undefined &&
|
||||
previous !== undefined &&
|
||||
(
|
||||
parsed.channelId !== previous.channelId ||
|
||||
parsed.channelEpoch !== previous.channelEpoch ||
|
||||
Object.keys(parsed.cursors).some(
|
||||
(key) =>
|
||||
BigInt(
|
||||
parsed.cursors[
|
||||
key as keyof typeof parsed.cursors
|
||||
]
|
||||
) <
|
||||
BigInt(
|
||||
previous.cursors[
|
||||
key as keyof typeof previous.cursors
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Controller ACP recovery identity or cursors cannot regress'
|
||||
)
|
||||
}
|
||||
connection.acpBindings = connection.acpBindings.filter(
|
||||
(candidate) => candidate.bindingId !== parsedBindingId
|
||||
)
|
||||
if (parsed !== undefined) {
|
||||
if (connection.acpBindings.length >= MAXIMUM_ACP_BINDINGS) {
|
||||
throw new Error('Controller ACP recovery state limit reached')
|
||||
}
|
||||
connection.acpBindings.push(parsed)
|
||||
}
|
||||
this.#acpBindingsDirty = true
|
||||
this.#scheduleAcpBindingWrite()
|
||||
})
|
||||
}
|
||||
|
||||
removeConnection(cacheKey: string): Promise<void> {
|
||||
assertCacheKey(cacheKey)
|
||||
return this.#queue(async () => {
|
||||
const state = structuredClone(await this.#ensureLoaded())
|
||||
const next = state.connections.filter(
|
||||
(connection) => connection.cacheKey !== cacheKey
|
||||
)
|
||||
if (next.length === state.connections.length) {
|
||||
return
|
||||
}
|
||||
state.connections = next
|
||||
await this.#writeState(state)
|
||||
})
|
||||
}
|
||||
|
||||
invalidateHost(hostId: string): Promise<void> {
|
||||
const parsedHostId = agentIdentifierSchema.parse(hostId)
|
||||
return this.#queue(async () => {
|
||||
const state = structuredClone(await this.#ensureLoaded())
|
||||
const next = state.connections.filter(
|
||||
(connection) => connection.hostId !== parsedHostId
|
||||
)
|
||||
if (next.length === state.connections.length) {
|
||||
return
|
||||
}
|
||||
state.connections = next
|
||||
await this.#writeState(state)
|
||||
})
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.#enqueue(async () => {
|
||||
await this.#writePendingAcpBindings()
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#acpBindingsDirty) {
|
||||
clearTimeout(this.#acpBindingWriteTimer)
|
||||
this.#acpBindingWriteTimer = undefined
|
||||
void this.#enqueue(async () => {
|
||||
await this.#writePendingAcpBindings()
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
this.#disposed = true
|
||||
}
|
||||
|
||||
async #ensureLoaded(): Promise<PersistedControllerState> {
|
||||
if (this.#state !== undefined) {
|
||||
return this.#state
|
||||
}
|
||||
if (this.#load !== undefined) {
|
||||
return await this.#load
|
||||
}
|
||||
this.#load = this.#loadState()
|
||||
try {
|
||||
return await this.#load
|
||||
} finally {
|
||||
this.#load = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async #loadState(): Promise<PersistedControllerState> {
|
||||
const value = await this.#file.read()
|
||||
const state =
|
||||
value === undefined
|
||||
? {
|
||||
version: 1 as const,
|
||||
controllerId: randomUUID(),
|
||||
connections: []
|
||||
}
|
||||
: persistedControllerStateReadSchema.parse(value)
|
||||
if (value === undefined) {
|
||||
await this.#file.write(state)
|
||||
}
|
||||
this.#state = state
|
||||
return state
|
||||
}
|
||||
|
||||
#queue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (this.#disposed) {
|
||||
return Promise.reject(new Error('Controller state store is closed'))
|
||||
}
|
||||
return this.#enqueue(operation)
|
||||
}
|
||||
|
||||
#enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.#updates.then(operation, operation)
|
||||
this.#updates = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
#scheduleAcpBindingWrite(): void {
|
||||
if (this.#acpBindingWriteTimer !== undefined) {
|
||||
return
|
||||
}
|
||||
this.#acpBindingWriteTimer = setTimeout(() => {
|
||||
this.#acpBindingWriteTimer = undefined
|
||||
void this.#queue(async () => {
|
||||
await this.#writePendingAcpBindings()
|
||||
}).catch(() => undefined)
|
||||
}, ACP_BINDING_WRITE_DELAY_MS)
|
||||
}
|
||||
|
||||
async #writePendingAcpBindings(): Promise<void> {
|
||||
if (!this.#acpBindingsDirty) {
|
||||
return
|
||||
}
|
||||
const state = await this.#ensureLoaded()
|
||||
await this.#file.write(state)
|
||||
this.#acpBindingsDirty = false
|
||||
}
|
||||
|
||||
async #writeState(state: PersistedControllerState): Promise<void> {
|
||||
await this.#file.write(state)
|
||||
this.#state = state
|
||||
this.#acpBindingsDirty = false
|
||||
if (this.#acpBindingWriteTimer !== undefined) {
|
||||
clearTimeout(this.#acpBindingWriteTimer)
|
||||
this.#acpBindingWriteTimer = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertCacheKey(value: string): void {
|
||||
if (!/^[a-f0-9]{64}$/u.test(value)) {
|
||||
throw new Error('Invalid controller state cache key')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export {
|
||||
agentManifestSignaturePayload,
|
||||
canonicalAgentManifestBytes,
|
||||
readAgentReleaseKeyRegistry,
|
||||
readAgentRuntimeLock,
|
||||
verifyAgentBundleDirectory,
|
||||
verifyAgentManifestSignature,
|
||||
verifyBundledAgentResources
|
||||
} from './agent-bundle-verifier'
|
||||
export type {
|
||||
AgentVerificationEnvironment,
|
||||
VerifiedAgentBundle,
|
||||
VerifyAgentBundleOptions,
|
||||
VerifyBundledAgentOptions
|
||||
} from './agent-bundle-verifier'
|
||||
export {
|
||||
getBundledAgentDirectory,
|
||||
resolveBundledAgentResourcePaths
|
||||
} from './bundled-agent-resources'
|
||||
export type {
|
||||
BundledAgentResourceEnvironment,
|
||||
BundledAgentResourcePaths
|
||||
} from './bundled-agent-resources'
|
||||
export {
|
||||
AgentAttachTransport,
|
||||
AgentAttachTransportError
|
||||
} from './agent-attach-transport'
|
||||
export type { AgentAttachTransportOptions } from './agent-attach-transport'
|
||||
export {
|
||||
AGENT_PROTOCOL_METHODS,
|
||||
AgentProtocolBinaryChannel,
|
||||
AgentProtocolClient,
|
||||
AgentProtocolClientError,
|
||||
AgentRpcError
|
||||
} from './agent-protocol-client'
|
||||
export type {
|
||||
AgentConsumedFrame,
|
||||
AgentProtocolMethod,
|
||||
AgentProtocolParams,
|
||||
AgentProtocolRequestOptions,
|
||||
AgentProtocolResult
|
||||
} from './agent-protocol-client'
|
||||
export {
|
||||
ControllerStateStore,
|
||||
JsonControllerStateFile
|
||||
} from './controller-state-store'
|
||||
export type {
|
||||
ControllerConnectionState,
|
||||
ControllerStateFile,
|
||||
PersistedControllerState
|
||||
} from './controller-state-store'
|
||||
export {
|
||||
RemoteAgentConnectionError,
|
||||
RemoteAgentConnectionManager
|
||||
} from './remote-agent-connection-manager'
|
||||
export type {
|
||||
RemoteAgentConnection,
|
||||
RemoteAgentConnectionIdentity,
|
||||
RemoteAgentConnectionState,
|
||||
RemoteAgentInstallationIdentity,
|
||||
RemoteAgentSshPool,
|
||||
RemoteAgentTargetResolver
|
||||
} from './remote-agent-connection-manager'
|
||||
export {
|
||||
ProtocolRemoteWorkspaceTransport,
|
||||
ProtocolRemoteWorkspaceTransportError
|
||||
} from './protocol-remote-workspace-transport'
|
||||
export type {
|
||||
DurableRemoteWorkspaceValidation,
|
||||
RemoteWorkspaceInstallationIdentityResolver,
|
||||
RemoteWorkspaceInstallationResolution
|
||||
} from './protocol-remote-workspace-transport'
|
||||
export {
|
||||
AgentInstallationError,
|
||||
AgentInstallationManager
|
||||
} from './agent-installation-manager'
|
||||
export type {
|
||||
AgentInstallationBundleLoader,
|
||||
AgentInstallationIdentity,
|
||||
AgentInstallationPhase,
|
||||
AgentInstallationRequestOptions,
|
||||
AgentInstallationTargetResolver
|
||||
} from './agent-installation-manager'
|
||||
export { RemoteAgentServices } from './remote-agent-services'
|
||||
export type { RemoteAgentServicesOptions } from './remote-agent-services'
|
||||
export {
|
||||
RemoteProjectSaveService,
|
||||
UnavailableRemoteProjectRuntimeValidator
|
||||
} from './remote-project-save-service'
|
||||
export type {
|
||||
RemoteProjectSaveOwner,
|
||||
RemoteProjectSaveServiceOptions,
|
||||
RemoteProjectRuntimeValidationEvidence,
|
||||
RemoteProjectRuntimeValidationInput,
|
||||
RemoteProjectRuntimeValidationLease,
|
||||
RemoteProjectRuntimeValidator
|
||||
} from './remote-project-save-service'
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MainModelBridgeDispatchError,
|
||||
dispatchFailureCode,
|
||||
dispatchFailureIsUncertain,
|
||||
validateMainModelBridgeDispatchResponse
|
||||
} from './main-model-bridge-dispatcher'
|
||||
|
||||
describe('Main model bridge dispatcher boundary', () => {
|
||||
it('treats unclassified dispatch failures as outcome-unknown', () => {
|
||||
expect(dispatchFailureIsUncertain(new Error('secret'))).toBe(true)
|
||||
expect(dispatchFailureCode(new Error('secret'))).toBe(
|
||||
'dispatch-failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('permits only an explicit pre-dispatch failure to stay definitive', () => {
|
||||
const error = new MainModelBridgeDispatchError(
|
||||
'policy-rejected',
|
||||
{ outcomeUnknown: false, postDispatch: false }
|
||||
)
|
||||
expect(dispatchFailureIsUncertain(error)).toBe(false)
|
||||
expect(dispatchFailureCode(error)).toBe('policy-rejected')
|
||||
expect(
|
||||
dispatchFailureIsUncertain(
|
||||
new MainModelBridgeDispatchError('network-failed', {
|
||||
outcomeUnknown: true,
|
||||
postDispatch: true
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('strictly validates gateway responses', () => {
|
||||
expect(
|
||||
validateMainModelBridgeDispatchResponse({
|
||||
status: 200,
|
||||
headers: {},
|
||||
bodyBase64: ''
|
||||
})
|
||||
).toEqual({
|
||||
status: 200,
|
||||
headers: {},
|
||||
bodyBase64: ''
|
||||
})
|
||||
expect(() =>
|
||||
validateMainModelBridgeDispatchResponse({
|
||||
status: 99,
|
||||
headers: {},
|
||||
bodyBase64: ''
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
ModelBridgeIdentity,
|
||||
ModelBridgePolicy
|
||||
} from '../../shared/model-bridge-contracts'
|
||||
import {
|
||||
remoteModelGatewayResponseSchema,
|
||||
type RemoteModelGatewayRequest,
|
||||
type RemoteModelGatewayResponse
|
||||
} from '../../shared/remote-model-gateway-contracts'
|
||||
|
||||
export type MainModelBridgeDispatchInput = {
|
||||
identity: ModelBridgeIdentity
|
||||
policy: ModelBridgePolicy
|
||||
request: RemoteModelGatewayRequest
|
||||
requestDigest: string
|
||||
}
|
||||
|
||||
export type MainModelBridgeDispatch = (
|
||||
input: MainModelBridgeDispatchInput,
|
||||
signal: AbortSignal
|
||||
) => Promise<RemoteModelGatewayResponse>
|
||||
|
||||
export type MainModelBridgeDelivered = (
|
||||
identity: ModelBridgeIdentity,
|
||||
requestDigest: string
|
||||
) => void | Promise<void>
|
||||
|
||||
export type MainModelBridgeFinalizePrompt = (
|
||||
identity: Pick<
|
||||
ModelBridgeIdentity,
|
||||
'bindingId' | 'promptOperationId'
|
||||
>
|
||||
) => void | Promise<void>
|
||||
|
||||
export type MainModelBridgePoison = (
|
||||
bindingId: string,
|
||||
promptOperationId: string,
|
||||
reason: string
|
||||
) => void | Promise<void>
|
||||
|
||||
/**
|
||||
* Dispatch implementations may explicitly identify a failure that happened
|
||||
* before any provider side effect. All unclassified dispatch failures are
|
||||
* conservatively treated as outcome-unknown by the session.
|
||||
*/
|
||||
export class MainModelBridgeDispatchError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
options: {
|
||||
outcomeUnknown: boolean
|
||||
postDispatch: boolean
|
||||
cause?: unknown
|
||||
}
|
||||
) {
|
||||
super('Remote model dispatch failed', {
|
||||
cause: options.cause
|
||||
})
|
||||
this.name = 'MainModelBridgeDispatchError'
|
||||
this.outcomeUnknown = options.outcomeUnknown
|
||||
this.postDispatch = options.postDispatch
|
||||
}
|
||||
|
||||
readonly outcomeUnknown: boolean
|
||||
readonly postDispatch: boolean
|
||||
}
|
||||
|
||||
export function validateMainModelBridgeDispatchResponse(
|
||||
value: unknown
|
||||
): RemoteModelGatewayResponse {
|
||||
return remoteModelGatewayResponseSchema.parse(value)
|
||||
}
|
||||
|
||||
export function dispatchFailureIsUncertain(error: unknown): boolean {
|
||||
return !(
|
||||
error instanceof MainModelBridgeDispatchError &&
|
||||
!error.outcomeUnknown &&
|
||||
!error.postDispatch
|
||||
)
|
||||
}
|
||||
|
||||
export function dispatchFailureCode(error: unknown): string {
|
||||
return error instanceof MainModelBridgeDispatchError
|
||||
? error.code
|
||||
: 'dispatch-failed'
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_PROTOCOL_LIMITS } from '../../shared/agent-protocol'
|
||||
import {
|
||||
REMOTE_MODEL_GATEWAY_LIMITS
|
||||
} from '../../shared/remote-model-gateway-contracts'
|
||||
import {
|
||||
createModelBridgeRequestMessage,
|
||||
decodeModelBridgeMessage,
|
||||
encodeModelBridgeMessage,
|
||||
modelBridgeDeliveryAckMessageSchema,
|
||||
type ModelBridgeIdentity,
|
||||
type ModelBridgePolicy
|
||||
} from '../../shared/model-bridge-contracts'
|
||||
import type { RuntimeProtocolBinaryFrame } from './protocol-remote-runtime-channel'
|
||||
import {
|
||||
MainModelBridgeDispatchError
|
||||
} from './main-model-bridge-dispatcher'
|
||||
import {
|
||||
MainModelBridgeSession
|
||||
} from './main-model-bridge-session'
|
||||
|
||||
const profileDigest = `sha256:${'a'.repeat(64)}`
|
||||
const policy: ModelBridgePolicy = {
|
||||
protocol: 'openai-responses',
|
||||
model: 'gpt-test',
|
||||
modelProfileDigest: profileDigest,
|
||||
supportsImageInput: false
|
||||
}
|
||||
|
||||
class FakeBlobChannel {
|
||||
readonly channelId = `model-${randomUUID()}`
|
||||
readonly channelEpoch = '19'
|
||||
readonly sent: Uint8Array[] = []
|
||||
consumed = 0
|
||||
closed = false
|
||||
closeNotifications = 0
|
||||
#frames: RuntimeProtocolBinaryFrame[] = []
|
||||
#waiters: Array<{
|
||||
resolve: (frame: RuntimeProtocolBinaryFrame) => void
|
||||
reject: (error: unknown) => void
|
||||
signal?: AbortSignal
|
||||
abort?: () => void
|
||||
}> = []
|
||||
#listeners = new Set<() => void>()
|
||||
|
||||
send(payload: Uint8Array, signal?: AbortSignal): Promise<void> {
|
||||
signal?.throwIfAborted()
|
||||
this.sent.push(new Uint8Array(payload))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
receive(signal?: AbortSignal): Promise<RuntimeProtocolBinaryFrame> {
|
||||
signal?.throwIfAborted()
|
||||
const frame = this.#frames.shift()
|
||||
if (frame !== undefined) {
|
||||
return Promise.resolve(frame)
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = { resolve, reject, signal }
|
||||
if (signal !== undefined) {
|
||||
const abort = (): void => {
|
||||
const index = this.#waiters.indexOf(waiter)
|
||||
if (index >= 0) {
|
||||
this.#waiters.splice(index, 1)
|
||||
}
|
||||
reject(signal.reason)
|
||||
}
|
||||
Object.assign(waiter, { abort })
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
}
|
||||
this.#waiters.push(waiter)
|
||||
})
|
||||
}
|
||||
|
||||
push(payload: Uint8Array): void {
|
||||
let consumed = false
|
||||
const frame: RuntimeProtocolBinaryFrame = {
|
||||
payload: new Uint8Array(payload),
|
||||
sequence: String(this.consumed + this.#frames.length + 1),
|
||||
consume: async () => {
|
||||
if (!consumed) {
|
||||
consumed = true
|
||||
this.consumed += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
const waiter = this.#waiters.shift()
|
||||
if (waiter === undefined) {
|
||||
this.#frames.push(frame)
|
||||
return
|
||||
}
|
||||
if (waiter.abort !== undefined) {
|
||||
waiter.signal?.removeEventListener('abort', waiter.abort)
|
||||
}
|
||||
waiter.resolve(frame)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
for (const waiter of this.#waiters.splice(0)) {
|
||||
waiter.reject(new Error('transport closed'))
|
||||
}
|
||||
for (const listener of this.#listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
async closeWithNotification(): Promise<void> {
|
||||
this.closeNotifications += 1
|
||||
this.close()
|
||||
}
|
||||
|
||||
onClose(listener: () => void): () => void {
|
||||
this.#listeners.add(listener)
|
||||
return () => this.#listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function identity(roundIndex: number): ModelBridgeIdentity {
|
||||
return {
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
requestId: `request-${roundIndex}`,
|
||||
roundIndex,
|
||||
modelProfileDigest: profileDigest,
|
||||
messageId: `message-${roundIndex}`
|
||||
}
|
||||
}
|
||||
|
||||
function request(bodyBytes = 1) {
|
||||
return {
|
||||
method: 'POST' as const,
|
||||
path: '/v1/responses' as const,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: Buffer.alloc(bodyBytes, 7).toString('base64')
|
||||
}
|
||||
}
|
||||
|
||||
const response = {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: Buffer.from('{"ok":true}').toString('base64')
|
||||
}
|
||||
|
||||
async function pushRequest(
|
||||
channel: FakeBlobChannel,
|
||||
roundIndex: number,
|
||||
options: {
|
||||
bodyBytes?: number
|
||||
requestPolicy?: ModelBridgePolicy
|
||||
} = {}
|
||||
) {
|
||||
const message = await createModelBridgeRequestMessage({
|
||||
identity: {
|
||||
...identity(roundIndex),
|
||||
modelProfileDigest:
|
||||
options.requestPolicy?.modelProfileDigest ?? profileDigest
|
||||
},
|
||||
policy: options.requestPolicy ?? policy,
|
||||
request: request(options.bodyBytes)
|
||||
})
|
||||
const payload = await encodeModelBridgeMessage(message)
|
||||
channel.push(payload)
|
||||
return { message, payload }
|
||||
}
|
||||
|
||||
async function sentMessage(
|
||||
channel: FakeBlobChannel,
|
||||
offset = 0
|
||||
) {
|
||||
await vi.waitFor(() =>
|
||||
expect(channel.sent.length).toBeGreaterThan(offset)
|
||||
)
|
||||
return {
|
||||
message: await decodeModelBridgeMessage(channel.sent[offset]!),
|
||||
nextOffset: offset + 1
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledge(
|
||||
channel: FakeBlobChannel,
|
||||
requestIdentity: ModelBridgeIdentity,
|
||||
requestDigest: string
|
||||
): Promise<void> {
|
||||
const acknowledgment = modelBridgeDeliveryAckMessageSchema.parse({
|
||||
protocol: 'goodbuddy-model-bridge-v1',
|
||||
kind: 'response-delivered',
|
||||
identity: requestIdentity,
|
||||
requestDigest
|
||||
})
|
||||
channel.push(await encodeModelBridgeMessage(acknowledgment))
|
||||
}
|
||||
|
||||
function fixture(
|
||||
overrides: Partial<
|
||||
ConstructorParameters<typeof MainModelBridgeSession>[0]
|
||||
> & { channel?: FakeBlobChannel } = {}
|
||||
) {
|
||||
const channel = overrides.channel ?? new FakeBlobChannel()
|
||||
const dispatch = vi.fn(async () => response)
|
||||
const onDelivered = vi.fn(async () => undefined)
|
||||
const finalizePrompt = vi.fn(async () => undefined)
|
||||
const poison = vi.fn(async () => undefined)
|
||||
const session = new MainModelBridgeSession({
|
||||
identity: {
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1'
|
||||
},
|
||||
policy,
|
||||
channel,
|
||||
isCurrentGeneration: () => true,
|
||||
dispatch,
|
||||
onDelivered,
|
||||
finalizePrompt,
|
||||
poison,
|
||||
requestTimeoutMs: 1_000,
|
||||
closeTimeoutMs: 100,
|
||||
...overrides
|
||||
})
|
||||
return {
|
||||
channel,
|
||||
dispatch,
|
||||
onDelivered,
|
||||
finalizePrompt,
|
||||
poison,
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
describe('MainModelBridgeSession', () => {
|
||||
it('reports provider uncertainty only after a model request begins', async () => {
|
||||
const dispatch = vi.fn(
|
||||
() => new Promise<typeof response>(() => undefined)
|
||||
)
|
||||
const bridge = fixture({ dispatch })
|
||||
expect(
|
||||
bridge.session.providerDeliveryMayBeUncertain
|
||||
).toBe(false)
|
||||
|
||||
await pushRequest(bridge.channel, 0)
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
expect(
|
||||
bridge.session.providerDeliveryMayBeUncertain
|
||||
).toBe(true)
|
||||
|
||||
await bridge.session.close('prompt-cancelled')
|
||||
})
|
||||
|
||||
it('accepts one bounded request message and records delivery', async () => {
|
||||
const bridge = fixture()
|
||||
const sent = await pushRequest(bridge.channel, 0, {
|
||||
bodyBytes: 600 * 1024
|
||||
})
|
||||
expect(sent.payload.byteLength).toBeGreaterThan(600 * 1024)
|
||||
expect(sent.payload.byteLength).toBeLessThanOrEqual(
|
||||
AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes
|
||||
)
|
||||
const outgoing = await sentMessage(bridge.channel)
|
||||
expect(outgoing.message).toMatchObject({
|
||||
kind: 'response',
|
||||
identity: sent.message.identity,
|
||||
requestDigest: sent.message.requestDigest,
|
||||
response
|
||||
})
|
||||
expect(bridge.channel.consumed).toBe(1)
|
||||
|
||||
await acknowledge(
|
||||
bridge.channel,
|
||||
sent.message.identity,
|
||||
sent.message.requestDigest
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(bridge.onDelivered).toHaveBeenCalledOnce()
|
||||
)
|
||||
await expect(bridge.session.close()).resolves.toEqual({
|
||||
clean: true,
|
||||
poisoned: false
|
||||
})
|
||||
expect(bridge.finalizePrompt).toHaveBeenCalledOnce()
|
||||
expect(bridge.poison).not.toHaveBeenCalled()
|
||||
expect(bridge.channel.closeNotifications).toBe(1)
|
||||
})
|
||||
|
||||
it('sends one bounded response message and records its delivery ACK', async () => {
|
||||
const largeResponse = {
|
||||
...response,
|
||||
bodyBase64: Buffer.alloc(600 * 1024, 3).toString('base64')
|
||||
}
|
||||
const bridge = fixture({
|
||||
dispatch: vi.fn(async () => largeResponse)
|
||||
})
|
||||
const sent = await pushRequest(bridge.channel, 0)
|
||||
const outgoing = await sentMessage(bridge.channel)
|
||||
expect(outgoing.nextOffset).toBe(1)
|
||||
expect(outgoing.message).toMatchObject({
|
||||
kind: 'response',
|
||||
response: largeResponse
|
||||
})
|
||||
|
||||
await acknowledge(
|
||||
bridge.channel,
|
||||
sent.message.identity,
|
||||
sent.message.requestDigest
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(bridge.onDelivered).toHaveBeenCalledOnce()
|
||||
)
|
||||
await expect(bridge.session.close()).resolves.toEqual({
|
||||
clean: true,
|
||||
poisoned: false
|
||||
})
|
||||
})
|
||||
|
||||
it('delivers a maximum-sized response in one bounded message', async () => {
|
||||
const channel = new FakeBlobChannel()
|
||||
const largeResponse = {
|
||||
...response,
|
||||
bodyBase64: Buffer.alloc(
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes,
|
||||
3
|
||||
).toString('base64')
|
||||
}
|
||||
const bridge = fixture({
|
||||
channel,
|
||||
dispatch: vi.fn(async () => largeResponse),
|
||||
requestTimeoutMs: 10_000
|
||||
})
|
||||
const requestMessage = await pushRequest(channel, 0)
|
||||
const outgoing = await sentMessage(channel)
|
||||
expect(outgoing.nextOffset).toBe(1)
|
||||
expect(channel.sent).toHaveLength(1)
|
||||
expect(channel.sent[0]!.byteLength).toBeLessThanOrEqual(
|
||||
AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes
|
||||
)
|
||||
expect(outgoing.message).toMatchObject({
|
||||
kind: 'response',
|
||||
response: largeResponse
|
||||
})
|
||||
await acknowledge(
|
||||
channel,
|
||||
requestMessage.message.identity,
|
||||
requestMessage.message.requestDigest
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(bridge.onDelivered).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(bridge.poison).not.toHaveBeenCalled()
|
||||
await expect(bridge.session.close()).resolves.toEqual({
|
||||
clean: true,
|
||||
poisoned: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reports prompt authority finalization failure as poisoned', async () => {
|
||||
const bridge = fixture({
|
||||
finalizePrompt: async () => {
|
||||
throw new Error('SQLite unavailable')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(bridge.session.close()).resolves.toEqual({
|
||||
clean: false,
|
||||
poisoned: true
|
||||
})
|
||||
expect(bridge.poison).toHaveBeenCalledWith(
|
||||
'binding-1',
|
||||
'prompt-1',
|
||||
'model-prompt-finalization-failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports prompt authority finalization timeout as poisoned', async () => {
|
||||
const bridge = fixture({
|
||||
finalizePrompt: async () => await new Promise(() => undefined),
|
||||
closeTimeoutMs: 10
|
||||
})
|
||||
|
||||
await expect(bridge.session.close()).resolves.toEqual({
|
||||
clean: false,
|
||||
poisoned: true
|
||||
})
|
||||
expect(bridge.poison).toHaveBeenCalledWith(
|
||||
'binding-1',
|
||||
'prompt-1',
|
||||
'model-prompt-finalization-failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('enforces exact policy before dispatch with a definitive error', async () => {
|
||||
const bridge = fixture()
|
||||
const mismatched = {
|
||||
...policy,
|
||||
model: 'another-model',
|
||||
modelProfileDigest: `sha256:${'b'.repeat(64)}`
|
||||
}
|
||||
await pushRequest(bridge.channel, 0, {
|
||||
requestPolicy: mismatched
|
||||
})
|
||||
const outgoing = await sentMessage(bridge.channel)
|
||||
expect(outgoing.message).toMatchObject({
|
||||
kind: 'error',
|
||||
error: {
|
||||
poisoned: false,
|
||||
outcomeUnknown: false,
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
expect(bridge.dispatch).not.toHaveBeenCalled()
|
||||
expect(bridge.poison).not.toHaveBeenCalled()
|
||||
await bridge.session.close()
|
||||
})
|
||||
|
||||
it('rejects an exact binding or prompt identity mismatch before dispatch', async () => {
|
||||
const bridge = fixture()
|
||||
const message = await createModelBridgeRequestMessage({
|
||||
identity: {
|
||||
...identity(0),
|
||||
bindingId: 'different-binding'
|
||||
},
|
||||
policy,
|
||||
request: request()
|
||||
})
|
||||
bridge.channel.push(await encodeModelBridgeMessage(message))
|
||||
const outgoing = await sentMessage(bridge.channel)
|
||||
expect(outgoing.message).toMatchObject({
|
||||
kind: 'error',
|
||||
error: {
|
||||
code: 'request-identity-mismatch',
|
||||
poisoned: false
|
||||
}
|
||||
})
|
||||
expect(bridge.dispatch).not.toHaveBeenCalled()
|
||||
expect(bridge.poison).not.toHaveBeenCalled()
|
||||
await bridge.session.close()
|
||||
})
|
||||
|
||||
it('supports sequential rounds only after each delivery ACK', async () => {
|
||||
const bridge = fixture()
|
||||
let offset = 0
|
||||
for (let round = 0; round < 2; round += 1) {
|
||||
const sent = await pushRequest(bridge.channel, round)
|
||||
const outgoing = await sentMessage(bridge.channel, offset)
|
||||
offset = outgoing.nextOffset
|
||||
await acknowledge(
|
||||
bridge.channel,
|
||||
sent.message.identity,
|
||||
sent.message.requestDigest
|
||||
)
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(bridge.onDelivered).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(bridge.dispatch).toHaveBeenCalledTimes(2)
|
||||
await bridge.session.close()
|
||||
})
|
||||
|
||||
it('poisons once when another request arrives before delivery ACK', async () => {
|
||||
const bridge = fixture()
|
||||
await pushRequest(bridge.channel, 0)
|
||||
await sentMessage(bridge.channel)
|
||||
await pushRequest(bridge.channel, 1)
|
||||
await expect(bridge.session.closed).resolves.toEqual({
|
||||
clean: false,
|
||||
poisoned: true
|
||||
})
|
||||
expect(bridge.dispatch).toHaveBeenCalledOnce()
|
||||
expect(bridge.poison).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('poisons once when transport closes after response and before ACK', async () => {
|
||||
const bridge = fixture()
|
||||
await pushRequest(bridge.channel, 0)
|
||||
await sentMessage(bridge.channel)
|
||||
bridge.channel.close()
|
||||
await expect(bridge.session.closed).resolves.toEqual({
|
||||
clean: false,
|
||||
poisoned: true
|
||||
})
|
||||
expect(bridge.poison).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows an in-flight delivery ACK to finish during clean prompt close', async () => {
|
||||
const bridge = fixture()
|
||||
const sent = await pushRequest(bridge.channel, 0)
|
||||
await sentMessage(bridge.channel)
|
||||
const closing = bridge.session.close('prompt-completed')
|
||||
await acknowledge(
|
||||
bridge.channel,
|
||||
sent.message.identity,
|
||||
sent.message.requestDigest
|
||||
)
|
||||
await expect(closing).resolves.toEqual({
|
||||
clean: true,
|
||||
poisoned: false
|
||||
})
|
||||
expect(bridge.onDelivered).toHaveBeenCalledOnce()
|
||||
expect(bridge.poison).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an explicitly pre-dispatch failure definitive without consuming the round', async () => {
|
||||
const definitive = new MainModelBridgeDispatchError(
|
||||
'policy-rejected',
|
||||
{ outcomeUnknown: false, postDispatch: false }
|
||||
)
|
||||
const dispatch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(definitive)
|
||||
.mockResolvedValueOnce(response)
|
||||
const bridge = fixture({ dispatch })
|
||||
await pushRequest(bridge.channel, 0)
|
||||
const first = await sentMessage(bridge.channel)
|
||||
expect(first.message).toMatchObject({
|
||||
kind: 'error',
|
||||
error: { poisoned: false, outcomeUnknown: false }
|
||||
})
|
||||
const secondRequest = await pushRequest(bridge.channel, 0)
|
||||
const second = await sentMessage(
|
||||
bridge.channel,
|
||||
first.nextOffset
|
||||
)
|
||||
expect(second.message.kind).toBe('response')
|
||||
await acknowledge(
|
||||
bridge.channel,
|
||||
secondRequest.message.identity,
|
||||
secondRequest.message.requestDigest
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(bridge.onDelivered).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(dispatch).toHaveBeenCalledTimes(2)
|
||||
expect(bridge.poison).not.toHaveBeenCalled()
|
||||
await bridge.session.close()
|
||||
})
|
||||
|
||||
it('times out an uncooperative dispatch, poisons, and never retries', async () => {
|
||||
const dispatch = vi.fn(
|
||||
() => new Promise<typeof response>(() => undefined)
|
||||
)
|
||||
const bridge = fixture({
|
||||
dispatch,
|
||||
requestTimeoutMs: 10
|
||||
})
|
||||
await pushRequest(bridge.channel, 0)
|
||||
await expect(bridge.session.closed).resolves.toEqual({
|
||||
clean: false,
|
||||
poisoned: true
|
||||
})
|
||||
expect(dispatch).toHaveBeenCalledOnce()
|
||||
expect(bridge.poison).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels an active dispatch by poisoning once without retry', async () => {
|
||||
const dispatch = vi.fn(
|
||||
() => new Promise<typeof response>(() => undefined)
|
||||
)
|
||||
const bridge = fixture({ dispatch })
|
||||
await pushRequest(bridge.channel, 0)
|
||||
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
|
||||
await expect(
|
||||
bridge.session.close('prompt-cancelled')
|
||||
).resolves.toEqual({
|
||||
clean: false,
|
||||
poisoned: true
|
||||
})
|
||||
expect(dispatch).toHaveBeenCalledOnce()
|
||||
expect(bridge.poison).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,582 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
MODEL_BRIDGE_LIMITS,
|
||||
MODEL_BRIDGE_PROTOCOL,
|
||||
decodeModelBridgeMessage,
|
||||
encodeModelBridgeMessage,
|
||||
modelBridgeErrorMessageSchema,
|
||||
modelBridgePolicySchema,
|
||||
modelBridgeResponseMessageSchema,
|
||||
type ModelBridgeIdentity,
|
||||
type ModelBridgePolicy,
|
||||
type ModelBridgeRequestMessage
|
||||
} from '../../shared/model-bridge-contracts'
|
||||
import type { RuntimeProtocolBinaryChannel } from './protocol-remote-runtime-channel'
|
||||
import {
|
||||
dispatchFailureCode,
|
||||
dispatchFailureIsUncertain,
|
||||
validateMainModelBridgeDispatchResponse,
|
||||
type MainModelBridgeDelivered,
|
||||
type MainModelBridgeDispatch,
|
||||
type MainModelBridgeFinalizePrompt,
|
||||
type MainModelBridgePoison
|
||||
} from './main-model-bridge-dispatcher'
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 180_000
|
||||
const DEFAULT_CLOSE_TIMEOUT_MS = 2_000
|
||||
|
||||
export type MainModelBridgeSessionIdentity = {
|
||||
bindingId: string
|
||||
promptOperationId: string
|
||||
}
|
||||
|
||||
export type MainModelBridgeSessionOptions = {
|
||||
identity: MainModelBridgeSessionIdentity
|
||||
policy: ModelBridgePolicy
|
||||
channel: RuntimeProtocolBinaryChannel
|
||||
isCurrentGeneration(): boolean
|
||||
dispatch: MainModelBridgeDispatch
|
||||
onDelivered: MainModelBridgeDelivered
|
||||
finalizePrompt: MainModelBridgeFinalizePrompt
|
||||
poison: MainModelBridgePoison
|
||||
requestTimeoutMs?: number
|
||||
closeTimeoutMs?: number
|
||||
}
|
||||
|
||||
export type MainModelBridgeSessionCloseResult = {
|
||||
clean: boolean
|
||||
poisoned: boolean
|
||||
}
|
||||
|
||||
type RequestPhase =
|
||||
| 'receiving'
|
||||
| 'dispatching'
|
||||
| 'sending-response'
|
||||
| 'awaiting-delivery'
|
||||
|
||||
type ActiveExchange = {
|
||||
request: ModelBridgeRequestMessage
|
||||
phase: RequestPhase
|
||||
}
|
||||
|
||||
export class MainModelBridgeSession {
|
||||
readonly version = MODEL_BRIDGE_PROTOCOL
|
||||
readonly channelId: string
|
||||
readonly channelEpoch: string
|
||||
readonly policy: ModelBridgePolicy
|
||||
readonly closed: Promise<MainModelBridgeSessionCloseResult>
|
||||
|
||||
readonly #identity: MainModelBridgeSessionIdentity
|
||||
readonly #channel: RuntimeProtocolBinaryChannel
|
||||
readonly #isCurrentGeneration: () => boolean
|
||||
readonly #dispatch: MainModelBridgeDispatch
|
||||
readonly #onDelivered: MainModelBridgeDelivered
|
||||
readonly #finalizePrompt: MainModelBridgeFinalizePrompt
|
||||
readonly #poisonCallback: MainModelBridgePoison
|
||||
readonly #requestTimeoutMs: number
|
||||
readonly #closeTimeoutMs: number
|
||||
readonly #lifetime = new AbortController()
|
||||
readonly #closedResolve: (
|
||||
result: MainModelBridgeSessionCloseResult
|
||||
) => void
|
||||
#nextRoundIndex = 0
|
||||
#active?: ActiveExchange
|
||||
#receivingRequest = false
|
||||
#poisoned = false
|
||||
#closing = false
|
||||
#closePromise?: Promise<MainModelBridgeSessionCloseResult>
|
||||
#poisonPromise?: Promise<void>
|
||||
#idleWaiters = new Set<() => void>()
|
||||
#unsubscribeClose: () => void = () => undefined
|
||||
|
||||
constructor(options: MainModelBridgeSessionOptions) {
|
||||
this.#identity = options.identity
|
||||
this.policy = modelBridgePolicySchema.parse(options.policy)
|
||||
this.#channel = options.channel
|
||||
this.channelId = options.channel.channelId
|
||||
this.channelEpoch = options.channel.channelEpoch
|
||||
this.#isCurrentGeneration = options.isCurrentGeneration
|
||||
this.#dispatch = options.dispatch
|
||||
this.#onDelivered = options.onDelivered
|
||||
this.#finalizePrompt = options.finalizePrompt
|
||||
this.#poisonCallback = options.poison
|
||||
this.#requestTimeoutMs = boundedTimeout(
|
||||
options.requestTimeoutMs,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
this.#closeTimeoutMs = boundedTimeout(
|
||||
options.closeTimeoutMs,
|
||||
DEFAULT_CLOSE_TIMEOUT_MS
|
||||
)
|
||||
let resolveClosed: (
|
||||
result: MainModelBridgeSessionCloseResult
|
||||
) => void = () => undefined
|
||||
this.closed = new Promise((resolve) => {
|
||||
resolveClosed = resolve
|
||||
})
|
||||
this.#closedResolve = resolveClosed
|
||||
this.#unsubscribeClose = this.#channel.onClose(() => {
|
||||
void this.#closeFromTransport()
|
||||
})
|
||||
void this.#run()
|
||||
}
|
||||
|
||||
get preparation(): {
|
||||
version: typeof MODEL_BRIDGE_PROTOCOL
|
||||
channelId: string
|
||||
channelEpoch: string
|
||||
policy: ModelBridgePolicy
|
||||
} {
|
||||
return {
|
||||
version: this.version,
|
||||
channelId: this.channelId,
|
||||
channelEpoch: this.channelEpoch,
|
||||
policy: this.policy
|
||||
}
|
||||
}
|
||||
|
||||
get providerDeliveryMayBeUncertain(): boolean {
|
||||
return this.#active !== undefined || this.#receivingRequest
|
||||
}
|
||||
|
||||
async close(reason = 'prompt-completed'): Promise<MainModelBridgeSessionCloseResult> {
|
||||
if (
|
||||
reason === 'prompt-completed' &&
|
||||
this.#active?.phase === 'awaiting-delivery'
|
||||
) {
|
||||
await this.#waitForIdle()
|
||||
}
|
||||
return await this.#beginClose(true, reason)
|
||||
}
|
||||
|
||||
async #run(): Promise<void> {
|
||||
try {
|
||||
while (!this.#closing) {
|
||||
this.#assertCurrent()
|
||||
const message = await this.#receiveMessage()
|
||||
if (message.kind !== 'request') {
|
||||
throw new Error(
|
||||
'Model bridge received a message without an active request'
|
||||
)
|
||||
}
|
||||
const request = message
|
||||
const preDispatchError = this.#validateRequest(request)
|
||||
if (preDispatchError !== undefined) {
|
||||
await this.#sendError(
|
||||
request.identity,
|
||||
request.requestDigest,
|
||||
preDispatchError,
|
||||
false
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
this.#active = { request, phase: 'dispatching' }
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort(
|
||||
new DOMException(
|
||||
'Remote model bridge request timed out',
|
||||
'TimeoutError'
|
||||
)
|
||||
)
|
||||
}, this.#requestTimeoutMs)
|
||||
timeout.unref?.()
|
||||
const abort = (): void => controller.abort(this.#lifetime.signal.reason)
|
||||
this.#lifetime.signal.addEventListener('abort', abort, {
|
||||
once: true
|
||||
})
|
||||
try {
|
||||
let response: unknown
|
||||
try {
|
||||
response = await raceWithAbort(
|
||||
this.#dispatch(
|
||||
{
|
||||
identity: request.identity,
|
||||
policy: request.policy,
|
||||
request: request.request,
|
||||
requestDigest: request.requestDigest
|
||||
},
|
||||
controller.signal
|
||||
),
|
||||
controller.signal
|
||||
)
|
||||
} catch (error) {
|
||||
const uncertain =
|
||||
controller.signal.aborted ||
|
||||
dispatchFailureIsUncertain(error)
|
||||
if (uncertain) {
|
||||
await this.#poisonAndWait(
|
||||
'model-dispatch-outcome-unknown'
|
||||
)
|
||||
}
|
||||
await this.#sendError(
|
||||
request.identity,
|
||||
request.requestDigest,
|
||||
dispatchFailureCode(error),
|
||||
uncertain
|
||||
)
|
||||
this.#clearActive()
|
||||
if (uncertain) {
|
||||
await this.#beginClose(true, 'model-dispatch-outcome-unknown')
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let parsedResponse
|
||||
try {
|
||||
parsedResponse =
|
||||
validateMainModelBridgeDispatchResponse(response)
|
||||
} catch {
|
||||
await this.#poisonAndWait('model-response-invalid')
|
||||
await this.#sendError(
|
||||
request.identity,
|
||||
request.requestDigest,
|
||||
'response-invalid',
|
||||
true
|
||||
)
|
||||
await this.#beginClose(true, 'model-response-invalid')
|
||||
return
|
||||
}
|
||||
this.#active.phase = 'sending-response'
|
||||
const responseMessage = modelBridgeResponseMessageSchema.parse({
|
||||
protocol: MODEL_BRIDGE_PROTOCOL,
|
||||
kind: 'response',
|
||||
identity: request.identity,
|
||||
requestDigest: request.requestDigest,
|
||||
response: parsedResponse
|
||||
})
|
||||
try {
|
||||
await this.#sendMessage(responseMessage, controller.signal)
|
||||
} catch {
|
||||
await this.#poisonAndWait(
|
||||
'model-response-send-failed'
|
||||
)
|
||||
await this.#beginClose(false, 'model-response-send-failed')
|
||||
return
|
||||
}
|
||||
this.#active.phase = 'awaiting-delivery'
|
||||
const acknowledgment = await this.#receiveMessage(
|
||||
request.identity,
|
||||
request.requestDigest
|
||||
)
|
||||
if (acknowledgment.kind !== 'response-delivered') {
|
||||
await this.#poisonAndWait(
|
||||
'model-delivery-ack-invalid'
|
||||
)
|
||||
await this.#beginClose(true, 'model-delivery-ack-invalid')
|
||||
return
|
||||
}
|
||||
await raceWithAbort(
|
||||
Promise.resolve(
|
||||
this.#onDelivered(
|
||||
request.identity,
|
||||
request.requestDigest
|
||||
)
|
||||
),
|
||||
controller.signal
|
||||
)
|
||||
this.#nextRoundIndex += 1
|
||||
this.#clearActive()
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
this.#lifetime.signal.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!this.#closing) {
|
||||
if (this.#active !== undefined) {
|
||||
await this.#poisonAndWait(
|
||||
this.#active.phase === 'awaiting-delivery'
|
||||
? 'model-delivery-ack-missing'
|
||||
: 'model-bridge-transport-lost'
|
||||
)
|
||||
}
|
||||
await this.#beginClose(false, 'model-bridge-transport-lost')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #receiveMessage(
|
||||
expectedIdentity?: ModelBridgeIdentity,
|
||||
expectedRequestDigest?: string
|
||||
) {
|
||||
const frame = await this.#channel.receive(this.#lifetime.signal)
|
||||
if (expectedIdentity === undefined) {
|
||||
this.#receivingRequest = true
|
||||
}
|
||||
try {
|
||||
this.#assertCurrent()
|
||||
const message = await decodeModelBridgeMessage(frame.payload, {
|
||||
expectedIdentity,
|
||||
expectedRequestDigest,
|
||||
maximumMessageBytes: MODEL_BRIDGE_LIMITS.maximumMessageBytes
|
||||
})
|
||||
await frame.consume()
|
||||
if (expectedIdentity === undefined) {
|
||||
this.#receivingRequest = false
|
||||
}
|
||||
return message
|
||||
} catch (error) {
|
||||
await frame.consume().catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
#validateRequest(request: ModelBridgeRequestMessage): string | undefined {
|
||||
if (
|
||||
request.identity.bindingId !== this.#identity.bindingId ||
|
||||
request.identity.promptOperationId !==
|
||||
this.#identity.promptOperationId ||
|
||||
request.identity.roundIndex !== this.#nextRoundIndex ||
|
||||
request.identity.modelProfileDigest !==
|
||||
this.policy.modelProfileDigest ||
|
||||
!isDeepStrictEqual(request.policy, this.policy)
|
||||
) {
|
||||
return 'request-identity-mismatch'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async #sendError(
|
||||
identity: ModelBridgeIdentity,
|
||||
requestDigest: string,
|
||||
code: string,
|
||||
uncertain: boolean
|
||||
): Promise<void> {
|
||||
const message = modelBridgeErrorMessageSchema.parse({
|
||||
protocol: MODEL_BRIDGE_PROTOCOL,
|
||||
kind: 'error',
|
||||
identity,
|
||||
requestDigest,
|
||||
error: {
|
||||
code: safeErrorCode(code),
|
||||
message: uncertain
|
||||
? 'Remote model request outcome is unknown'
|
||||
: 'Remote model request was rejected',
|
||||
retryable: false,
|
||||
poisoned: uncertain,
|
||||
outcomeUnknown: uncertain
|
||||
}
|
||||
})
|
||||
await this.#sendMessage(message, this.#lifetime.signal)
|
||||
}
|
||||
|
||||
async #sendMessage(
|
||||
message: unknown,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const payload = await encodeModelBridgeMessage(message)
|
||||
this.#assertCurrent()
|
||||
await this.#channel.send(payload, signal)
|
||||
}
|
||||
|
||||
#assertCurrent(): void {
|
||||
if (
|
||||
this.#closing ||
|
||||
this.#lifetime.signal.aborted ||
|
||||
!this.#isCurrentGeneration()
|
||||
) {
|
||||
throw new Error('Model bridge transport generation is stale')
|
||||
}
|
||||
}
|
||||
|
||||
#poison(reason: string): Promise<void> {
|
||||
if (this.#poisonPromise !== undefined) {
|
||||
return this.#poisonPromise
|
||||
}
|
||||
this.#poisoned = true
|
||||
this.#poisonPromise = Promise.resolve()
|
||||
.then(() =>
|
||||
this.#poisonCallback(
|
||||
this.#identity.bindingId,
|
||||
this.#identity.promptOperationId,
|
||||
reason
|
||||
)
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch(() => undefined)
|
||||
return this.#poisonPromise
|
||||
}
|
||||
|
||||
async #poisonAndWait(reason: string): Promise<void> {
|
||||
await settleWithin(
|
||||
this.#poison(reason),
|
||||
this.#closeTimeoutMs
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
#closeFromTransport(): Promise<MainModelBridgeSessionCloseResult> {
|
||||
return this.#beginClose(false, 'model-bridge-transport-lost')
|
||||
}
|
||||
|
||||
#beginClose(
|
||||
notifyPeer: boolean,
|
||||
reason: string
|
||||
): Promise<MainModelBridgeSessionCloseResult> {
|
||||
if (this.#closePromise !== undefined) {
|
||||
return this.#closePromise
|
||||
}
|
||||
this.#closing = true
|
||||
if (this.#active !== undefined || this.#receivingRequest) {
|
||||
void this.#poison(reason)
|
||||
}
|
||||
this.#lifetime.abort(
|
||||
new DOMException('Model bridge closed', 'AbortError')
|
||||
)
|
||||
this.#closePromise = Promise.resolve().then(async () => {
|
||||
try {
|
||||
if (
|
||||
!this.#poisoned &&
|
||||
this.#active === undefined &&
|
||||
!this.#receivingRequest
|
||||
) {
|
||||
try {
|
||||
await settleWithin(
|
||||
Promise.resolve(
|
||||
this.#finalizePrompt(this.#identity)
|
||||
),
|
||||
this.#closeTimeoutMs
|
||||
)
|
||||
} catch {
|
||||
await this.#poisonAndWait(
|
||||
'model-prompt-finalization-failed'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (notifyPeer) {
|
||||
const closable = this.#channel as RuntimeProtocolBinaryChannel & {
|
||||
closeWithNotification?: () => Promise<void>
|
||||
}
|
||||
if (closable.closeWithNotification !== undefined) {
|
||||
await settleWithin(
|
||||
closable.closeWithNotification(),
|
||||
this.#closeTimeoutMs
|
||||
)
|
||||
} else {
|
||||
this.#channel.close()
|
||||
}
|
||||
} else {
|
||||
this.#channel.close()
|
||||
}
|
||||
} catch {
|
||||
this.#channel.close()
|
||||
if (this.#active !== undefined) {
|
||||
await this.#poisonAndWait('model-bridge-close-failed')
|
||||
}
|
||||
}
|
||||
if (this.#poisonPromise !== undefined) {
|
||||
await settleWithin(
|
||||
this.#poisonPromise,
|
||||
this.#closeTimeoutMs
|
||||
).catch(() => undefined)
|
||||
}
|
||||
this.#unsubscribeClose()
|
||||
const result = {
|
||||
clean: !this.#poisoned,
|
||||
poisoned: this.#poisoned
|
||||
}
|
||||
this.#closedResolve(result)
|
||||
return result
|
||||
})
|
||||
return this.#closePromise
|
||||
}
|
||||
|
||||
#clearActive(): void {
|
||||
this.#active = undefined
|
||||
for (const resolve of this.#idleWaiters) {
|
||||
resolve()
|
||||
}
|
||||
this.#idleWaiters.clear()
|
||||
}
|
||||
|
||||
async #waitForIdle(): Promise<void> {
|
||||
if (this.#active === undefined) {
|
||||
return
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
this.#idleWaiters.add(resolve)
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, this.#closeTimeoutMs)
|
||||
timer.unref?.()
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.#idleWaiters.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeErrorCode(value: string): string {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value)
|
||||
? value
|
||||
: 'dispatch-failed'
|
||||
}
|
||||
|
||||
function boundedTimeout(
|
||||
value: number | undefined,
|
||||
fallback: number
|
||||
): number {
|
||||
const timeout = value ?? fallback
|
||||
if (
|
||||
!Number.isSafeInteger(timeout) ||
|
||||
timeout < 1 ||
|
||||
timeout > 300_000
|
||||
) {
|
||||
throw new RangeError('Model bridge timeout is invalid')
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
async function settleWithin(
|
||||
promise: Promise<void>,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error('Model bridge close timed out')),
|
||||
timeoutMs
|
||||
)
|
||||
timer.unref?.()
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function raceWithAbort<T>(
|
||||
promise: Promise<T>,
|
||||
signal: AbortSignal
|
||||
): Promise<T> {
|
||||
signal.throwIfAborted()
|
||||
let abort = (): void => undefined
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
abort = (): void => {
|
||||
reject(
|
||||
signal.reason ??
|
||||
new DOMException('Model bridge aborted', 'AbortError')
|
||||
)
|
||||
}
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
})
|
||||
try {
|
||||
return await Promise.race([promise, aborted])
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
runtimeSessionBindingSchema,
|
||||
type RuntimeSessionBinding
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import type { RemoteModelGateway } from '../agent/remote-model-gateway'
|
||||
import { RemoteModelGatewayError } from '../agent/remote-model-gateway'
|
||||
import { ModelCallCapacityError } from '../agent/model-call-operation-store'
|
||||
import { MemoryRuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import {
|
||||
createManagedModelBridge,
|
||||
reconcileStartupModelCalls
|
||||
} from './managed-model-bridge'
|
||||
import { MainModelBridgeDispatchError } from './main-model-bridge-dispatcher'
|
||||
|
||||
const digest = (character: string): string =>
|
||||
`sha256:${character.repeat(64)}`
|
||||
|
||||
const profile: ResolvedModelProfile = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: 'Private profile',
|
||||
baseUrl: 'https://provider.example/v1',
|
||||
modelName: 'private-model',
|
||||
protocol: 'openai-responses',
|
||||
authentication: 'api-key',
|
||||
supportsImageInput: false,
|
||||
apiKey: 'main-only-secret'
|
||||
}
|
||||
|
||||
const response = {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: Buffer.from('{}').toString('base64')
|
||||
}
|
||||
|
||||
function bridgeFixture(options?: {
|
||||
bindingStore?: MemoryRuntimeSessionBindingStore
|
||||
}) {
|
||||
const bindingStore =
|
||||
options?.bindingStore ??
|
||||
new MemoryRuntimeSessionBindingStore()
|
||||
const dispatch = vi.fn(async () => response)
|
||||
const markResponseDelivered = vi.fn()
|
||||
const finalizePrompt = vi.fn()
|
||||
const gateway = {
|
||||
dispatch,
|
||||
markResponseDelivered,
|
||||
finalizePrompt
|
||||
} as unknown as RemoteModelGateway
|
||||
const bridge = createManagedModelBridge({
|
||||
profile,
|
||||
gateway,
|
||||
bindingStore
|
||||
})
|
||||
return {
|
||||
bindingStore,
|
||||
dispatch,
|
||||
markResponseDelivered,
|
||||
finalizePrompt,
|
||||
bridge
|
||||
}
|
||||
}
|
||||
|
||||
function binding(
|
||||
policy: ReturnType<typeof bridgeFixture>['bridge']['policy'],
|
||||
state: RuntimeSessionBinding['state'] = 'prompt-running'
|
||||
): RuntimeSessionBinding {
|
||||
return runtimeSessionBindingSchema.parse({
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
controllerGeneration: 2,
|
||||
conversationId: 'conversation-1',
|
||||
hostId: 'host-1',
|
||||
hostRevision: 1,
|
||||
hostKeyGeneration: 1,
|
||||
workspaceIdentity: 'workspace-1',
|
||||
agentInstallationId: 'agent-1',
|
||||
daemonBootIdAtOpen: 'boot-1',
|
||||
runtimeId: 'opencode',
|
||||
runtimeBundleDigest: digest('a'),
|
||||
runtimeAdapterDigest: digest('b'),
|
||||
modelBridgeVersion: 'goodbuddy-model-bridge-v1',
|
||||
modelBridgePolicy: policy,
|
||||
acpSessionId: 'session-1',
|
||||
acpCapabilitiesDigest: digest('c'),
|
||||
state,
|
||||
activePromptOperationId:
|
||||
state === 'prompt-running' || state === 'outcome-unknown'
|
||||
? 'prompt-1'
|
||||
: undefined,
|
||||
channelEpoch: '1',
|
||||
lastOutboundJournaledSequence: '0',
|
||||
lastOutboundDeliveredSequence: '0',
|
||||
lastInboundJournaledSequence: '0',
|
||||
lastMainAckSequence: '0'
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchInput(
|
||||
policy: ReturnType<typeof bridgeFixture>['bridge']['policy']
|
||||
) {
|
||||
return {
|
||||
identity: {
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
requestId: 'local-model-request-1',
|
||||
roundIndex: 0,
|
||||
modelProfileDigest: policy.modelProfileDigest,
|
||||
messageId: 'message-1'
|
||||
},
|
||||
policy,
|
||||
request: {
|
||||
method: 'POST' as const,
|
||||
path: '/v1/responses' as const,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({ model: 'private-model' })
|
||||
).toString('base64')
|
||||
},
|
||||
requestDigest: digest('e')
|
||||
}
|
||||
}
|
||||
|
||||
describe('managed model bridge composition', () => {
|
||||
it('does not impose call-count or token quotas', () => {
|
||||
const fixture = bridgeFixture()
|
||||
|
||||
expect(fixture.bridge.policy).toEqual({
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
modelProfileDigest: expect.stringMatching(
|
||||
/^sha256:[a-f0-9]{64}$/u
|
||||
),
|
||||
supportsImageInput: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps credentials in Main and maps exact dispatch plus delivery identity', async () => {
|
||||
const fixture = bridgeFixture()
|
||||
await fixture.bindingStore.put(binding(fixture.bridge.policy))
|
||||
const input = dispatchInput(fixture.bridge.policy)
|
||||
|
||||
await expect(
|
||||
fixture.bridge.channel.dispatch(
|
||||
input,
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toEqual(response)
|
||||
expect(fixture.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestId: 'local-model-request-1',
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 0,
|
||||
roundIndex: 0,
|
||||
modelProfile: expect.objectContaining({
|
||||
apiKey: 'main-only-secret'
|
||||
})
|
||||
}),
|
||||
input.request,
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(JSON.stringify(fixture.bridge.policy)).not.toContain(
|
||||
'main-only-secret'
|
||||
)
|
||||
expect(JSON.stringify(fixture.bridge.policy)).not.toContain(
|
||||
'provider.example'
|
||||
)
|
||||
|
||||
await fixture.bridge.channel.onDelivered(
|
||||
input.identity,
|
||||
input.requestDigest
|
||||
)
|
||||
expect(fixture.markResponseDelivered).toHaveBeenCalledWith({
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
roundIndex: 0
|
||||
})
|
||||
await fixture.bridge.channel.finalizePrompt(input.identity)
|
||||
expect(fixture.finalizePrompt).toHaveBeenCalledWith({
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies only known pre-dispatch gateway failures as definitive', async () => {
|
||||
const fixture = bridgeFixture()
|
||||
await fixture.bindingStore.put(binding(fixture.bridge.policy))
|
||||
vi.mocked(fixture.dispatch).mockRejectedValueOnce(
|
||||
new RemoteModelGatewayError(
|
||||
'request-policy-mismatch',
|
||||
'safe failure'
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
fixture.bridge.channel.dispatch(
|
||||
dispatchInput(fixture.bridge.policy),
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'request-policy-mismatch',
|
||||
outcomeUnknown: false,
|
||||
postDispatch: false
|
||||
})
|
||||
|
||||
vi.mocked(fixture.dispatch).mockRejectedValueOnce(
|
||||
new ModelCallCapacityError()
|
||||
)
|
||||
await expect(
|
||||
fixture.bridge.channel.dispatch(
|
||||
dispatchInput(fixture.bridge.policy),
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'model-ledger-capacity',
|
||||
outcomeUnknown: false,
|
||||
postDispatch: false
|
||||
})
|
||||
|
||||
vi.mocked(fixture.dispatch).mockRejectedValueOnce(
|
||||
new RemoteModelGatewayError(
|
||||
'network-error',
|
||||
'must not escape'
|
||||
)
|
||||
)
|
||||
await expect(
|
||||
fixture.bridge.channel.dispatch(
|
||||
dispatchInput(fixture.bridge.policy),
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toBeInstanceOf(MainModelBridgeDispatchError)
|
||||
await expect(
|
||||
fixture.bridge.channel.dispatch(
|
||||
{
|
||||
...dispatchInput(fixture.bridge.policy),
|
||||
policy: {
|
||||
...fixture.bridge.policy,
|
||||
model: 'different-model'
|
||||
}
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'model-policy-mismatch',
|
||||
outcomeUnknown: false
|
||||
})
|
||||
})
|
||||
|
||||
it('durably poisons the exact active binding', async () => {
|
||||
const fixture = bridgeFixture()
|
||||
await fixture.bindingStore.put(binding(fixture.bridge.policy))
|
||||
|
||||
await fixture.bridge.channel.poison(
|
||||
'binding-1',
|
||||
'prompt-1',
|
||||
'delivery-unknown'
|
||||
)
|
||||
await expect(
|
||||
fixture.bindingStore.getById('binding-1')
|
||||
).resolves.toMatchObject({ state: 'outcome-unknown' })
|
||||
})
|
||||
|
||||
it('paginates startup uncertainty and poisons only matching live prompts', async () => {
|
||||
const fixture = bridgeFixture()
|
||||
await fixture.bindingStore.put(binding(fixture.bridge.policy))
|
||||
const firstCursor = {
|
||||
updatedAt: 10,
|
||||
callOperationId: 'call-1'
|
||||
}
|
||||
const listStartupRecords = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
records: [
|
||||
{
|
||||
status: 'completed',
|
||||
identity: {
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1'
|
||||
}
|
||||
}
|
||||
],
|
||||
nextCursor: firstCursor
|
||||
})
|
||||
.mockReturnValueOnce({ records: [] })
|
||||
|
||||
await reconcileStartupModelCalls({
|
||||
gatewayStore: { listStartupRecords },
|
||||
bindingStore: fixture.bindingStore
|
||||
})
|
||||
expect(listStartupRecords).toHaveBeenNthCalledWith(2, {
|
||||
cursor: firstCursor,
|
||||
limit: 100
|
||||
})
|
||||
await expect(
|
||||
fixture.bindingStore.getById('binding-1')
|
||||
).resolves.toMatchObject({ state: 'outcome-unknown' })
|
||||
})
|
||||
|
||||
it('marks a prepared but undispatched startup call as interrupted', async () => {
|
||||
const fixture = bridgeFixture()
|
||||
await fixture.bindingStore.put(binding(fixture.bridge.policy))
|
||||
|
||||
await reconcileStartupModelCalls({
|
||||
gatewayStore: {
|
||||
listStartupRecords: () => ({
|
||||
records: [
|
||||
{
|
||||
status: 'prepared',
|
||||
identity: {
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
},
|
||||
bindingStore: fixture.bindingStore
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.bindingStore.getById('binding-1')
|
||||
).resolves.toMatchObject({
|
||||
state: 'interrupted',
|
||||
activePromptOperationId: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
modelBridgePolicySchema,
|
||||
type ModelBridgePolicy
|
||||
} from '../../shared/model-bridge-contracts'
|
||||
import { runtimeSessionBindingSchema } from '../../shared/remote-agent-contracts'
|
||||
import {
|
||||
RemoteModelGateway,
|
||||
RemoteModelGatewayError,
|
||||
createResolvedModelProfileDigest
|
||||
} from '../agent/remote-model-gateway'
|
||||
import { ModelCallCapacityError } from '../agent/model-call-operation-store'
|
||||
import type { RuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import {
|
||||
MainModelBridgeDispatchError,
|
||||
type MainModelBridgeDispatchInput
|
||||
} from './main-model-bridge-dispatcher'
|
||||
import type { ProtocolRemoteRuntimeChannelOptions } from './protocol-remote-runtime-channel'
|
||||
|
||||
const PRE_DISPATCH_GATEWAY_ERRORS = new Set([
|
||||
'invalid-provider-endpoint',
|
||||
'missing-provider-credential',
|
||||
'model-profile-digest-mismatch',
|
||||
'protocol-path-mismatch',
|
||||
'request-policy-mismatch'
|
||||
])
|
||||
|
||||
export type ManagedModelBridge = {
|
||||
policy: ModelBridgePolicy
|
||||
channel: NonNullable<ProtocolRemoteRuntimeChannelOptions['modelBridge']>
|
||||
}
|
||||
|
||||
export function createManagedModelBridge(options: {
|
||||
profile: ResolvedModelProfile
|
||||
gateway: RemoteModelGateway
|
||||
bindingStore: RuntimeSessionBindingStore
|
||||
}): ManagedModelBridge {
|
||||
const profile = structuredClone(options.profile)
|
||||
if (
|
||||
profile.protocol === 'openai-images-generations' ||
|
||||
(profile.authentication === 'api-key' && !profile.apiKey)
|
||||
) {
|
||||
throw new Error(
|
||||
'Managed remote OpenCode requires a usable text model profile'
|
||||
)
|
||||
}
|
||||
const policy = modelBridgePolicySchema.parse({
|
||||
protocol: profile.protocol,
|
||||
model: profile.modelName,
|
||||
modelProfileDigest: createResolvedModelProfileDigest(profile),
|
||||
supportsImageInput: profile.supportsImageInput === true
|
||||
})
|
||||
|
||||
const assertActiveBinding = async (
|
||||
input: Pick<
|
||||
MainModelBridgeDispatchInput['identity'],
|
||||
'bindingId' | 'promptOperationId'
|
||||
>
|
||||
) => {
|
||||
const binding = await options.bindingStore.getById(input.bindingId)
|
||||
if (
|
||||
binding === undefined ||
|
||||
binding.state !== 'prompt-running' ||
|
||||
binding.activePromptOperationId !== input.promptOperationId ||
|
||||
binding.modelBridgeVersion !== 'goodbuddy-model-bridge-v1' ||
|
||||
!isDeepStrictEqual(binding.modelBridgePolicy, policy)
|
||||
) {
|
||||
throw new MainModelBridgeDispatchError(
|
||||
'binding-policy-mismatch',
|
||||
{ outcomeUnknown: false, postDispatch: false }
|
||||
)
|
||||
}
|
||||
return binding
|
||||
}
|
||||
|
||||
return {
|
||||
policy,
|
||||
channel: {
|
||||
dispatch: async (input, signal) => {
|
||||
const binding = await assertActiveBinding(input.identity)
|
||||
if (!isDeepStrictEqual(input.policy, policy)) {
|
||||
throw new MainModelBridgeDispatchError(
|
||||
'model-policy-mismatch',
|
||||
{ outcomeUnknown: false, postDispatch: false }
|
||||
)
|
||||
}
|
||||
try {
|
||||
return await options.gateway.dispatch(
|
||||
{
|
||||
requestId: input.identity.requestId,
|
||||
bindingId: input.identity.bindingId,
|
||||
promptOperationId:
|
||||
input.identity.promptOperationId,
|
||||
promptSequence: binding.promptSequence,
|
||||
roundIndex: input.identity.roundIndex,
|
||||
modelProfileDigest: policy.modelProfileDigest,
|
||||
modelProfile: profile
|
||||
},
|
||||
input.request,
|
||||
signal
|
||||
)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof MainModelBridgeDispatchError
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
error instanceof RemoteModelGatewayError &&
|
||||
PRE_DISPATCH_GATEWAY_ERRORS.has(error.code)
|
||||
) {
|
||||
throw new MainModelBridgeDispatchError(error.code, {
|
||||
outcomeUnknown: false,
|
||||
postDispatch: false,
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
if (error instanceof ModelCallCapacityError) {
|
||||
throw new MainModelBridgeDispatchError(
|
||||
'model-ledger-capacity',
|
||||
{
|
||||
outcomeUnknown: false,
|
||||
postDispatch: false,
|
||||
cause: error
|
||||
}
|
||||
)
|
||||
}
|
||||
throw new MainModelBridgeDispatchError(
|
||||
error instanceof RemoteModelGatewayError
|
||||
? error.code
|
||||
: 'dispatch-failed',
|
||||
{
|
||||
outcomeUnknown: true,
|
||||
postDispatch: true,
|
||||
cause: error
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelivered: async (identity) => {
|
||||
await assertActiveBinding(identity)
|
||||
options.gateway.markResponseDelivered({
|
||||
bindingId: identity.bindingId,
|
||||
promptOperationId: identity.promptOperationId,
|
||||
roundIndex: identity.roundIndex
|
||||
})
|
||||
},
|
||||
finalizePrompt: async (identity) => {
|
||||
const binding = await assertActiveBinding(identity)
|
||||
options.gateway.finalizePrompt({
|
||||
bindingId: identity.bindingId,
|
||||
promptOperationId: identity.promptOperationId,
|
||||
promptSequence: binding.promptSequence
|
||||
})
|
||||
},
|
||||
poison: async (
|
||||
bindingId,
|
||||
promptOperationId
|
||||
) => {
|
||||
const binding =
|
||||
await options.bindingStore.getById(bindingId)
|
||||
if (
|
||||
binding === undefined ||
|
||||
binding.state === 'closed' ||
|
||||
binding.state === 'outcome-unknown' ||
|
||||
binding.activePromptOperationId !== promptOperationId
|
||||
) {
|
||||
return
|
||||
}
|
||||
await options.bindingStore.put(
|
||||
runtimeSessionBindingSchema.parse({
|
||||
...binding,
|
||||
state: 'outcome-unknown'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileStartupModelCalls(options: {
|
||||
gatewayStore: {
|
||||
listStartupRecords(input?: {
|
||||
cursor?: {
|
||||
updatedAt: number
|
||||
callOperationId: string
|
||||
}
|
||||
limit?: number
|
||||
}): {
|
||||
records: Array<{
|
||||
status:
|
||||
| 'prepared'
|
||||
| 'dispatched'
|
||||
| 'completed'
|
||||
| 'failed-definitive'
|
||||
| 'outcome-unknown'
|
||||
identity: {
|
||||
bindingId: string
|
||||
promptOperationId: string
|
||||
}
|
||||
}>
|
||||
nextCursor?: {
|
||||
updatedAt: number
|
||||
callOperationId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
bindingStore: RuntimeSessionBindingStore
|
||||
}): Promise<void> {
|
||||
let cursor:
|
||||
| { updatedAt: number; callOperationId: string }
|
||||
| undefined
|
||||
do {
|
||||
const page = options.gatewayStore.listStartupRecords({
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
limit: 100
|
||||
})
|
||||
for (const record of page.records) {
|
||||
const binding = await options.bindingStore.getById(
|
||||
record.identity.bindingId
|
||||
)
|
||||
if (
|
||||
binding === undefined ||
|
||||
binding.state === 'closed' ||
|
||||
binding.state === 'outcome-unknown' ||
|
||||
binding.activePromptOperationId !==
|
||||
record.identity.promptOperationId
|
||||
) {
|
||||
continue
|
||||
}
|
||||
await options.bindingStore.put(
|
||||
runtimeSessionBindingSchema.parse({
|
||||
...binding,
|
||||
state:
|
||||
record.status === 'prepared'
|
||||
? 'interrupted'
|
||||
: 'outcome-unknown',
|
||||
...(record.status === 'prepared'
|
||||
? { activePromptOperationId: undefined }
|
||||
: {})
|
||||
})
|
||||
)
|
||||
}
|
||||
cursor = page.nextCursor
|
||||
} while (cursor !== undefined)
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const remoteHarness = vi.hoisted(() => ({
|
||||
instances: [] as Array<{
|
||||
options: {
|
||||
identity: {
|
||||
controllerGeneration: number
|
||||
daemonBootIdAtOpen: string
|
||||
}
|
||||
channelFactory: (bindingId: string) => Promise<unknown>
|
||||
}
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
runCalls: number
|
||||
}>,
|
||||
runGate: undefined as Promise<void> | undefined
|
||||
}))
|
||||
|
||||
vi.mock('../agent/acp-remote-runtime', () => ({
|
||||
AcpRemoteRuntime: class {
|
||||
readonly requiresToolApproval = false
|
||||
readonly supportsScopedDataTools = false
|
||||
readonly capability = 'chat'
|
||||
readonly runtimeId = 'opencode'
|
||||
readonly supportsToolExecution = true
|
||||
readonly dispose = vi.fn(async () => undefined)
|
||||
runCalls = 0
|
||||
|
||||
constructor(
|
||||
readonly options: {
|
||||
identity: {
|
||||
controllerGeneration: number
|
||||
daemonBootIdAtOpen: string
|
||||
}
|
||||
channelFactory: (bindingId: string) => Promise<unknown>
|
||||
}
|
||||
) {
|
||||
remoteHarness.instances.push(this)
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return {
|
||||
id: 'opencode',
|
||||
label: 'OpenCode(远程托管)',
|
||||
available: true
|
||||
}
|
||||
}
|
||||
|
||||
async *run(request: { requestId: string }) {
|
||||
this.runCalls += 1
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'status',
|
||||
message: 'running'
|
||||
}
|
||||
await remoteHarness.runGate
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'done',
|
||||
sessionId: 'session-1'
|
||||
}
|
||||
}
|
||||
|
||||
async releaseConversation() {}
|
||||
async beginDrain() {}
|
||||
async waitForDrain() {}
|
||||
async forceShutdown() {
|
||||
await this.dispose()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
MemoryRuntimeSessionBindingStore
|
||||
} from '../agent/runtime-session-binding-store'
|
||||
import type { SshExecutionSpaceDescriptor } from '../execution-space'
|
||||
import {
|
||||
createManagedRemoteAcpRuntime,
|
||||
type ManagedRemoteAcpRuntimeOptions
|
||||
} from './managed-remote-acp-runtime'
|
||||
|
||||
const digest = (value: string): string =>
|
||||
`sha256:${value.repeat(64)}`
|
||||
|
||||
function descriptor(): SshExecutionSpaceDescriptor {
|
||||
return {
|
||||
kind: 'ssh',
|
||||
hostId: 'host-1',
|
||||
remoteRootPath: '/srv/project',
|
||||
validation: {
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'alice',
|
||||
workspaceIdentity: 'workspace-1',
|
||||
agentProtocolMajor: 1,
|
||||
agentInstallationIdAtValidation: 'agent-1',
|
||||
agentBinaryDigestAtValidation: digest('a'),
|
||||
agentVersionAtValidation: '1.0.0',
|
||||
agentArchitectureAtValidation: 'x64',
|
||||
validatedAt: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
runtimeValidation: {
|
||||
runtimeSelectionKey: 'opencode:default',
|
||||
runtimeBundleDigest: digest('b'),
|
||||
runtimeAdapterDigest: digest('c'),
|
||||
agentInstallationIdAtValidation: 'agent-1',
|
||||
validatedAt: '2030-01-01T00:00:00.000Z',
|
||||
workMode: 'ask'
|
||||
},
|
||||
cacheIdentity: 'cache-1',
|
||||
routeIdentity: 'route-1',
|
||||
workspaceAccess: {
|
||||
getIdentity: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
readText: vi.fn(),
|
||||
writeTextAtomic: vi.fn(),
|
||||
search: vi.fn(),
|
||||
getChanges: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function harness(
|
||||
overrides: Partial<ManagedRemoteAcpRuntimeOptions> = {}
|
||||
) {
|
||||
remoteHarness.instances.length = 0
|
||||
remoteHarness.runGate = undefined
|
||||
const releaseConnection = vi.fn()
|
||||
const workspaceAccesses: Array<{
|
||||
getIdentity: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}> = []
|
||||
const connection = {
|
||||
identity: {
|
||||
cacheKey: 'connection-cache',
|
||||
hostId: 'host-1',
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'alice',
|
||||
installationId: 'agent-1',
|
||||
binaryDigest: digest('a'),
|
||||
protocolMajor: 1,
|
||||
protocolMinor: 0
|
||||
},
|
||||
status: {
|
||||
state: 'ready',
|
||||
draining: false,
|
||||
installationId: 'agent-1',
|
||||
binaryDigest: digest('a'),
|
||||
agentVersion: '1.0.0',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
daemonBootId: 'boot-1',
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
supervisor: 'detached-on-demand'
|
||||
},
|
||||
capabilities: capabilities(),
|
||||
client: { generation: 5 },
|
||||
state: 'ready',
|
||||
refreshCapabilities: vi.fn(async () => capabilities()),
|
||||
reconnect: vi.fn(async () => undefined),
|
||||
release: releaseConnection
|
||||
}
|
||||
const options = {
|
||||
executionSpace: descriptor(),
|
||||
selection: { provider: 'opencode' as const },
|
||||
agentServices: {
|
||||
installationManager: {
|
||||
ensureInstalled: vi.fn(async () => ({
|
||||
installationId: 'agent-1',
|
||||
binaryDigest: digest('a'),
|
||||
agentVersion: '1.0.0',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
platform: 'linux' as const,
|
||||
architecture: 'x64' as const,
|
||||
supervisor: 'detached-on-demand' as const
|
||||
}))
|
||||
},
|
||||
connectionManager: {
|
||||
acquire: vi.fn(async () => connection)
|
||||
},
|
||||
controllerState: {
|
||||
getControllerId: vi.fn(async () => 'controller-1')
|
||||
}
|
||||
},
|
||||
runtimeInstallationManager: {
|
||||
ensureInstalled: vi.fn(async () => ({
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: '1.18.9',
|
||||
bundleDigest: digest('b'),
|
||||
manifestDigest: digest('e'),
|
||||
runtimeAdapterDigest: digest('c'),
|
||||
acpCapabilitiesDigest: digest('f'),
|
||||
platform: 'linux' as const,
|
||||
architecture: 'x64' as const
|
||||
}))
|
||||
},
|
||||
workspaceAccessFactory: {
|
||||
create: vi.fn(() => {
|
||||
const access = {
|
||||
getIdentity: vi.fn(async () => ({
|
||||
kind: 'remote' as const,
|
||||
id: 'host-1:workspace-1',
|
||||
canonicalDisplayPath: '/srv/project',
|
||||
capabilities: {
|
||||
read: true,
|
||||
write: true,
|
||||
search: true,
|
||||
changes: true
|
||||
}
|
||||
})),
|
||||
listDirectory: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
readText: vi.fn(),
|
||||
writeTextAtomic: vi.fn(),
|
||||
search: vi.fn(),
|
||||
getChanges: vi.fn(),
|
||||
dispose: vi.fn(async () => undefined)
|
||||
}
|
||||
workspaceAccesses.push(access)
|
||||
return access
|
||||
})
|
||||
},
|
||||
bindingStore: new MemoryRuntimeSessionBindingStore(),
|
||||
modelBridge: {
|
||||
policy: {
|
||||
protocol: 'openai-responses' as const,
|
||||
model: 'private-model',
|
||||
modelProfileDigest: digest('9'),
|
||||
supportsImageInput: false
|
||||
},
|
||||
channel: {
|
||||
dispatch: vi.fn(),
|
||||
onDelivered: vi.fn(),
|
||||
finalizePrompt: vi.fn(),
|
||||
poison: vi.fn()
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
} as unknown as ManagedRemoteAcpRuntimeOptions
|
||||
return {
|
||||
options,
|
||||
connection,
|
||||
releaseConnection,
|
||||
workspaceAccesses
|
||||
}
|
||||
}
|
||||
|
||||
describe('createManagedRemoteAcpRuntime', () => {
|
||||
it('binds exact persisted evidence and owns its trust and connection leases', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
fixture.options
|
||||
)
|
||||
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
id: 'opencode',
|
||||
available: true
|
||||
})
|
||||
expect(
|
||||
fixture.options.workspaceAccessFactory.create
|
||||
).not.toHaveBeenCalled()
|
||||
expect(
|
||||
fixture.options.agentServices.connectionManager.acquire
|
||||
).toHaveBeenCalledWith('host-1', expect.objectContaining({
|
||||
requiredCapabilities: [
|
||||
{ name: 'runtime/acp', exactVersion: 3, critical: true },
|
||||
{
|
||||
name: 'runtime/model-bridge',
|
||||
exactVersion: 1,
|
||||
critical: true
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
await runtime.dispose()
|
||||
await runtime.dispose()
|
||||
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('recreates the Runtime and reopens the Workspace after reconnect identity changes', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
fixture.options
|
||||
)
|
||||
|
||||
await collect(runtime.run(request('request-1'), new AbortController().signal))
|
||||
expect(remoteHarness.instances).toHaveLength(1)
|
||||
expect(remoteHarness.instances[0]!.options.identity).toMatchObject({
|
||||
controllerGeneration: 5,
|
||||
daemonBootIdAtOpen: 'boot-1'
|
||||
})
|
||||
expect(fixture.workspaceAccesses).toHaveLength(1)
|
||||
|
||||
fixture.connection.state = 'offline'
|
||||
vi.mocked(fixture.connection.reconnect).mockImplementationOnce(async () => {
|
||||
fixture.connection.client.generation = 6
|
||||
fixture.connection.status.daemonBootId = 'boot-2'
|
||||
fixture.connection.state = 'ready'
|
||||
})
|
||||
|
||||
await collect(runtime.run(request('request-2'), new AbortController().signal))
|
||||
|
||||
expect(fixture.connection.reconnect).toHaveBeenCalledOnce()
|
||||
expect(remoteHarness.instances).toHaveLength(2)
|
||||
expect(remoteHarness.instances[0]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(remoteHarness.instances[1]!.options.identity).toMatchObject({
|
||||
controllerGeneration: 6,
|
||||
daemonBootIdAtOpen: 'boot-2'
|
||||
})
|
||||
expect(fixture.workspaceAccesses).toHaveLength(2)
|
||||
expect(
|
||||
fixture.workspaceAccesses[0]!.dispose
|
||||
).toHaveBeenCalledOnce()
|
||||
|
||||
await runtime.dispose()
|
||||
expect(fixture.workspaceAccesses[1]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a stale channel factory lease after a transport change', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
fixture.options
|
||||
)
|
||||
const staleFactory =
|
||||
remoteHarness.instances[0]!.options.channelFactory
|
||||
|
||||
fixture.connection.client.generation = 6
|
||||
fixture.connection.status.daemonBootId = 'boot-2'
|
||||
|
||||
await expect(staleFactory('binding-1')).rejects.toThrow(
|
||||
/transport lease is stale/iu
|
||||
)
|
||||
expect(fixture.workspaceAccesses).toHaveLength(1)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('recreates the Runtime when only the daemon boot identity changes', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
fixture.options
|
||||
)
|
||||
|
||||
await collect(runtime.run(request('request-1'), new AbortController().signal))
|
||||
fixture.connection.status.daemonBootId = 'boot-2'
|
||||
await collect(runtime.run(request('request-2'), new AbortController().signal))
|
||||
|
||||
expect(remoteHarness.instances).toHaveLength(2)
|
||||
expect(remoteHarness.instances[1]!.options.identity).toMatchObject({
|
||||
controllerGeneration: 5,
|
||||
daemonBootIdAtOpen: 'boot-2'
|
||||
})
|
||||
expect(fixture.workspaceAccesses).toHaveLength(2)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails closed on reconnect during an active request without replaying it', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
fixture.options
|
||||
)
|
||||
let releaseGate = (): void => undefined
|
||||
remoteHarness.runGate = new Promise<void>((resolve) => {
|
||||
releaseGate = resolve
|
||||
})
|
||||
const first = runtime.run(
|
||||
request('request-1'),
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
await expect(first.next()).resolves.toMatchObject({
|
||||
value: { type: 'status' },
|
||||
done: false
|
||||
})
|
||||
fixture.connection.client.generation = 6
|
||||
fixture.connection.status.daemonBootId = 'boot-2'
|
||||
|
||||
await expect(
|
||||
runtime
|
||||
.run(request('request-2'), new AbortController().signal)
|
||||
.next()
|
||||
).rejects.toThrow(/another Runtime request was active/iu)
|
||||
expect(remoteHarness.instances).toHaveLength(1)
|
||||
expect(remoteHarness.instances[0]!.runCalls).toBe(1)
|
||||
|
||||
releaseGate()
|
||||
await collect(first)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('releases exact trust without connecting when Runtime evidence changed', async () => {
|
||||
const fixture = harness()
|
||||
vi.mocked(
|
||||
fixture.options.runtimeInstallationManager.ensureInstalled
|
||||
).mockResolvedValueOnce({
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: '1.18.9',
|
||||
bundleDigest: digest('9'),
|
||||
manifestDigest: digest('e'),
|
||||
runtimeAdapterDigest: digest('c'),
|
||||
acpCapabilitiesDigest: digest('f'),
|
||||
platform: 'linux',
|
||||
architecture: 'x64'
|
||||
})
|
||||
|
||||
await expect(
|
||||
createManagedRemoteAcpRuntime(fixture.options)
|
||||
).rejects.toThrow(/does not match project validation/iu)
|
||||
expect(
|
||||
fixture.options.agentServices.connectionManager.acquire
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases both leases when the live Agent identity is stale', async () => {
|
||||
const fixture = harness()
|
||||
fixture.connection.status.agentVersion = '2.0.0'
|
||||
|
||||
await expect(
|
||||
createManagedRemoteAcpRuntime(fixture.options)
|
||||
).rejects.toThrow(/connection does not match/iu)
|
||||
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects the obsolete Runtime ACP v1 capability', async () => {
|
||||
const fixture = harness()
|
||||
vi.mocked(
|
||||
fixture.connection.refreshCapabilities
|
||||
).mockResolvedValueOnce({
|
||||
...capabilities(),
|
||||
capabilities: [
|
||||
{ name: 'runtime/acp', version: 1, critical: true }
|
||||
]
|
||||
})
|
||||
|
||||
await expect(
|
||||
createManagedRemoteAcpRuntime(fixture.options)
|
||||
).rejects.toThrow(/does not advertise/iu)
|
||||
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
function capabilities() {
|
||||
return {
|
||||
generation: 7,
|
||||
capabilities: [
|
||||
{ name: 'runtime/acp', version: 3, critical: true },
|
||||
{
|
||||
name: 'runtime/model-bridge',
|
||||
version: 1,
|
||||
critical: true
|
||||
}
|
||||
],
|
||||
runtimes: [
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
version: '1.18.9',
|
||||
bundleDigest: digest('b'),
|
||||
acpCapabilitiesDigest: digest('f'),
|
||||
sessionLoad: true,
|
||||
sessionResume: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function request(requestId: string) {
|
||||
return {
|
||||
requestId,
|
||||
conversationId: 'conversation-1',
|
||||
prompt: 'hello',
|
||||
workMode: 'ask' as const
|
||||
}
|
||||
}
|
||||
|
||||
async function collect<T>(
|
||||
values: AsyncGenerator<T, void, void>
|
||||
): Promise<T[]> {
|
||||
const collected: T[] = []
|
||||
for await (const value of values) {
|
||||
collected.push(value)
|
||||
}
|
||||
return collected
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import {
|
||||
AcpRemoteRuntime,
|
||||
type AcpRemoteRuntimeOptions
|
||||
} from '../agent/acp-remote-runtime'
|
||||
import type {
|
||||
AgentExecutionRequest,
|
||||
AgentRuntime,
|
||||
RuntimeAuthorizer,
|
||||
RuntimeEvent
|
||||
} from '../agent/runtime'
|
||||
import type { RuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
|
||||
import type { SshExecutionSpaceDescriptor } from '../execution-space'
|
||||
import type { RemoteWorkspaceProjectBinding } from '../workspace'
|
||||
import { createProtocolRemoteRuntimeChannel } from './protocol-remote-runtime-channel'
|
||||
import type { RemoteAgentServices } from './remote-agent-services'
|
||||
import type { ManagedRemoteWorkspaceAccessFactory } from './managed-remote-workspace-access-factory'
|
||||
import type { ManagedModelBridge } from './managed-model-bridge'
|
||||
import type { RemoteRuntimeInstallationManager } from './remote-runtime-installation-manager'
|
||||
|
||||
const RUNTIME_ACP_CAPABILITY = {
|
||||
name: 'runtime/acp',
|
||||
exactVersion: 3,
|
||||
critical: true
|
||||
} as const
|
||||
const RUNTIME_MODEL_BRIDGE_CAPABILITY = {
|
||||
name: 'runtime/model-bridge',
|
||||
exactVersion: 1,
|
||||
critical: true
|
||||
} as const
|
||||
|
||||
type ManagedRemoteTransportIdentity = {
|
||||
controllerGeneration: number
|
||||
daemonBootId: string
|
||||
}
|
||||
|
||||
export type ManagedRemoteAcpRuntimeOptions = {
|
||||
executionSpace: SshExecutionSpaceDescriptor
|
||||
selection: AgentRuntimeSelection
|
||||
agentServices: Pick<
|
||||
RemoteAgentServices,
|
||||
'installationManager' | 'connectionManager' | 'controllerState'
|
||||
>
|
||||
runtimeInstallationManager: Pick<
|
||||
RemoteRuntimeInstallationManager,
|
||||
'ensureInstalled'
|
||||
>
|
||||
workspaceAccessFactory: Pick<
|
||||
ManagedRemoteWorkspaceAccessFactory,
|
||||
'create'
|
||||
>
|
||||
bindingStore: RuntimeSessionBindingStore
|
||||
modelBridge: ManagedModelBridge
|
||||
usage?: {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires every production lease needed by one validated remote Runtime.
|
||||
* The returned Runtime owns those leases and releases them only after all ACP
|
||||
* conversation channels have been closed.
|
||||
*/
|
||||
export async function createManagedRemoteAcpRuntime(
|
||||
options: ManagedRemoteAcpRuntimeOptions
|
||||
): Promise<AgentRuntime> {
|
||||
const descriptor = options.executionSpace
|
||||
const validation = descriptor.validation
|
||||
const runtimeValidation = descriptor.runtimeValidation
|
||||
if (
|
||||
options.selection.provider !== 'opencode' ||
|
||||
validation === undefined ||
|
||||
runtimeValidation === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
'Managed remote execution requires validated OpenCode project evidence'
|
||||
)
|
||||
}
|
||||
const workspaceBinding = workspaceProjectBinding(descriptor)
|
||||
let connection:
|
||||
| Awaited<
|
||||
ReturnType<
|
||||
RemoteAgentServices['connectionManager']['acquire']
|
||||
>
|
||||
>
|
||||
| undefined
|
||||
let retained = false
|
||||
try {
|
||||
const agent =
|
||||
await options.agentServices.installationManager.ensureInstalled(
|
||||
descriptor.hostId
|
||||
)
|
||||
assertAgentMatches(workspaceBinding, agent)
|
||||
|
||||
const runtime =
|
||||
await options.runtimeInstallationManager.ensureInstalled(
|
||||
descriptor.hostId
|
||||
)
|
||||
assertRuntimeMatches(runtimeValidation, agent.architecture, runtime)
|
||||
|
||||
const activeConnection =
|
||||
await options.agentServices.connectionManager.acquire(
|
||||
descriptor.hostId,
|
||||
{
|
||||
...agent,
|
||||
requiredCapabilities: [
|
||||
RUNTIME_ACP_CAPABILITY,
|
||||
RUNTIME_MODEL_BRIDGE_CAPABILITY
|
||||
]
|
||||
}
|
||||
)
|
||||
connection = activeConnection
|
||||
assertConnectionMatches(workspaceBinding, activeConnection)
|
||||
const initialTransport = {
|
||||
controllerGeneration: activeConnection.client.generation,
|
||||
daemonBootId: activeConnection.status.daemonBootId
|
||||
}
|
||||
const capabilities = await activeConnection.refreshCapabilities()
|
||||
assertConnectionMatches(workspaceBinding, activeConnection)
|
||||
assertSameTransport(
|
||||
initialTransport,
|
||||
{
|
||||
controllerGeneration: activeConnection.client.generation,
|
||||
daemonBootId: activeConnection.status.daemonBootId
|
||||
},
|
||||
'Remote Agent transport changed while creating the Runtime'
|
||||
)
|
||||
assertRuntimeAdvertised(runtime, capabilities)
|
||||
const controllerId =
|
||||
await options.agentServices.controllerState.getControllerId()
|
||||
assertSameTransport(
|
||||
initialTransport,
|
||||
{
|
||||
controllerGeneration: activeConnection.client.generation,
|
||||
daemonBootId: activeConnection.status.daemonBootId
|
||||
},
|
||||
'Remote Agent transport changed while creating the Runtime'
|
||||
)
|
||||
const resources = new ManagedRemoteRuntimeResources(
|
||||
options.workspaceAccessFactory,
|
||||
workspaceBinding,
|
||||
activeConnection,
|
||||
runtime,
|
||||
options.modelBridge
|
||||
)
|
||||
|
||||
const createRemote = (
|
||||
transport: ManagedRemoteTransportIdentity
|
||||
): AcpRemoteRuntime => {
|
||||
const remoteOptions: AcpRemoteRuntimeOptions = {
|
||||
runtimeId: 'opencode',
|
||||
label: 'OpenCode(远程托管)',
|
||||
workspacePath: descriptor.remoteRootPath,
|
||||
identity: {
|
||||
controllerId,
|
||||
controllerGeneration: transport.controllerGeneration,
|
||||
hostId: workspaceBinding.hostId,
|
||||
hostRevision: workspaceBinding.hostRevision,
|
||||
hostKeyGeneration: workspaceBinding.hostKeyGeneration,
|
||||
workspaceIdentity: workspaceBinding.workspaceIdentity,
|
||||
agentInstallationId: workspaceBinding.agentInstallationId,
|
||||
daemonBootIdAtOpen: transport.daemonBootId,
|
||||
runtimeBundleDigest:
|
||||
runtimeValidation.runtimeBundleDigest,
|
||||
runtimeAdapterDigest:
|
||||
runtimeValidation.runtimeAdapterDigest,
|
||||
},
|
||||
channelFactory: async (bindingId) =>
|
||||
resources.openChannel(transport, bindingId),
|
||||
bindingStore: options.bindingStore,
|
||||
modelBridgePolicy: options.modelBridge.policy,
|
||||
assertHostCurrent: (identity) => {
|
||||
if (
|
||||
identity.controllerId !== controllerId ||
|
||||
identity.hostId !== workspaceBinding.hostId ||
|
||||
identity.hostRevision !== workspaceBinding.hostRevision ||
|
||||
identity.hostKeyGeneration !==
|
||||
workspaceBinding.hostKeyGeneration ||
|
||||
identity.agentInstallationId !==
|
||||
workspaceBinding.agentInstallationId
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Runtime Host identity no longer matches its project'
|
||||
)
|
||||
}
|
||||
},
|
||||
...(options.usage
|
||||
? {
|
||||
usage: {
|
||||
runtime: 'opencode',
|
||||
provider: options.usage.provider,
|
||||
model: options.usage.model
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
return new AcpRemoteRuntime(remoteOptions)
|
||||
}
|
||||
const transport = initialTransport
|
||||
const remote = createRemote(transport)
|
||||
retained = true
|
||||
return new ManagedRemoteAcpRuntime(
|
||||
remote,
|
||||
transport,
|
||||
createRemote,
|
||||
resources
|
||||
)
|
||||
} finally {
|
||||
if (!retained) {
|
||||
connection?.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedRemoteRuntimeResources {
|
||||
#workspace:
|
||||
| {
|
||||
transport: ManagedRemoteTransportIdentity
|
||||
access: ReturnType<ManagedRemoteWorkspaceAccessFactory['create']>
|
||||
}
|
||||
| undefined
|
||||
#workspaceTail: Promise<void> = Promise.resolve()
|
||||
#disposePromise?: Promise<void>
|
||||
|
||||
constructor(
|
||||
private readonly workspaceFactory:
|
||||
Pick<ManagedRemoteWorkspaceAccessFactory, 'create'>,
|
||||
private readonly workspaceBinding: RemoteWorkspaceProjectBinding,
|
||||
private readonly connection: NonNullable<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
RemoteAgentServices['connectionManager']['acquire']
|
||||
>
|
||||
>
|
||||
>,
|
||||
private readonly runtime: Awaited<
|
||||
ReturnType<RemoteRuntimeInstallationManager['ensureInstalled']>
|
||||
>,
|
||||
private readonly modelBridge: ManagedModelBridge
|
||||
) {}
|
||||
|
||||
currentTransportIdentity(): ManagedRemoteTransportIdentity {
|
||||
return {
|
||||
controllerGeneration: this.connection.client.generation,
|
||||
daemonBootId: this.connection.status.daemonBootId
|
||||
}
|
||||
}
|
||||
|
||||
async prepareTransport(): Promise<ManagedRemoteTransportIdentity> {
|
||||
if (this.connection.state !== 'ready') {
|
||||
await this.connection.reconnect()
|
||||
}
|
||||
assertConnectionMatches(this.workspaceBinding, this.connection)
|
||||
const beforeRefresh = this.currentTransportIdentity()
|
||||
const capabilities = await this.connection.refreshCapabilities()
|
||||
assertConnectionMatches(this.workspaceBinding, this.connection)
|
||||
assertSameTransport(
|
||||
beforeRefresh,
|
||||
this.currentTransportIdentity(),
|
||||
'Remote Agent transport changed while refreshing capabilities'
|
||||
)
|
||||
assertRuntimeAdvertised(this.runtime, capabilities)
|
||||
await this.ensureWorkspace(beforeRefresh)
|
||||
assertSameTransport(
|
||||
beforeRefresh,
|
||||
this.currentTransportIdentity(),
|
||||
'Remote Agent transport changed while opening the Workspace'
|
||||
)
|
||||
return beforeRefresh
|
||||
}
|
||||
|
||||
async openChannel(
|
||||
expected: ManagedRemoteTransportIdentity,
|
||||
bindingId: string
|
||||
) {
|
||||
const transport = await this.prepareTransport()
|
||||
assertSameTransport(
|
||||
expected,
|
||||
transport,
|
||||
'Remote Runtime transport lease is stale'
|
||||
)
|
||||
const channel = await createProtocolRemoteRuntimeChannel({
|
||||
connection: this.connection,
|
||||
openIdentity: {
|
||||
bindingId,
|
||||
runtimeId: 'opencode',
|
||||
runtimeBundleDigest: this.runtime.bundleDigest,
|
||||
workspaceIdentity: this.workspaceBinding.workspaceIdentity
|
||||
},
|
||||
modelBridge: this.modelBridge.channel
|
||||
})
|
||||
try {
|
||||
assertSameTransport(
|
||||
expected,
|
||||
this.currentTransportIdentity(),
|
||||
'Remote Agent transport changed while opening an ACP channel'
|
||||
)
|
||||
return channel
|
||||
} catch (error) {
|
||||
await channel.close().catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
ensureWorkspace(
|
||||
transport: ManagedRemoteTransportIdentity
|
||||
): Promise<void> {
|
||||
const operation = this.#workspaceTail.then(async () => {
|
||||
if (
|
||||
this.#workspace !== undefined &&
|
||||
sameTransport(this.#workspace.transport, transport)
|
||||
) {
|
||||
await this.#workspace.access.getIdentity()
|
||||
assertSameTransport(
|
||||
transport,
|
||||
this.currentTransportIdentity(),
|
||||
'Remote Agent transport changed while checking the Workspace'
|
||||
)
|
||||
return
|
||||
}
|
||||
const previous = this.#workspace
|
||||
this.#workspace = undefined
|
||||
if (previous !== undefined) {
|
||||
await previous.access.dispose().catch(() => undefined)
|
||||
}
|
||||
const access = this.workspaceFactory.create(
|
||||
this.workspaceBinding
|
||||
)
|
||||
try {
|
||||
const identity = await access.getIdentity()
|
||||
if (
|
||||
identity.kind !== 'remote' ||
|
||||
identity.canonicalDisplayPath !==
|
||||
this.workspaceBinding.remoteRootPath ||
|
||||
identity.id !==
|
||||
`${this.workspaceBinding.hostId}:${this.workspaceBinding.workspaceIdentity}`
|
||||
) {
|
||||
throw new Error(
|
||||
'Live remote Workspace does not match persisted validation'
|
||||
)
|
||||
}
|
||||
assertSameTransport(
|
||||
transport,
|
||||
this.currentTransportIdentity(),
|
||||
'Remote Agent transport changed while opening the Workspace'
|
||||
)
|
||||
this.#workspace = { transport, access }
|
||||
} catch (error) {
|
||||
await access.dispose().catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
this.#workspaceTail = operation.catch(() => undefined)
|
||||
return operation
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
this.#disposePromise ??= this.#dispose()
|
||||
return this.#disposePromise
|
||||
}
|
||||
|
||||
async #dispose(): Promise<void> {
|
||||
await this.#workspaceTail
|
||||
const errors: unknown[] = []
|
||||
try {
|
||||
await this.#workspace?.access.dispose()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
try {
|
||||
this.connection.release()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
'Remote Runtime resources failed to release'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedRemoteAcpRuntime implements AgentRuntime {
|
||||
readonly requiresToolApproval = false
|
||||
readonly supportsScopedDataTools = false
|
||||
readonly capability = 'chat' as const
|
||||
readonly runtimeId = 'opencode' as const
|
||||
#disposePromise?: Promise<void>
|
||||
#transitionTail: Promise<void> = Promise.resolve()
|
||||
#activeRuns = 0
|
||||
#draining = false
|
||||
#disposed = false
|
||||
|
||||
constructor(
|
||||
private remote: AcpRemoteRuntime,
|
||||
private transport: ManagedRemoteTransportIdentity,
|
||||
private readonly createRemote: (
|
||||
transport: ManagedRemoteTransportIdentity
|
||||
) => AcpRemoteRuntime,
|
||||
private readonly resources: ManagedRemoteRuntimeResources
|
||||
) {}
|
||||
|
||||
get supportsToolExecution(): boolean {
|
||||
return this.remote.supportsToolExecution
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.remote.getStatus()
|
||||
}
|
||||
|
||||
run(
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal,
|
||||
authorize?: RuntimeAuthorizer
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
return this.#run(request, signal, authorize)
|
||||
}
|
||||
|
||||
async *#run(
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal,
|
||||
authorize?: RuntimeAuthorizer
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
const remote = await this.#acquireRun()
|
||||
let completed = false
|
||||
try {
|
||||
yield* remote.run(request, signal, authorize)
|
||||
completed = true
|
||||
} finally {
|
||||
this.#activeRuns -= 1
|
||||
if (completed) {
|
||||
try {
|
||||
const current = this.resources.currentTransportIdentity()
|
||||
if (
|
||||
current.daemonBootId === this.transport.daemonBootId &&
|
||||
current.controllerGeneration >
|
||||
this.transport.controllerGeneration
|
||||
) {
|
||||
this.transport = current
|
||||
}
|
||||
} catch {
|
||||
// A later run will perform the authoritative transport transition.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #acquireRun(): Promise<AcpRemoteRuntime> {
|
||||
let acquired: AcpRemoteRuntime | undefined
|
||||
const operation = this.#transitionTail.then(async () => {
|
||||
if (this.#disposed) {
|
||||
throw new Error('远端 Runtime 已关闭')
|
||||
}
|
||||
if (this.#draining) {
|
||||
throw new Error('远端 Runtime 正在退役')
|
||||
}
|
||||
const currentTransport = await this.resources.prepareTransport()
|
||||
if (this.#disposed) {
|
||||
throw new Error('远端 Runtime 已关闭')
|
||||
}
|
||||
if (this.#draining) {
|
||||
throw new Error('远端 Runtime 正在退役')
|
||||
}
|
||||
if (!sameTransport(this.transport, currentTransport)) {
|
||||
if (this.#activeRuns > 0) {
|
||||
throw new Error(
|
||||
'Remote Agent transport changed while another Runtime request was active'
|
||||
)
|
||||
}
|
||||
await this.remote.dispose()
|
||||
if (this.#disposed) {
|
||||
throw new Error('远端 Runtime 已关闭')
|
||||
}
|
||||
if (this.#draining) {
|
||||
throw new Error('远端 Runtime 正在退役')
|
||||
}
|
||||
this.remote = this.createRemote(currentTransport)
|
||||
this.transport = currentTransport
|
||||
}
|
||||
this.#activeRuns += 1
|
||||
acquired = this.remote
|
||||
})
|
||||
this.#transitionTail = operation.catch(() => undefined)
|
||||
await operation
|
||||
return acquired!
|
||||
}
|
||||
|
||||
releaseConversation(conversationId: string): Promise<void> {
|
||||
const operation = this.#transitionTail.then(() =>
|
||||
this.remote.releaseConversation(conversationId)
|
||||
)
|
||||
this.#transitionTail = operation.catch(() => undefined)
|
||||
return operation
|
||||
}
|
||||
|
||||
beginDrain(): Promise<void> {
|
||||
this.#draining = true
|
||||
const operation = this.#transitionTail.then(() =>
|
||||
this.remote.beginDrain()
|
||||
)
|
||||
this.#transitionTail = operation.catch(() => undefined)
|
||||
return operation
|
||||
}
|
||||
|
||||
waitForDrain(): Promise<void> {
|
||||
return this.#transitionTail.then(() => this.remote.waitForDrain())
|
||||
}
|
||||
|
||||
forceShutdown(): Promise<void> {
|
||||
this.#disposed = true
|
||||
return this.#transitionTail
|
||||
.then(() => this.remote.forceShutdown())
|
||||
.finally(() => this.resources.dispose())
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
this.#disposed = true
|
||||
this.#disposePromise ??= this.#dispose()
|
||||
return this.#disposePromise
|
||||
}
|
||||
|
||||
async #dispose(): Promise<void> {
|
||||
await this.#transitionTail
|
||||
const results = await Promise.allSettled([
|
||||
this.remote.dispose(),
|
||||
this.resources.dispose()
|
||||
])
|
||||
const errors = results.flatMap((result) =>
|
||||
result.status === 'rejected' ? [result.reason] : []
|
||||
)
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
'Managed remote Runtime failed to dispose'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameTransport(
|
||||
expected: ManagedRemoteTransportIdentity,
|
||||
actual: ManagedRemoteTransportIdentity
|
||||
): boolean {
|
||||
return (
|
||||
expected.controllerGeneration === actual.controllerGeneration &&
|
||||
expected.daemonBootId === actual.daemonBootId
|
||||
)
|
||||
}
|
||||
|
||||
function assertSameTransport(
|
||||
expected: ManagedRemoteTransportIdentity,
|
||||
actual: ManagedRemoteTransportIdentity,
|
||||
message: string
|
||||
): void {
|
||||
if (!sameTransport(expected, actual)) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceProjectBinding(
|
||||
descriptor: SshExecutionSpaceDescriptor
|
||||
): RemoteWorkspaceProjectBinding {
|
||||
const validation = descriptor.validation
|
||||
const runtime = descriptor.runtimeValidation
|
||||
if (
|
||||
validation === undefined ||
|
||||
runtime === undefined ||
|
||||
validation.agentInstallationIdAtValidation !==
|
||||
runtime.agentInstallationIdAtValidation
|
||||
) {
|
||||
throw new Error('Remote project validation evidence is inconsistent')
|
||||
}
|
||||
return {
|
||||
hostId: descriptor.hostId,
|
||||
hostRevision: validation.hostRevision,
|
||||
hostKeyGeneration: validation.hostKeyGeneration,
|
||||
remoteUsername: validation.remoteUsername,
|
||||
remoteRootPath: descriptor.remoteRootPath,
|
||||
workspaceIdentity: validation.workspaceIdentity,
|
||||
agentInstallationId:
|
||||
validation.agentInstallationIdAtValidation,
|
||||
agentBinaryDigest:
|
||||
validation.agentBinaryDigestAtValidation,
|
||||
agentVersion: validation.agentVersionAtValidation,
|
||||
agentArchitecture:
|
||||
validation.agentArchitectureAtValidation,
|
||||
agentProtocolMajor: validation.agentProtocolMajor
|
||||
}
|
||||
}
|
||||
|
||||
function assertAgentMatches(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
agent: Awaited<
|
||||
ReturnType<RemoteAgentServices['installationManager']['ensureInstalled']>
|
||||
>
|
||||
): void {
|
||||
if (
|
||||
agent.installationId !== binding.agentInstallationId ||
|
||||
agent.binaryDigest !== binding.agentBinaryDigest ||
|
||||
agent.agentVersion !== binding.agentVersion ||
|
||||
agent.architecture !== binding.agentArchitecture ||
|
||||
agent.protocol.major !== binding.agentProtocolMajor
|
||||
) {
|
||||
throw new Error(
|
||||
'Installed remote Agent does not match project validation'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRuntimeMatches(
|
||||
validation: NonNullable<
|
||||
SshExecutionSpaceDescriptor['runtimeValidation']
|
||||
>,
|
||||
architecture: 'x64' | 'arm64',
|
||||
runtime: Awaited<
|
||||
ReturnType<RemoteRuntimeInstallationManager['ensureInstalled']>
|
||||
>
|
||||
): void {
|
||||
if (
|
||||
runtime.runtimeId !== 'opencode' ||
|
||||
runtime.architecture !== architecture ||
|
||||
runtime.bundleDigest !== validation.runtimeBundleDigest ||
|
||||
runtime.runtimeAdapterDigest !==
|
||||
validation.runtimeAdapterDigest
|
||||
) {
|
||||
throw new Error(
|
||||
'Installed OpenCode Runtime does not match project validation'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertConnectionMatches(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
connection: Awaited<
|
||||
ReturnType<RemoteAgentServices['connectionManager']['acquire']>
|
||||
>
|
||||
): void {
|
||||
if (
|
||||
connection.state !== 'ready' ||
|
||||
connection.identity.hostId !== binding.hostId ||
|
||||
connection.identity.hostRevision !== binding.hostRevision ||
|
||||
connection.identity.hostKeyGeneration !==
|
||||
binding.hostKeyGeneration ||
|
||||
connection.identity.remoteUsername !== binding.remoteUsername ||
|
||||
connection.identity.installationId !==
|
||||
binding.agentInstallationId ||
|
||||
connection.identity.binaryDigest !== binding.agentBinaryDigest ||
|
||||
connection.identity.protocolMajor !== binding.agentProtocolMajor ||
|
||||
connection.status.state !== 'ready' ||
|
||||
connection.status.draining ||
|
||||
connection.status.installationId !== binding.agentInstallationId ||
|
||||
connection.status.binaryDigest !== binding.agentBinaryDigest ||
|
||||
connection.status.agentVersion !== binding.agentVersion ||
|
||||
connection.status.architecture !== binding.agentArchitecture
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Agent connection does not match project validation'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRuntimeAdvertised(
|
||||
runtime: Awaited<
|
||||
ReturnType<RemoteRuntimeInstallationManager['ensureInstalled']>
|
||||
>,
|
||||
capabilities: Awaited<
|
||||
ReturnType<
|
||||
RemoteAgentServices['connectionManager']['acquire']
|
||||
>
|
||||
>['capabilities']
|
||||
): void {
|
||||
const capability = capabilities.capabilities.find(
|
||||
(entry) => entry.name === RUNTIME_ACP_CAPABILITY.name
|
||||
)
|
||||
const modelBridgeCapability = capabilities.capabilities.find(
|
||||
(entry) => entry.name === RUNTIME_MODEL_BRIDGE_CAPABILITY.name
|
||||
)
|
||||
const advertised = capabilities.runtimes.filter(
|
||||
(entry) => entry.runtimeId === 'opencode'
|
||||
)
|
||||
if (
|
||||
capability === undefined ||
|
||||
capability.version !==
|
||||
RUNTIME_ACP_CAPABILITY.exactVersion ||
|
||||
!capability.critical ||
|
||||
modelBridgeCapability === undefined ||
|
||||
modelBridgeCapability.version !==
|
||||
RUNTIME_MODEL_BRIDGE_CAPABILITY.exactVersion ||
|
||||
!modelBridgeCapability.critical ||
|
||||
advertised.length !== 1 ||
|
||||
advertised[0]!.version !== runtime.runtimeVersion ||
|
||||
advertised[0]!.bundleDigest !== runtime.bundleDigest ||
|
||||
advertised[0]!.acpCapabilitiesDigest !==
|
||||
runtime.acpCapabilitiesDigest ||
|
||||
!advertised[0]!.sessionLoad ||
|
||||
!advertised[0]!.sessionResume
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Agent does not advertise the validated OpenCode Runtime'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { join } from 'node:path'
|
||||
import { SqliteRuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
|
||||
import { ModelCallOperationStore } from '../agent/model-call-operation-store'
|
||||
import { RemoteModelGateway } from '../agent/remote-model-gateway'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import type { SshHostStore } from '../ssh/ssh-host-store'
|
||||
import {
|
||||
resolveBundledRemoteRuntimeResourcePaths,
|
||||
type BundledRemoteRuntimeResourcePaths
|
||||
} from './bundled-remote-runtime-resources'
|
||||
import { ManagedRemoteProjectRuntimeValidator } from './managed-remote-project-runtime-validator'
|
||||
import {
|
||||
createManagedRemoteAcpRuntime,
|
||||
type ManagedRemoteAcpRuntimeOptions
|
||||
} from './managed-remote-acp-runtime'
|
||||
import { ManagedRemoteWorkspaceAccessFactory } from './managed-remote-workspace-access-factory'
|
||||
import type { RemoteAgentServices } from './remote-agent-services'
|
||||
import { RemoteProjectSaveService } from './remote-project-save-service'
|
||||
import {
|
||||
RemoteRuntimeInstallationManager,
|
||||
type RemoteRuntimeActivator
|
||||
} from './remote-runtime-installation-manager'
|
||||
import {
|
||||
createRemoteRuntimeResourceLoader,
|
||||
loadRemoteRuntimeVerificationMetadata
|
||||
} from './remote-runtime-resource-loader'
|
||||
import {
|
||||
createManagedModelBridge,
|
||||
reconcileStartupModelCalls
|
||||
} from './managed-model-bridge'
|
||||
|
||||
const RUNTIME_BINDING_DATABASE_NAME = 'remote-runtime-bindings.sqlite'
|
||||
const MODEL_CALL_DATABASE_NAME = 'remote-model-calls-v2.sqlite'
|
||||
|
||||
export type ManagedRemoteExecutionServicesOptions = {
|
||||
sshHostStore: SshHostStore
|
||||
agentServices: Pick<
|
||||
RemoteAgentServices,
|
||||
| 'targetResolver'
|
||||
| 'sshPool'
|
||||
| 'installationManager'
|
||||
| 'connectionManager'
|
||||
| 'controllerState'
|
||||
>
|
||||
userDataPath: string
|
||||
appPath: string
|
||||
resourcesPath: string
|
||||
packaged: boolean
|
||||
resolveModelProfile(
|
||||
selection: AgentRuntimeSelection
|
||||
): Promise<ResolvedModelProfile | undefined>
|
||||
resolveRuntimeSelection(
|
||||
selection: AgentRuntimeSelection
|
||||
): Promise<AgentRuntimeSelection>
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-only lifecycle owner for verified remote execution primitives.
|
||||
* Construction performs no SSH or provider work.
|
||||
*/
|
||||
export class ManagedRemoteExecutionServices {
|
||||
readonly runtimeResourcePaths: BundledRemoteRuntimeResourcePaths
|
||||
readonly runtimeInstallationManager: RemoteRuntimeInstallationManager
|
||||
readonly runtimeValidator: ManagedRemoteProjectRuntimeValidator
|
||||
readonly workspaceAccessFactory: ManagedRemoteWorkspaceAccessFactory
|
||||
readonly bindingStore: SqliteRuntimeSessionBindingStore
|
||||
readonly modelCallStore: ModelCallOperationStore
|
||||
readonly modelGateway: RemoteModelGateway
|
||||
|
||||
#disposePromise?: Promise<void>
|
||||
readonly #readiness: Promise<void>
|
||||
readonly #resolveRuntimeSelection: ManagedRemoteExecutionServicesOptions['resolveRuntimeSelection']
|
||||
readonly #sshHosts: SshHostStore
|
||||
readonly #agentServices:
|
||||
ManagedRemoteExecutionServicesOptions['agentServices']
|
||||
|
||||
constructor(options: ManagedRemoteExecutionServicesOptions) {
|
||||
this.#agentServices = options.agentServices
|
||||
this.#resolveRuntimeSelection = options.resolveRuntimeSelection
|
||||
this.#sshHosts = options.sshHostStore
|
||||
this.runtimeResourcePaths =
|
||||
resolveBundledRemoteRuntimeResourcePaths({
|
||||
appPath: options.appPath,
|
||||
resourcesPath: options.resourcesPath,
|
||||
packaged: options.packaged
|
||||
})
|
||||
const activate: RemoteRuntimeActivator = async (
|
||||
lease,
|
||||
runtimeId,
|
||||
bundleDigest,
|
||||
architecture,
|
||||
signal
|
||||
) => {
|
||||
const agent =
|
||||
await options.agentServices.installationManager.ensureInstalled(
|
||||
lease.identity.hostId,
|
||||
{ signal }
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
const current = await options.agentServices.targetResolver.resolve(
|
||||
lease.identity.hostId
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
if (
|
||||
runtimeId !== 'opencode' ||
|
||||
current.host.id !== lease.identity.hostId ||
|
||||
current.hostRevision !== lease.identity.hostRevision ||
|
||||
current.hostKeyGeneration !==
|
||||
lease.identity.hostKeyGeneration ||
|
||||
agent.architecture !== architecture
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Agent or SSH host identity changed before Runtime activation'
|
||||
)
|
||||
}
|
||||
const result = await lease.runAgentRuntimeAction(
|
||||
agent.installationId,
|
||||
{
|
||||
kind: 'runtime-activate',
|
||||
runtimeId,
|
||||
bundleDigest,
|
||||
architecture
|
||||
},
|
||||
signal
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error('Remote Runtime activation failed')
|
||||
}
|
||||
}
|
||||
this.runtimeInstallationManager =
|
||||
new RemoteRuntimeInstallationManager({
|
||||
resolver: options.agentServices.targetResolver,
|
||||
sshPool: options.agentServices.sshPool,
|
||||
loadVerifiedBundle: createRemoteRuntimeResourceLoader(
|
||||
this.runtimeResourcePaths
|
||||
),
|
||||
loadVerificationMetadata: () =>
|
||||
loadRemoteRuntimeVerificationMetadata(
|
||||
this.runtimeResourcePaths
|
||||
),
|
||||
activate
|
||||
})
|
||||
this.runtimeValidator = new ManagedRemoteProjectRuntimeValidator({
|
||||
installationManager: this.runtimeInstallationManager,
|
||||
resolveModelProfile: options.resolveModelProfile
|
||||
})
|
||||
this.workspaceAccessFactory =
|
||||
new ManagedRemoteWorkspaceAccessFactory({
|
||||
installationManager:
|
||||
options.agentServices.installationManager,
|
||||
connectionManager: options.agentServices.connectionManager
|
||||
})
|
||||
this.bindingStore = new SqliteRuntimeSessionBindingStore(
|
||||
join(options.userDataPath, RUNTIME_BINDING_DATABASE_NAME)
|
||||
)
|
||||
this.modelCallStore = new ModelCallOperationStore(
|
||||
join(options.userDataPath, MODEL_CALL_DATABASE_NAME)
|
||||
)
|
||||
this.modelGateway = new RemoteModelGateway({
|
||||
store: this.modelCallStore
|
||||
})
|
||||
this.#readiness = reconcileStartupModelCalls({
|
||||
gatewayStore: this.modelCallStore,
|
||||
bindingStore: this.bindingStore
|
||||
})
|
||||
}
|
||||
|
||||
createProjectSaveService(
|
||||
options: Pick<
|
||||
ConstructorParameters<typeof RemoteProjectSaveService>[0],
|
||||
'database' | 'notify'
|
||||
>
|
||||
): RemoteProjectSaveService {
|
||||
return new RemoteProjectSaveService({
|
||||
database: options.database,
|
||||
sshHosts: this.#sshHosts,
|
||||
installationManager:
|
||||
this.#agentServices.installationManager,
|
||||
connectionManager: this.#agentServices.connectionManager,
|
||||
resolveRuntimeSelection: this.#resolveRuntimeSelection,
|
||||
runtimeValidator: this.runtimeValidator,
|
||||
...(options.notify ? { notify: options.notify } : {})
|
||||
})
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.#readiness
|
||||
}
|
||||
|
||||
async createRuntime(
|
||||
options: Pick<
|
||||
ManagedRemoteAcpRuntimeOptions,
|
||||
'executionSpace' | 'selection' | 'usage'
|
||||
> & {
|
||||
modelProfile: ResolvedModelProfile
|
||||
}
|
||||
) {
|
||||
await this.#readiness
|
||||
const modelBridge = createManagedModelBridge({
|
||||
profile: options.modelProfile,
|
||||
gateway: this.modelGateway,
|
||||
bindingStore: this.bindingStore
|
||||
})
|
||||
return createManagedRemoteAcpRuntime({
|
||||
...options,
|
||||
modelBridge,
|
||||
agentServices: {
|
||||
installationManager:
|
||||
this.#agentServices.installationManager,
|
||||
connectionManager: this.#agentServices.connectionManager,
|
||||
controllerState: this.#agentServices.controllerState
|
||||
},
|
||||
runtimeInstallationManager:
|
||||
this.runtimeInstallationManager,
|
||||
workspaceAccessFactory: this.workspaceAccessFactory,
|
||||
bindingStore: this.bindingStore
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
this.#disposePromise ??= this.#dispose()
|
||||
return this.#disposePromise
|
||||
}
|
||||
|
||||
async #dispose(): Promise<void> {
|
||||
const errors: unknown[] = []
|
||||
const settle = async (
|
||||
operation: () => void | Promise<void>
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await operation()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
await settle(() => this.runtimeInstallationManager.dispose())
|
||||
await settle(() => this.bindingStore.dispose())
|
||||
await settle(() => this.modelCallStore.dispose())
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
'Managed remote execution services failed to dispose completely'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
DaemonCapabilities,
|
||||
DaemonStatus
|
||||
} from '../../shared/agent-protocol'
|
||||
import type { RemoteAgentConnection } from './remote-agent-connection-manager'
|
||||
import { ManagedRemoteProjectRuntimeValidator } from './managed-remote-project-runtime-validator'
|
||||
import type { RemoteProjectRuntimeValidationInput } from './remote-project-save-service'
|
||||
import type { RemoteRuntimeInstallationIdentity } from './remote-runtime-installation-manager'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
|
||||
const digest = (character: string): string =>
|
||||
`sha256:${character.repeat(64)}`
|
||||
|
||||
const installation: RemoteRuntimeInstallationIdentity = {
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: '1.2.3',
|
||||
bundleDigest: digest('b'),
|
||||
manifestDigest: digest('c'),
|
||||
runtimeAdapterDigest: digest('d'),
|
||||
acpCapabilitiesDigest: digest('f'),
|
||||
platform: 'linux',
|
||||
architecture: 'x64'
|
||||
}
|
||||
const modelProfile: ResolvedModelProfile = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: 'Private model',
|
||||
baseUrl: 'https://provider.example/v1',
|
||||
modelName: 'private-model',
|
||||
protocol: 'openai-responses',
|
||||
authentication: 'api-key',
|
||||
apiKey: 'main-only-secret'
|
||||
}
|
||||
|
||||
function advertisedCapabilities(): DaemonCapabilities {
|
||||
return {
|
||||
generation: 2,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true },
|
||||
{ name: 'runtime/acp', version: 3, critical: true },
|
||||
{
|
||||
name: 'runtime/model-bridge',
|
||||
version: 1,
|
||||
critical: true
|
||||
}
|
||||
],
|
||||
runtimes: [
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
version: installation.runtimeVersion,
|
||||
bundleDigest: installation.bundleDigest,
|
||||
acpCapabilitiesDigest:
|
||||
installation.acpCapabilitiesDigest,
|
||||
sessionLoad: true,
|
||||
sessionResume: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function harness(options: {
|
||||
selection?: RemoteProjectRuntimeValidationInput['selection']
|
||||
workMode?: 'ask' | 'execute'
|
||||
architecture?: 'x64' | 'arm64'
|
||||
installed?: RemoteRuntimeInstallationIdentity
|
||||
refreshed?: DaemonCapabilities
|
||||
modelProfile?: ResolvedModelProfile | null
|
||||
} = {}) {
|
||||
const calls: string[] = []
|
||||
const selection =
|
||||
options.selection ??
|
||||
({
|
||||
provider: 'opencode'
|
||||
} as const)
|
||||
const workMode = options.workMode ?? 'ask'
|
||||
const architecture = options.architecture ?? 'x64'
|
||||
const installed = options.installed ?? installation
|
||||
let state: RemoteAgentConnection['state'] = 'ready'
|
||||
let capabilities: DaemonCapabilities = {
|
||||
generation: 1,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true }
|
||||
],
|
||||
runtimes: []
|
||||
}
|
||||
const status: DaemonStatus = {
|
||||
state: 'ready',
|
||||
installationId: 'agent-installation',
|
||||
binaryDigest: digest('a'),
|
||||
daemonBootId: 'daemon-boot',
|
||||
agentVersion: '2.0.0',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
platform: 'linux',
|
||||
architecture,
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: 'remote-user',
|
||||
draining: false
|
||||
}
|
||||
const refreshCapabilities = vi.fn(async () => {
|
||||
calls.push('refresh')
|
||||
capabilities = structuredClone(
|
||||
options.refreshed ?? advertisedCapabilities()
|
||||
)
|
||||
return structuredClone(capabilities)
|
||||
})
|
||||
const connection = {
|
||||
identity: {
|
||||
cacheKey: 'cache-key',
|
||||
hostId: 'host-1',
|
||||
hostRevision: 3,
|
||||
hostKeyGeneration: 2,
|
||||
remoteUsername: 'builder',
|
||||
installationId: status.installationId,
|
||||
binaryDigest: status.binaryDigest,
|
||||
protocolMajor: 1,
|
||||
protocolMinor: 0
|
||||
},
|
||||
status,
|
||||
get capabilities() {
|
||||
return capabilities
|
||||
},
|
||||
client: {},
|
||||
get state() {
|
||||
return state
|
||||
},
|
||||
refreshCapabilities,
|
||||
reconnect: vi.fn(),
|
||||
release: vi.fn()
|
||||
} as unknown as RemoteAgentConnection
|
||||
const ensureInstalled = vi.fn(async () => {
|
||||
calls.push('install')
|
||||
return installed
|
||||
})
|
||||
const resolveModelProfile = vi.fn(async () => {
|
||||
calls.push('model')
|
||||
return options.modelProfile === null
|
||||
? undefined
|
||||
: options.modelProfile ?? modelProfile
|
||||
})
|
||||
const validator = new ManagedRemoteProjectRuntimeValidator({
|
||||
installationManager: { ensureInstalled },
|
||||
resolveModelProfile,
|
||||
now: () => new Date('2030-01-01T00:00:00.000Z')
|
||||
})
|
||||
const signal = new AbortController().signal
|
||||
const input: RemoteProjectRuntimeValidationInput = {
|
||||
selection,
|
||||
runtimeSelectionKey: `${selection.provider}:${
|
||||
'profileId' in selection
|
||||
? selection.profileId ?? 'platform'
|
||||
: 'default'
|
||||
}`,
|
||||
workMode,
|
||||
host: {
|
||||
hostId: 'host-1',
|
||||
hostRevision: 3,
|
||||
hostKeyGeneration: 2,
|
||||
remoteUsername: 'builder'
|
||||
},
|
||||
agent: {
|
||||
installationId: status.installationId,
|
||||
binaryDigest: status.binaryDigest,
|
||||
version: status.agentVersion,
|
||||
architecture,
|
||||
protocolMajor: 1
|
||||
},
|
||||
workspace: {
|
||||
canonicalRemoteRoot: '/srv/project',
|
||||
workspaceIdentity: 'workspace-identity',
|
||||
workspaceId: 'workspace-id',
|
||||
generation: 1,
|
||||
access: 'read-only',
|
||||
capabilities: ['read-text']
|
||||
},
|
||||
connection,
|
||||
signal
|
||||
}
|
||||
return {
|
||||
validator,
|
||||
input,
|
||||
connection,
|
||||
calls,
|
||||
ensureInstalled,
|
||||
refreshCapabilities,
|
||||
resolveModelProfile,
|
||||
setCapabilities: (value: DaemonCapabilities) => {
|
||||
capabilities = value
|
||||
},
|
||||
setState: (value: RemoteAgentConnection['state']) => {
|
||||
state = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
it('installs, refreshes, and leases exact Ask Runtime evidence', async () => {
|
||||
const test = harness()
|
||||
const lease = await test.validator.validate(test.input)
|
||||
|
||||
expect(test.calls).toEqual(['model', 'install', 'refresh'])
|
||||
expect(test.ensureInstalled).toHaveBeenCalledWith('host-1', {
|
||||
signal: test.input.signal
|
||||
})
|
||||
expect(lease.evidence).toEqual({
|
||||
runtimeSelectionKey: 'opencode:default',
|
||||
runtimeBundleDigest: installation.bundleDigest,
|
||||
runtimeAdapterDigest: installation.runtimeAdapterDigest,
|
||||
agentInstallationIdAtValidation: 'agent-installation',
|
||||
validatedAt: '2030-01-01T00:00:00.000Z',
|
||||
workMode: 'ask'
|
||||
})
|
||||
expect(() => lease.assertCurrent()).not.toThrow()
|
||||
|
||||
test.setCapabilities({
|
||||
...advertisedCapabilities(),
|
||||
generation: 3
|
||||
})
|
||||
expect(() => lease.assertCurrent()).toThrow(/stale/iu)
|
||||
lease.release()
|
||||
lease.release()
|
||||
expect(() => lease.assertCurrent()).toThrow(/released/iu)
|
||||
})
|
||||
|
||||
it('validates Execute without a second authorization tier', async () => {
|
||||
const test = harness({ workMode: 'execute' })
|
||||
const lease = await test.validator.validate(test.input)
|
||||
|
||||
expect(lease.evidence).toMatchObject({
|
||||
workMode: 'execute'
|
||||
})
|
||||
test.setState('offline')
|
||||
expect(() => lease.assertCurrent()).toThrow(/not ready/iu)
|
||||
})
|
||||
|
||||
it('rejects non-OpenCode selections before installation', async () => {
|
||||
const test = harness({
|
||||
selection: {
|
||||
provider: 'model',
|
||||
profileId: '00000000-0000-4000-8000-000000000001'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(test.validator.validate(test.input)).rejects.toThrow(
|
||||
/OpenCode Runtime/iu
|
||||
)
|
||||
expect(test.ensureInstalled).not.toHaveBeenCalled()
|
||||
expect(test.refreshCapabilities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires the installed Runtime architecture to exactly match the Agent', async () => {
|
||||
const test = harness({ architecture: 'arm64' })
|
||||
|
||||
await expect(test.validator.validate(test.input)).rejects.toThrow(
|
||||
/architecture/iu
|
||||
)
|
||||
expect(test.refreshCapabilities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an unavailable model profile before remote installation', async () => {
|
||||
const test = harness({ modelProfile: null })
|
||||
|
||||
await expect(test.validator.validate(test.input)).rejects.toThrow(
|
||||
/model profile/iu
|
||||
)
|
||||
expect(test.resolveModelProfile).toHaveBeenCalledOnce()
|
||||
expect(test.ensureInstalled).not.toHaveBeenCalled()
|
||||
expect(test.refreshCapabilities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'missing model bridge',
|
||||
{
|
||||
...advertisedCapabilities(),
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true },
|
||||
{ name: 'runtime/acp', version: 3, critical: true }
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
'wrong model bridge version',
|
||||
{
|
||||
...advertisedCapabilities(),
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true },
|
||||
{ name: 'runtime/acp', version: 3, critical: true },
|
||||
{
|
||||
name: 'runtime/model-bridge',
|
||||
version: 3,
|
||||
critical: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
'non-critical ACP',
|
||||
{
|
||||
...advertisedCapabilities(),
|
||||
capabilities: [
|
||||
{
|
||||
name: 'runtime/acp',
|
||||
version: 3,
|
||||
critical: false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
'wrong ACP version',
|
||||
{
|
||||
...advertisedCapabilities(),
|
||||
capabilities: [
|
||||
{
|
||||
name: 'runtime/acp',
|
||||
version: 1,
|
||||
critical: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
'duplicate OpenCode Runtime',
|
||||
{
|
||||
...advertisedCapabilities(),
|
||||
runtimes: [
|
||||
...advertisedCapabilities().runtimes,
|
||||
...advertisedCapabilities().runtimes
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
'wrong Runtime version',
|
||||
{
|
||||
...advertisedCapabilities(),
|
||||
runtimes: [
|
||||
{
|
||||
...advertisedCapabilities().runtimes[0]!,
|
||||
version: '9.9.9'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
'wrong bundle digest',
|
||||
{
|
||||
...advertisedCapabilities(),
|
||||
runtimes: [
|
||||
{
|
||||
...advertisedCapabilities().runtimes[0]!,
|
||||
bundleDigest: digest('9')
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
'wrong ACP digest',
|
||||
{
|
||||
...advertisedCapabilities(),
|
||||
runtimes: [
|
||||
{
|
||||
...advertisedCapabilities().runtimes[0]!,
|
||||
acpCapabilitiesDigest: digest('8')
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
'missing session load',
|
||||
{
|
||||
...advertisedCapabilities(),
|
||||
runtimes: [
|
||||
{
|
||||
...advertisedCapabilities().runtimes[0]!,
|
||||
sessionLoad: false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
'missing session resume',
|
||||
{
|
||||
...advertisedCapabilities(),
|
||||
runtimes: [
|
||||
{
|
||||
...advertisedCapabilities().runtimes[0]!,
|
||||
sessionResume: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
])('fails closed for %s advertisement', async (_name, refreshed) => {
|
||||
const test = harness({
|
||||
refreshed: refreshed as DaemonCapabilities
|
||||
})
|
||||
|
||||
await expect(test.validator.validate(test.input)).rejects.toThrow(
|
||||
/Runtime|ACP/iu
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { projectRuntimeValidationSchema } from '../../shared/assistant-contracts'
|
||||
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import type { RemoteAgentConnection } from './remote-agent-connection-manager'
|
||||
import type {
|
||||
RemoteProjectRuntimeValidationEvidence,
|
||||
RemoteProjectRuntimeValidationInput,
|
||||
RemoteProjectRuntimeValidationLease,
|
||||
RemoteProjectRuntimeValidator
|
||||
} from './remote-project-save-service'
|
||||
import type {
|
||||
RemoteRuntimeInstallationIdentity,
|
||||
RemoteRuntimeInstallationManager
|
||||
} from './remote-runtime-installation-manager'
|
||||
|
||||
const RUNTIME_ACP_CAPABILITY = 'runtime/acp'
|
||||
const RUNTIME_ACP_CAPABILITY_VERSION = 3
|
||||
const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge'
|
||||
const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1
|
||||
|
||||
export class ManagedRemoteProjectRuntimeValidator
|
||||
implements RemoteProjectRuntimeValidator
|
||||
{
|
||||
readonly #installationManager: Pick<
|
||||
RemoteRuntimeInstallationManager,
|
||||
'ensureInstalled'
|
||||
>
|
||||
readonly #now: () => Date
|
||||
readonly #resolveModelProfile: (
|
||||
selection: RemoteProjectRuntimeValidationInput['selection']
|
||||
) => Promise<ResolvedModelProfile | undefined>
|
||||
|
||||
constructor(options: {
|
||||
installationManager: Pick<
|
||||
RemoteRuntimeInstallationManager,
|
||||
'ensureInstalled'
|
||||
>
|
||||
resolveModelProfile(
|
||||
selection: RemoteProjectRuntimeValidationInput['selection']
|
||||
): Promise<ResolvedModelProfile | undefined>
|
||||
now?: () => Date
|
||||
}) {
|
||||
this.#installationManager = options.installationManager
|
||||
this.#resolveModelProfile = options.resolveModelProfile
|
||||
this.#now = options.now ?? (() => new Date())
|
||||
}
|
||||
|
||||
async validate(
|
||||
input: RemoteProjectRuntimeValidationInput
|
||||
): Promise<RemoteProjectRuntimeValidationLease> {
|
||||
input.signal.throwIfAborted()
|
||||
if (
|
||||
input.selection.provider !== 'opencode' ||
|
||||
input.runtimeSelectionKey !==
|
||||
agentRuntimeSelectionKey(input.selection)
|
||||
) {
|
||||
throw new Error(
|
||||
'Managed remote projects require the OpenCode Runtime'
|
||||
)
|
||||
}
|
||||
assertInputIdentity(input)
|
||||
const modelProfile = await this.#resolveModelProfile(
|
||||
input.selection
|
||||
)
|
||||
input.signal.throwIfAborted()
|
||||
if (
|
||||
modelProfile === undefined ||
|
||||
modelProfile.protocol === 'openai-images-generations' ||
|
||||
(modelProfile.authentication === 'api-key' &&
|
||||
!modelProfile.apiKey)
|
||||
) {
|
||||
throw new Error(
|
||||
'Managed remote OpenCode requires a usable text model profile'
|
||||
)
|
||||
}
|
||||
const installation =
|
||||
await this.#installationManager.ensureInstalled(
|
||||
input.host.hostId,
|
||||
{ signal: input.signal }
|
||||
)
|
||||
input.signal.throwIfAborted()
|
||||
assertInstallationIdentity(input, installation)
|
||||
|
||||
const refreshed = await input.connection.refreshCapabilities(
|
||||
input.signal
|
||||
)
|
||||
input.signal.throwIfAborted()
|
||||
if (
|
||||
input.connection.state !== 'ready' ||
|
||||
!isDeepStrictEqual(input.connection.capabilities, refreshed)
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Agent capabilities changed during Runtime validation'
|
||||
)
|
||||
}
|
||||
assertRuntimeCapability(refreshed, installation)
|
||||
|
||||
const validated = projectRuntimeValidationSchema.parse({
|
||||
runtimeSelectionKey: input.runtimeSelectionKey,
|
||||
runtimeBundleDigest: installation.bundleDigest,
|
||||
runtimeAdapterDigest: installation.runtimeAdapterDigest,
|
||||
agentInstallationIdAtValidation: input.agent.installationId,
|
||||
validatedAt: this.#now().toISOString(),
|
||||
workMode: input.workMode
|
||||
})
|
||||
const evidence: RemoteProjectRuntimeValidationEvidence = validated
|
||||
const capabilitySnapshot = structuredClone(refreshed)
|
||||
let released = false
|
||||
return {
|
||||
evidence,
|
||||
assertCurrent: (): void => {
|
||||
if (released) {
|
||||
throw new Error(
|
||||
'Remote Runtime validation lease is released'
|
||||
)
|
||||
}
|
||||
if (input.connection.state !== 'ready') {
|
||||
throw new Error(
|
||||
'Remote Runtime validation connection is not ready'
|
||||
)
|
||||
}
|
||||
let current: RemoteAgentConnection['capabilities']
|
||||
try {
|
||||
current = input.connection.capabilities
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'Remote Runtime validation connection is not ready',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
if (!isDeepStrictEqual(current, capabilitySnapshot)) {
|
||||
throw new Error(
|
||||
'Remote Runtime capability validation is stale'
|
||||
)
|
||||
}
|
||||
},
|
||||
release: (): void => {
|
||||
released = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertInputIdentity(
|
||||
input: RemoteProjectRuntimeValidationInput
|
||||
): void {
|
||||
const connection = input.connection
|
||||
if (
|
||||
connection.state !== 'ready' ||
|
||||
connection.identity.hostId !== input.host.hostId ||
|
||||
connection.identity.hostRevision !== input.host.hostRevision ||
|
||||
connection.identity.hostKeyGeneration !==
|
||||
input.host.hostKeyGeneration ||
|
||||
connection.identity.remoteUsername !==
|
||||
input.host.remoteUsername ||
|
||||
connection.identity.installationId !==
|
||||
input.agent.installationId ||
|
||||
connection.identity.binaryDigest !== input.agent.binaryDigest ||
|
||||
connection.identity.protocolMajor !== input.agent.protocolMajor ||
|
||||
connection.status.state !== 'ready' ||
|
||||
connection.status.draining ||
|
||||
connection.status.installationId !==
|
||||
input.agent.installationId ||
|
||||
connection.status.binaryDigest !== input.agent.binaryDigest ||
|
||||
connection.status.agentVersion !== input.agent.version ||
|
||||
connection.status.architecture !== input.agent.architecture ||
|
||||
connection.status.protocol.major !== input.agent.protocolMajor
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Agent identity does not match Runtime validation'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertInstallationIdentity(
|
||||
input: RemoteProjectRuntimeValidationInput,
|
||||
installation: RemoteRuntimeInstallationIdentity
|
||||
): void {
|
||||
if (
|
||||
installation.runtimeId !== 'opencode' ||
|
||||
installation.platform !== 'linux' ||
|
||||
installation.architecture !== input.agent.architecture
|
||||
) {
|
||||
throw new Error(
|
||||
'Installed OpenCode Runtime architecture is incompatible'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRuntimeCapability(
|
||||
capabilities: RemoteAgentConnection['capabilities'],
|
||||
installation: RemoteRuntimeInstallationIdentity
|
||||
): void {
|
||||
const acp = capabilities.capabilities.find(
|
||||
(capability) => capability.name === RUNTIME_ACP_CAPABILITY
|
||||
)
|
||||
const modelBridge = capabilities.capabilities.find(
|
||||
(capability) =>
|
||||
capability.name === RUNTIME_MODEL_BRIDGE_CAPABILITY
|
||||
)
|
||||
const runtimes = capabilities.runtimes.filter(
|
||||
(runtime) => runtime.runtimeId === 'opencode'
|
||||
)
|
||||
if (
|
||||
acp === undefined ||
|
||||
acp.version !== RUNTIME_ACP_CAPABILITY_VERSION ||
|
||||
!acp.critical ||
|
||||
modelBridge === undefined ||
|
||||
modelBridge.version !==
|
||||
RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION ||
|
||||
!modelBridge.critical ||
|
||||
runtimes.length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Agent does not advertise the required Runtime ACP capability'
|
||||
)
|
||||
}
|
||||
const runtime = runtimes[0]!
|
||||
if (
|
||||
runtime.version !== installation.runtimeVersion ||
|
||||
runtime.bundleDigest !== installation.bundleDigest ||
|
||||
runtime.acpCapabilitiesDigest !==
|
||||
installation.acpCapabilitiesDigest ||
|
||||
!runtime.sessionLoad ||
|
||||
!runtime.sessionResume
|
||||
) {
|
||||
throw new Error(
|
||||
'Advertised OpenCode Runtime does not match the verified installation'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_PROTOCOL_VERSION,
|
||||
type DaemonCapabilities
|
||||
} from '../../shared/agent-protocol'
|
||||
import type { RemoteWorkspaceHandle } from '../../shared/remote-agent-contracts'
|
||||
import type { RemoteWorkspaceProjectBinding } from '../workspace/remote-workspace-access'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
import type {
|
||||
AgentInstallationIdentity,
|
||||
AgentInstallationManager
|
||||
} from './agent-installation-manager'
|
||||
import type { AgentProtocolClient } from './agent-protocol-client'
|
||||
import {
|
||||
ManagedRemoteWorkspaceAccessFactory,
|
||||
type ManagedRemoteWorkspaceAccessFactoryOptions
|
||||
} from './managed-remote-workspace-access-factory'
|
||||
import type {
|
||||
RemoteAgentConnection,
|
||||
RemoteAgentConnectionManager
|
||||
} from './remote-agent-connection-manager'
|
||||
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
const binding: RemoteWorkspaceProjectBinding = {
|
||||
hostId: '00000000-0000-4000-8000-000000000201',
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'builder',
|
||||
remoteRootPath: '/srv/project',
|
||||
workspaceIdentity: 'workspace-identity',
|
||||
agentInstallationId: 'installation-test',
|
||||
agentBinaryDigest: digest,
|
||||
agentVersion: '0.11.0',
|
||||
agentArchitecture: 'x64',
|
||||
agentProtocolMajor: AGENT_PROTOCOL_VERSION.major
|
||||
}
|
||||
const installation: AgentInstallationIdentity = {
|
||||
installationId: verifyAgentInstallationId(
|
||||
binding.agentInstallationId
|
||||
),
|
||||
binaryDigest: binding.agentBinaryDigest,
|
||||
agentVersion: binding.agentVersion,
|
||||
protocol: { ...AGENT_PROTOCOL_VERSION },
|
||||
platform: 'linux',
|
||||
architecture: binding.agentArchitecture,
|
||||
supervisor: 'detached-on-demand'
|
||||
}
|
||||
const handle: RemoteWorkspaceHandle = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceIdentity: binding.workspaceIdentity,
|
||||
canonicalDisplayPath: binding.remoteRootPath,
|
||||
access: 'read-only',
|
||||
git: 'available',
|
||||
capabilities: [
|
||||
'list',
|
||||
'stat',
|
||||
'read-text',
|
||||
'search',
|
||||
'git-status',
|
||||
'git-diff'
|
||||
],
|
||||
generation: 1
|
||||
}
|
||||
|
||||
describe('ManagedRemoteWorkspaceAccessFactory', () => {
|
||||
it('verifies installation before network work and owns the connection lease', async () => {
|
||||
const test = createHarness()
|
||||
const access = test.factory.create(binding)
|
||||
|
||||
await expect(access.getIdentity()).resolves.toEqual({
|
||||
kind: 'remote',
|
||||
id: `${binding.hostId}:${binding.workspaceIdentity}`,
|
||||
canonicalDisplayPath: binding.remoteRootPath,
|
||||
access: 'read-only'
|
||||
})
|
||||
|
||||
expect(test.events.slice(0, 2)).toEqual([
|
||||
'agent:ensure',
|
||||
'connection:acquire'
|
||||
])
|
||||
expect(test.protocolMethods).toContain('workspace/validate')
|
||||
expect(test.protocolMethods).not.toContain('workspace/open')
|
||||
expect(test.protocolMethods).not.toContain('workspace/resume')
|
||||
|
||||
await access.dispose()
|
||||
await access.dispose()
|
||||
expect(test.connectionRelease).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
test.protocolMethods.filter(
|
||||
(method) => method === 'workspace/close'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'installation id',
|
||||
{
|
||||
...installation,
|
||||
installationId:
|
||||
verifyAgentInstallationId('installation-other')
|
||||
}
|
||||
],
|
||||
[
|
||||
'binary digest',
|
||||
{
|
||||
...installation,
|
||||
binaryDigest: `sha256:${'b'.repeat(64)}`
|
||||
}
|
||||
],
|
||||
['version', { ...installation, agentVersion: '0.11.1' }],
|
||||
['architecture', { ...installation, architecture: 'arm64' as const }],
|
||||
[
|
||||
'protocol major',
|
||||
{
|
||||
...installation,
|
||||
protocol: {
|
||||
...installation.protocol,
|
||||
major: installation.protocol.major + 1
|
||||
}
|
||||
}
|
||||
]
|
||||
])(
|
||||
'rejects an installed Agent with a different persisted %s',
|
||||
async (_field, changedInstallation) => {
|
||||
const test = createHarness({
|
||||
ensureInstalled: async () => changedInstallation
|
||||
})
|
||||
const access = test.factory.create(binding)
|
||||
|
||||
await expect(access.getIdentity()).rejects.toThrow(
|
||||
'does not match the persisted project binding'
|
||||
)
|
||||
expect(test.acquire).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('propagates connection acquisition failures', async () => {
|
||||
const test = createHarness({
|
||||
acquireError: new Error('network unavailable')
|
||||
})
|
||||
const access = test.factory.create(binding)
|
||||
|
||||
await expect(access.getIdentity()).rejects.toThrow(
|
||||
'network unavailable'
|
||||
)
|
||||
expect(test.connectionRelease).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
type HarnessOptions = {
|
||||
acquireError?: Error
|
||||
ensureInstalled?: () => Promise<AgentInstallationIdentity>
|
||||
}
|
||||
|
||||
function createHarness(options: HarnessOptions = {}): {
|
||||
factory: ManagedRemoteWorkspaceAccessFactory
|
||||
events: string[]
|
||||
protocolMethods: string[]
|
||||
ensureInstalled: ReturnType<typeof vi.fn>
|
||||
acquire: ReturnType<typeof vi.fn>
|
||||
connectionRelease: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const events: string[] = []
|
||||
const protocolMethods: string[] = []
|
||||
const ensureInstalled = vi.fn(async () => {
|
||||
events.push('agent:ensure')
|
||||
return options.ensureInstalled
|
||||
? await options.ensureInstalled()
|
||||
: installation
|
||||
})
|
||||
const client = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
protocolMethods.push(method)
|
||||
if (method === 'agent/capabilities') {
|
||||
return capabilities
|
||||
}
|
||||
if (method === 'workspace/validate') {
|
||||
return {
|
||||
handle,
|
||||
validatedAt: '2026-08-21T00:00:00.000Z'
|
||||
}
|
||||
}
|
||||
if (method === 'workspace/close') {
|
||||
return {
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
closed: true
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected protocol method: ${method}`)
|
||||
})
|
||||
} as unknown as AgentProtocolClient
|
||||
const connectionRelease = vi.fn(() => {
|
||||
events.push('connection:release')
|
||||
})
|
||||
const connection: RemoteAgentConnection = {
|
||||
identity: {
|
||||
cacheKey: 'connection-cache',
|
||||
hostId: binding.hostId,
|
||||
hostRevision: binding.hostRevision,
|
||||
hostKeyGeneration: binding.hostKeyGeneration,
|
||||
remoteUsername: binding.remoteUsername,
|
||||
installationId: binding.agentInstallationId,
|
||||
binaryDigest: binding.agentBinaryDigest,
|
||||
protocolMajor: binding.agentProtocolMajor,
|
||||
protocolMinor: installation.protocol.minor
|
||||
},
|
||||
status: {
|
||||
state: 'ready',
|
||||
installationId: binding.agentInstallationId,
|
||||
binaryDigest: binding.agentBinaryDigest,
|
||||
daemonBootId: 'boot-1',
|
||||
agentVersion: binding.agentVersion,
|
||||
protocol: { ...installation.protocol },
|
||||
platform: 'linux',
|
||||
architecture: binding.agentArchitecture,
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: 'uid:1000',
|
||||
draining: false
|
||||
},
|
||||
capabilities,
|
||||
client,
|
||||
state: 'ready',
|
||||
reconnect: async () => undefined,
|
||||
refreshCapabilities: async () => capabilities,
|
||||
updateAcpBinding: async () => undefined,
|
||||
flushAcpBindings: async () => undefined,
|
||||
release: connectionRelease
|
||||
}
|
||||
const acquire = vi.fn(async () => {
|
||||
events.push('connection:acquire')
|
||||
if (options.acquireError) {
|
||||
throw options.acquireError
|
||||
}
|
||||
return connection
|
||||
})
|
||||
const factoryOptions = {
|
||||
installationManager: {
|
||||
ensureInstalled
|
||||
} as unknown as Pick<AgentInstallationManager, 'ensureInstalled'>,
|
||||
connectionManager: {
|
||||
acquire
|
||||
} as unknown as Pick<RemoteAgentConnectionManager, 'acquire'>
|
||||
} satisfies ManagedRemoteWorkspaceAccessFactoryOptions
|
||||
return {
|
||||
factory: new ManagedRemoteWorkspaceAccessFactory(factoryOptions),
|
||||
events,
|
||||
protocolMethods,
|
||||
ensureInstalled,
|
||||
acquire,
|
||||
connectionRelease
|
||||
}
|
||||
}
|
||||
|
||||
const capabilities: DaemonCapabilities = {
|
||||
generation: 1,
|
||||
capabilities: [
|
||||
{
|
||||
name: 'workspace/read',
|
||||
version: 1,
|
||||
critical: true
|
||||
}
|
||||
],
|
||||
runtimes: []
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
RemoteWorkspaceAccess,
|
||||
type RemoteWorkspaceAccessFactory,
|
||||
type RemoteWorkspaceProjectBinding
|
||||
} from '../workspace/remote-workspace-access'
|
||||
import type { AgentInstallationManager } from './agent-installation-manager'
|
||||
import type { RemoteAgentConnectionManager } from './remote-agent-connection-manager'
|
||||
import {
|
||||
ProtocolRemoteWorkspaceTransport,
|
||||
type RemoteWorkspaceInstallationIdentityResolver
|
||||
} from './protocol-remote-workspace-transport'
|
||||
|
||||
export type ManagedRemoteWorkspaceAccessFactoryOptions = {
|
||||
installationManager: Pick<
|
||||
AgentInstallationManager,
|
||||
'ensureInstalled'
|
||||
>
|
||||
connectionManager: Pick<RemoteAgentConnectionManager, 'acquire'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes persisted project validation with fresh installation and
|
||||
* connection leases. The resulting WorkspaceAccess remains strictly
|
||||
* read-only and owns every acquired connection lease.
|
||||
*/
|
||||
export class ManagedRemoteWorkspaceAccessFactory
|
||||
implements RemoteWorkspaceAccessFactory
|
||||
{
|
||||
constructor(
|
||||
private readonly options: ManagedRemoteWorkspaceAccessFactoryOptions
|
||||
) {}
|
||||
|
||||
create(bindingInput: RemoteWorkspaceProjectBinding): RemoteWorkspaceAccess {
|
||||
const binding = { ...bindingInput }
|
||||
const resolver: RemoteWorkspaceInstallationIdentityResolver = {
|
||||
resolve: async (hostId, installationId, signal) => {
|
||||
if (
|
||||
hostId !== binding.hostId ||
|
||||
installationId !== binding.agentInstallationId
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote workspace installation request does not match its project binding'
|
||||
)
|
||||
}
|
||||
|
||||
signal?.throwIfAborted()
|
||||
const installation =
|
||||
await this.options.installationManager.ensureInstalled(
|
||||
binding.hostId,
|
||||
{ signal }
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
assertInstallationMatches(binding, installation)
|
||||
return { installation }
|
||||
}
|
||||
}
|
||||
return new RemoteWorkspaceAccess(
|
||||
binding,
|
||||
new ProtocolRemoteWorkspaceTransport(
|
||||
this.options.connectionManager,
|
||||
resolver
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertInstallationMatches(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
installation: Awaited<
|
||||
ReturnType<AgentInstallationManager['ensureInstalled']>
|
||||
>
|
||||
): void {
|
||||
if (
|
||||
installation.installationId !== binding.agentInstallationId ||
|
||||
installation.binaryDigest !== binding.agentBinaryDigest ||
|
||||
installation.agentVersion !== binding.agentVersion ||
|
||||
installation.architecture !== binding.agentArchitecture ||
|
||||
installation.protocol.major !== binding.agentProtocolMajor
|
||||
) {
|
||||
throw new Error(
|
||||
'Installed Remote Agent does not match the persisted project binding'
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,823 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_PROTOCOL_VERSION,
|
||||
type AgentFrame,
|
||||
type AttachWelcome,
|
||||
type DaemonCapabilities
|
||||
} from '../../shared/agent-protocol'
|
||||
import {
|
||||
decodeAgentFrame,
|
||||
encodeAgentFrame
|
||||
} from '../../shared/agent-protocol/frame'
|
||||
import type {
|
||||
RemoteWorkspaceHandle
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
import type { AgentAttachTransport } from './agent-attach-transport'
|
||||
import { AgentProtocolClient } from './agent-protocol-client'
|
||||
import type {
|
||||
RemoteAgentConnection,
|
||||
RemoteAgentConnectionManager,
|
||||
RemoteAgentInstallationIdentity
|
||||
} from './remote-agent-connection-manager'
|
||||
import {
|
||||
ProtocolRemoteWorkspaceTransport,
|
||||
ProtocolRemoteWorkspaceTransportError,
|
||||
type RemoteWorkspaceInstallationIdentityResolver
|
||||
} from './protocol-remote-workspace-transport'
|
||||
import type {
|
||||
RemoteWorkspaceProjectBinding,
|
||||
RemoteWorkspaceTransportBinding
|
||||
} from '../workspace/remote-workspace-access'
|
||||
|
||||
type ControllerLease = {
|
||||
controllerId: string
|
||||
connectionId: string
|
||||
generation: number
|
||||
capabilityGeneration: number
|
||||
}
|
||||
|
||||
type ControllerRegistryLike = {
|
||||
attach(controllerId: string): ControllerLease
|
||||
assertCurrent(
|
||||
controllerId: string,
|
||||
generation: number
|
||||
): ControllerLease
|
||||
revokeCapabilities(controllerId: string): number
|
||||
}
|
||||
|
||||
type ProtocolMethodHandler = (
|
||||
params: unknown,
|
||||
context: {
|
||||
controller: ControllerLease
|
||||
channelId: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
) => unknown | Promise<unknown>
|
||||
|
||||
const controllerRegistryModule =
|
||||
'../../agent-daemon/controller-registry'
|
||||
const eventJournalModule = '../../agent-daemon/event-journal'
|
||||
const protocolServerModule = '../../agent-daemon/protocol-server'
|
||||
const { ControllerRegistry } = (await import(
|
||||
controllerRegistryModule
|
||||
)) as {
|
||||
ControllerRegistry: new () => ControllerRegistryLike
|
||||
}
|
||||
const { EventJournal } = (await import(eventJournalModule)) as {
|
||||
EventJournal: new (path: string) => { close(): void }
|
||||
}
|
||||
const { AgentProtocolServer } = (await import(
|
||||
protocolServerModule
|
||||
)) as {
|
||||
AgentProtocolServer: new (options: {
|
||||
controllers: ControllerRegistryLike
|
||||
events: unknown
|
||||
status: () => unknown
|
||||
methods: Record<string, ProtocolMethodHandler>
|
||||
}) => {
|
||||
accept(socket: unknown, controller: ControllerLease): void
|
||||
}
|
||||
}
|
||||
|
||||
const temporaryPaths: string[] = []
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
const timestamp = '2026-08-21T00:00:00.000Z'
|
||||
const binding: RemoteWorkspaceProjectBinding = {
|
||||
hostId: 'host-1',
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'builder',
|
||||
remoteRootPath: '/srv/project',
|
||||
workspaceIdentity: 'workspace-identity',
|
||||
agentInstallationId: 'installation-test',
|
||||
agentBinaryDigest: digest,
|
||||
agentVersion: '0.11.0',
|
||||
agentArchitecture: 'x64',
|
||||
agentProtocolMajor: AGENT_PROTOCOL_VERSION.major
|
||||
}
|
||||
const installation: RemoteAgentInstallationIdentity = {
|
||||
installationId: verifyAgentInstallationId('installation-test'),
|
||||
binaryDigest: digest,
|
||||
agentVersion: '0.11.0',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
supervisor: 'detached-on-demand'
|
||||
}
|
||||
const handle: RemoteWorkspaceHandle = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceIdentity: binding.workspaceIdentity,
|
||||
canonicalDisplayPath: binding.remoteRootPath,
|
||||
access: 'read-only',
|
||||
git: 'available',
|
||||
capabilities: [
|
||||
'list',
|
||||
'stat',
|
||||
'read-text',
|
||||
'search',
|
||||
'git-status',
|
||||
'git-diff'
|
||||
],
|
||||
generation: 5
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of temporaryPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('ProtocolRemoteWorkspaceTransport', () => {
|
||||
it('bridges fixed validate/read/Git methods through a real protocol client and server', async () => {
|
||||
const calls: string[] = []
|
||||
const test = createHarness({
|
||||
methods: workspaceMethods(calls)
|
||||
})
|
||||
const durableBinding = transportBinding(test, 1)
|
||||
test.resolution.durableWorkspaceValidation = {
|
||||
binding: durableBinding,
|
||||
workspaceIdentity: binding.workspaceIdentity,
|
||||
handle,
|
||||
validatedAt: timestamp
|
||||
}
|
||||
const lease = await test.transport.acquireLease(binding)
|
||||
|
||||
expect(lease.binding).toEqual(durableBinding)
|
||||
expect(lease.resumableHandle).toEqual(handle)
|
||||
await expect(
|
||||
lease.validateWorkspace({
|
||||
remoteRootPath: binding.remoteRootPath,
|
||||
requestedAccess: 'read-only',
|
||||
requiredCapabilities: [...handle.capabilities]
|
||||
})
|
||||
).resolves.toEqual({ handle, validatedAt: timestamp })
|
||||
await expect(
|
||||
lease.openWorkspace({
|
||||
workspaceIdentity: binding.workspaceIdentity,
|
||||
requestedAccess: 'read-only'
|
||||
})
|
||||
).resolves.toEqual(handle)
|
||||
await expect(
|
||||
lease.resumeWorkspace({
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
workspaceIdentity: binding.workspaceIdentity
|
||||
})
|
||||
).resolves.toEqual({ resumed: true, handle })
|
||||
await expect(
|
||||
lease.listWorkspace({
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
relativePath: '',
|
||||
limit: 10
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
entries: [{ relativePath: 'README.md', name: 'README.md' }]
|
||||
})
|
||||
await expect(
|
||||
lease.statWorkspace({
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
relativePath: 'README.md'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
relativePath: 'README.md',
|
||||
byteLength: 5
|
||||
})
|
||||
await expect(
|
||||
lease.readWorkspaceText({
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
relativePath: 'README.md',
|
||||
offsetBytes: 0,
|
||||
maximumBytes: 10
|
||||
})
|
||||
).resolves.toMatchObject({ content: 'hello', bytesRead: 5 })
|
||||
await expect(
|
||||
lease.searchWorkspace({
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
query: 'hello',
|
||||
caseSensitive: true,
|
||||
limit: 10
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
matches: [{ relativePath: 'README.md', line: 1 }]
|
||||
})
|
||||
await expect(
|
||||
lease.getGitStatus({
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
includeIgnored: false,
|
||||
maximumEntries: 10
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
repositoryIdentity: 'repository-1',
|
||||
branch: 'main'
|
||||
})
|
||||
await expect(
|
||||
lease.getGitDiff({
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
staged: false,
|
||||
maximumBytes: 100
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
repositoryIdentity: 'repository-1',
|
||||
patch: '+hello\n'
|
||||
})
|
||||
await expect(
|
||||
lease.closeWorkspace({
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation
|
||||
})
|
||||
).resolves.toEqual({
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
closed: true
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
'workspace/validate',
|
||||
'workspace/open',
|
||||
'workspace/resume',
|
||||
'workspace/list',
|
||||
'workspace/stat',
|
||||
'workspace/readText',
|
||||
'workspace/search',
|
||||
'git/status',
|
||||
'git/diff',
|
||||
'workspace/close'
|
||||
])
|
||||
lease.release()
|
||||
expect(test.release).toHaveBeenCalledOnce()
|
||||
test.close()
|
||||
})
|
||||
|
||||
it('accepts a durable non-Git handle for read operations', async () => {
|
||||
const nonGitHandle: RemoteWorkspaceHandle = {
|
||||
...handle,
|
||||
git: 'not-a-repository',
|
||||
capabilities: ['list', 'stat', 'read-text', 'search']
|
||||
}
|
||||
const test = createHarness()
|
||||
test.resolution.durableWorkspaceValidation = {
|
||||
binding: transportBinding(test, 1),
|
||||
workspaceIdentity: binding.workspaceIdentity,
|
||||
handle: nonGitHandle,
|
||||
validatedAt: timestamp
|
||||
}
|
||||
|
||||
const lease = await test.transport.acquireLease(binding)
|
||||
|
||||
expect(lease.resumableHandle).toEqual(nonGitHandle)
|
||||
lease.release()
|
||||
expect(test.release).toHaveBeenCalledOnce()
|
||||
test.close()
|
||||
})
|
||||
|
||||
it('fails closed for project, installation, status, and capability mismatches', async () => {
|
||||
const hostMismatch = createHarness({
|
||||
identity: { hostRevision: 99 }
|
||||
})
|
||||
await expect(
|
||||
hostMismatch.transport.acquireLease(binding)
|
||||
).rejects.toMatchObject({ reason: 'binding-mismatch' })
|
||||
expect(hostMismatch.release).toHaveBeenCalledOnce()
|
||||
hostMismatch.close()
|
||||
|
||||
const installationMismatch = createHarness({
|
||||
installation: {
|
||||
...installation,
|
||||
installationId:
|
||||
verifyAgentInstallationId('installation-other')
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
installationMismatch.transport.acquireLease(binding)
|
||||
).rejects.toMatchObject({ reason: 'binding-mismatch' })
|
||||
expect(installationMismatch.acquire).not.toHaveBeenCalled()
|
||||
installationMismatch.close()
|
||||
|
||||
const statusMismatch = createHarness({
|
||||
connectionStatus: { draining: true }
|
||||
})
|
||||
await expect(
|
||||
statusMismatch.transport.acquireLease(binding)
|
||||
).rejects.toMatchObject({ reason: 'binding-mismatch' })
|
||||
expect(statusMismatch.release).toHaveBeenCalledOnce()
|
||||
statusMismatch.close()
|
||||
|
||||
const connectionDigestMismatch = createHarness({
|
||||
identity: { binaryDigest: `sha256:${'b'.repeat(64)}` }
|
||||
})
|
||||
await expect(
|
||||
connectionDigestMismatch.transport.acquireLease(binding)
|
||||
).rejects.toMatchObject({ reason: 'binding-mismatch' })
|
||||
expect(connectionDigestMismatch.release).toHaveBeenCalledOnce()
|
||||
connectionDigestMismatch.close()
|
||||
|
||||
for (const changedInstallation of [
|
||||
{ ...installation, binaryDigest: `sha256:${'b'.repeat(64)}` },
|
||||
{ ...installation, agentVersion: '0.11.1' },
|
||||
{ ...installation, architecture: 'arm64' as const }
|
||||
]) {
|
||||
const identityMismatch = createHarness({
|
||||
installation: changedInstallation
|
||||
})
|
||||
await expect(
|
||||
identityMismatch.transport.acquireLease(binding)
|
||||
).rejects.toMatchObject({ reason: 'binding-mismatch' })
|
||||
expect(identityMismatch.acquire).not.toHaveBeenCalled()
|
||||
identityMismatch.close()
|
||||
}
|
||||
|
||||
for (const changedStatus of [
|
||||
{ binaryDigest: `sha256:${'b'.repeat(64)}` },
|
||||
{ agentVersion: '0.11.1' },
|
||||
{ architecture: 'arm64' as const }
|
||||
]) {
|
||||
const liveStatusMismatch = createHarness({
|
||||
connectionStatus: changedStatus
|
||||
})
|
||||
await expect(
|
||||
liveStatusMismatch.transport.acquireLease(binding)
|
||||
).rejects.toMatchObject({ reason: 'binding-mismatch' })
|
||||
expect(liveStatusMismatch.release).toHaveBeenCalledOnce()
|
||||
liveStatusMismatch.close()
|
||||
}
|
||||
|
||||
const methods = workspaceMethods()
|
||||
delete methods['git/diff']
|
||||
const capabilityMismatch = createHarness({ methods })
|
||||
await expect(
|
||||
capabilityMismatch.transport.acquireLease(binding)
|
||||
).rejects.toMatchObject({ reason: 'capability-missing' })
|
||||
expect(capabilityMismatch.release).toHaveBeenCalledOnce()
|
||||
capabilityMismatch.close()
|
||||
})
|
||||
|
||||
it('rejects stale capability generations before dispatching a workspace call', async () => {
|
||||
const calls: string[] = []
|
||||
const test = createHarness({
|
||||
methods: workspaceMethods(calls)
|
||||
})
|
||||
const lease = await test.transport.acquireLease(binding)
|
||||
test.controllers.revokeCapabilities(test.controller.controllerId)
|
||||
|
||||
await expect(
|
||||
lease.listWorkspace({
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
relativePath: '',
|
||||
limit: 10
|
||||
})
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
reason: 'stale-generation'
|
||||
})
|
||||
)
|
||||
expect(calls).not.toContain('workspace/list')
|
||||
lease.release()
|
||||
test.close()
|
||||
})
|
||||
|
||||
it('preserves cancellation through AgentProtocolClient requests', async () => {
|
||||
let finish!: () => void
|
||||
const gate = new Promise<void>((resolveGate) => {
|
||||
finish = resolveGate
|
||||
})
|
||||
const methods = workspaceMethods()
|
||||
methods['workspace/readText'] = vi.fn(async () => {
|
||||
await gate
|
||||
return readResult()
|
||||
})
|
||||
const test = createHarness({ methods })
|
||||
const lease = await test.transport.acquireLease(binding)
|
||||
const controller = new AbortController()
|
||||
const operation = lease.readWorkspaceText(
|
||||
{
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
relativePath: 'README.md',
|
||||
offsetBytes: 0,
|
||||
maximumBytes: 10
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(methods['workspace/readText']).toHaveBeenCalled()
|
||||
})
|
||||
controller.abort(new Error('read canceled'))
|
||||
|
||||
await expect(operation).rejects.toThrow('read canceled')
|
||||
finish()
|
||||
lease.release()
|
||||
test.close()
|
||||
})
|
||||
|
||||
it('only exposes resumable state with a complete durable identity match', async () => {
|
||||
const test = createHarness()
|
||||
test.resolution.durableWorkspaceValidation = {
|
||||
binding: {
|
||||
...transportBinding(test, 1),
|
||||
capabilityGeneration: 2
|
||||
},
|
||||
workspaceIdentity: binding.workspaceIdentity,
|
||||
handle,
|
||||
validatedAt: timestamp
|
||||
}
|
||||
const lease = await test.transport.acquireLease(binding)
|
||||
|
||||
expect(lease.resumableHandle).toBeUndefined()
|
||||
lease.release()
|
||||
test.close()
|
||||
})
|
||||
|
||||
it('keeps one connection reference per lease and makes close and release idempotent', async () => {
|
||||
const closeHandler = vi.fn(
|
||||
workspaceMethods()['workspace/close']
|
||||
)
|
||||
const methods = workspaceMethods()
|
||||
methods['workspace/close'] = closeHandler
|
||||
const test = createHarness({ methods })
|
||||
const [first, second] = await Promise.all([
|
||||
test.transport.acquireLease(binding),
|
||||
test.transport.acquireLease(binding)
|
||||
])
|
||||
expect(test.acquire).toHaveBeenCalledTimes(2)
|
||||
const request = {
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation
|
||||
}
|
||||
|
||||
const [firstClose, duplicateClose] = await Promise.all([
|
||||
first.closeWorkspace(request),
|
||||
first.closeWorkspace(request)
|
||||
])
|
||||
expect(firstClose).toEqual(duplicateClose)
|
||||
expect(closeHandler).toHaveBeenCalledOnce()
|
||||
|
||||
first.release()
|
||||
first.release()
|
||||
expect(test.release).toHaveBeenCalledTimes(1)
|
||||
second.release()
|
||||
second.release()
|
||||
expect(test.release).toHaveBeenCalledTimes(2)
|
||||
expect(() => first.binding).toThrow(
|
||||
ProtocolRemoteWorkspaceTransportError
|
||||
)
|
||||
test.close()
|
||||
})
|
||||
})
|
||||
|
||||
type HarnessOptions = {
|
||||
methods?: Record<string, ProtocolMethodHandler>
|
||||
identity?: Partial<RemoteAgentConnection['identity']>
|
||||
installation?: RemoteAgentInstallationIdentity
|
||||
connectionStatus?: Partial<RemoteAgentConnection['status']>
|
||||
}
|
||||
|
||||
function createHarness(options: HarnessOptions = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-workspace-transport-'))
|
||||
temporaryPaths.push(root)
|
||||
const controllers = new ControllerRegistry()
|
||||
const controller = controllers.attach('controller-test')
|
||||
const events = new EventJournal(resolve(root, 'events.sqlite'))
|
||||
const status = {
|
||||
state: 'ready' as const,
|
||||
installationId: binding.agentInstallationId,
|
||||
binaryDigest: digest,
|
||||
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,
|
||||
...options.connectionStatus
|
||||
}
|
||||
const server = new AgentProtocolServer({
|
||||
controllers,
|
||||
events,
|
||||
status: () => status,
|
||||
methods: options.methods ?? workspaceMethods()
|
||||
})
|
||||
const socket = new MemorySocket()
|
||||
const transport = new MemoryAgentTransport(socket, controller)
|
||||
socket.onWrite = (data) => transport.accept(data)
|
||||
server.accept(socket, controller)
|
||||
const client = new AgentProtocolClient(
|
||||
transport as unknown as AgentAttachTransport
|
||||
)
|
||||
const release = vi.fn()
|
||||
const currentCapabilities = (): DaemonCapabilities => {
|
||||
const current = controllers.assertCurrent(
|
||||
controller.controllerId,
|
||||
controller.generation
|
||||
)
|
||||
const advertised =
|
||||
Object.keys(options.methods ?? workspaceMethods()).length >= 10
|
||||
return {
|
||||
generation: current.capabilityGeneration,
|
||||
capabilities: advertised
|
||||
? [
|
||||
{
|
||||
name: 'workspace/read',
|
||||
version: 1,
|
||||
critical: true
|
||||
}
|
||||
]
|
||||
: [],
|
||||
runtimes: []
|
||||
}
|
||||
}
|
||||
const acquire = vi.fn(async () => {
|
||||
let released = false
|
||||
return {
|
||||
identity: {
|
||||
cacheKey: 'connection-cache',
|
||||
hostId: binding.hostId,
|
||||
hostRevision: binding.hostRevision,
|
||||
hostKeyGeneration: binding.hostKeyGeneration,
|
||||
remoteUsername: binding.remoteUsername,
|
||||
installationId: binding.agentInstallationId,
|
||||
binaryDigest: digest,
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
...options.identity
|
||||
},
|
||||
status,
|
||||
get capabilities(): DaemonCapabilities {
|
||||
return currentCapabilities()
|
||||
},
|
||||
client,
|
||||
state: 'ready' as const,
|
||||
reconnect: async () => undefined,
|
||||
refreshCapabilities: async () => currentCapabilities(),
|
||||
updateAcpBinding: async () => undefined,
|
||||
flushAcpBindings: async () => undefined,
|
||||
release: () => {
|
||||
if (!released) {
|
||||
released = true
|
||||
release()
|
||||
}
|
||||
}
|
||||
} satisfies RemoteAgentConnection
|
||||
})
|
||||
const resolution = {
|
||||
installation: options.installation ?? installation,
|
||||
durableWorkspaceValidation: undefined
|
||||
} as {
|
||||
installation: RemoteAgentInstallationIdentity
|
||||
durableWorkspaceValidation?:
|
||||
import('./protocol-remote-workspace-transport').DurableRemoteWorkspaceValidation
|
||||
}
|
||||
const resolver: RemoteWorkspaceInstallationIdentityResolver = {
|
||||
resolve: vi.fn(async () => resolution)
|
||||
}
|
||||
const workspaceTransport = new ProtocolRemoteWorkspaceTransport(
|
||||
{ acquire } as unknown as Pick<
|
||||
RemoteAgentConnectionManager,
|
||||
'acquire'
|
||||
>,
|
||||
resolver
|
||||
)
|
||||
return {
|
||||
transport: workspaceTransport,
|
||||
controllers,
|
||||
controller,
|
||||
resolution,
|
||||
acquire,
|
||||
release,
|
||||
close: () => {
|
||||
client.dispose()
|
||||
socket.destroy()
|
||||
events.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceMethods(
|
||||
calls: string[] = []
|
||||
): Record<string, ProtocolMethodHandler> {
|
||||
const method = (
|
||||
name: string,
|
||||
result: unknown
|
||||
): ProtocolMethodHandler =>
|
||||
vi.fn(async () => {
|
||||
calls.push(name)
|
||||
return result
|
||||
})
|
||||
return {
|
||||
'workspace/validate': method('workspace/validate', {
|
||||
handle,
|
||||
validatedAt: timestamp
|
||||
}),
|
||||
'workspace/open': method('workspace/open', handle),
|
||||
'workspace/resume': method('workspace/resume', {
|
||||
resumed: true,
|
||||
handle
|
||||
}),
|
||||
'workspace/close': method('workspace/close', {
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation,
|
||||
closed: true
|
||||
}),
|
||||
'workspace/list': method('workspace/list', {
|
||||
entries: [
|
||||
{
|
||||
relativePath: 'README.md',
|
||||
name: 'README.md',
|
||||
kind: 'file',
|
||||
byteLength: 5,
|
||||
modifiedAt: timestamp,
|
||||
digest,
|
||||
executable: false
|
||||
}
|
||||
]
|
||||
}),
|
||||
'workspace/stat': method('workspace/stat', {
|
||||
relativePath: 'README.md',
|
||||
name: 'README.md',
|
||||
kind: 'file',
|
||||
byteLength: 5,
|
||||
modifiedAt: timestamp,
|
||||
digest,
|
||||
executable: false
|
||||
}),
|
||||
'workspace/readText': method('workspace/readText', readResult()),
|
||||
'workspace/search': method('workspace/search', {
|
||||
matches: [
|
||||
{
|
||||
relativePath: 'README.md',
|
||||
line: 1,
|
||||
column: 1,
|
||||
snippet: 'hello'
|
||||
}
|
||||
],
|
||||
truncated: false
|
||||
}),
|
||||
'git/status': method('git/status', {
|
||||
repositoryIdentity: 'repository-1',
|
||||
branch: 'main',
|
||||
entries: [],
|
||||
truncated: false
|
||||
}),
|
||||
'git/diff': method('git/diff', {
|
||||
repositoryIdentity: 'repository-1',
|
||||
patch: '+hello\n',
|
||||
byteLength: 7,
|
||||
truncated: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function readResult() {
|
||||
return {
|
||||
relativePath: 'README.md',
|
||||
content: 'hello',
|
||||
offsetBytes: 0,
|
||||
bytesRead: 5,
|
||||
totalBytes: 5,
|
||||
digest,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
|
||||
function transportBinding(
|
||||
_test: ReturnType<typeof createHarness>,
|
||||
capabilityGeneration: number
|
||||
): RemoteWorkspaceTransportBinding {
|
||||
return {
|
||||
hostId: binding.hostId,
|
||||
hostRevision: binding.hostRevision,
|
||||
hostKeyGeneration: binding.hostKeyGeneration,
|
||||
remoteUsername: binding.remoteUsername,
|
||||
agentInstallationId: binding.agentInstallationId,
|
||||
agentBinaryDigest: binding.agentBinaryDigest,
|
||||
agentVersion: binding.agentVersion,
|
||||
agentArchitecture: binding.agentArchitecture,
|
||||
agentProtocolMajor: binding.agentProtocolMajor,
|
||||
capabilityGeneration
|
||||
}
|
||||
}
|
||||
|
||||
class MemorySocket extends EventEmitter {
|
||||
onWrite?: (data: Uint8Array) => void
|
||||
destroyed = false
|
||||
|
||||
pause(): this {
|
||||
return this
|
||||
}
|
||||
|
||||
resume(): this {
|
||||
return this
|
||||
}
|
||||
|
||||
write(
|
||||
data: Uint8Array,
|
||||
callback?: (error?: Error | null) => void
|
||||
): boolean {
|
||||
this.onWrite?.(data)
|
||||
queueMicrotask(() => callback?.())
|
||||
return true
|
||||
}
|
||||
|
||||
receive(data: Uint8Array): void {
|
||||
this.emit('data', Buffer.from(data))
|
||||
}
|
||||
|
||||
destroy(): this {
|
||||
if (!this.destroyed) {
|
||||
this.destroyed = true
|
||||
queueMicrotask(() => this.emit('close'))
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAgentTransport {
|
||||
readonly welcome: AttachWelcome
|
||||
#frames: AgentFrame[] = []
|
||||
#readers: Array<{
|
||||
resolve: (frame: AgentFrame) => void
|
||||
reject: (error: unknown) => void
|
||||
}> = []
|
||||
#closeListeners = new Set<(error: Error) => void>()
|
||||
#disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly socket: MemorySocket,
|
||||
controller: ControllerLease
|
||||
) {
|
||||
this.welcome = {
|
||||
type: 'goodbuddy-agent-welcome',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
connectionId: controller.connectionId,
|
||||
generation: controller.generation,
|
||||
installationId: binding.agentInstallationId,
|
||||
binaryDigest: digest,
|
||||
daemonBootId: 'boot-test',
|
||||
serverNonce: 'server-test'
|
||||
}
|
||||
}
|
||||
|
||||
async send(frame: AgentFrame): Promise<void> {
|
||||
if (this.#disposed) {
|
||||
throw new Error('transport disposed')
|
||||
}
|
||||
this.socket.receive(encodeAgentFrame(frame))
|
||||
}
|
||||
|
||||
receive(): Promise<AgentFrame> {
|
||||
const frame = this.#frames.shift()
|
||||
if (frame !== undefined) {
|
||||
return Promise.resolve(frame)
|
||||
}
|
||||
return new Promise((resolveFrame, reject) => {
|
||||
this.#readers.push({ resolve: resolveFrame, reject })
|
||||
})
|
||||
}
|
||||
|
||||
accept(data: Uint8Array): void {
|
||||
const frame = decodeAgentFrame(data, {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
maximumProtocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: this.welcome.connectionId,
|
||||
generation: this.welcome.generation
|
||||
})
|
||||
const reader = this.#readers.shift()
|
||||
if (reader === undefined) {
|
||||
this.#frames.push(frame)
|
||||
} else {
|
||||
reader.resolve(frame)
|
||||
}
|
||||
}
|
||||
|
||||
onClose(listener: (error: Error) => void): () => void {
|
||||
this.#closeListeners.add(listener)
|
||||
return () => this.#closeListeners.delete(listener)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) {
|
||||
return
|
||||
}
|
||||
this.#disposed = true
|
||||
const error = new Error('transport disposed')
|
||||
for (const reader of this.#readers.splice(0)) {
|
||||
reader.reject(error)
|
||||
}
|
||||
for (const listener of this.#closeListeners) {
|
||||
listener(error)
|
||||
}
|
||||
this.#closeListeners.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import {
|
||||
REMOTE_WORKSPACE_READ_CAPABILITIES,
|
||||
remoteWorkspaceHandleSchema,
|
||||
type RemoteWorkspaceCloseResult,
|
||||
type RemoteWorkspaceHandle
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import type {
|
||||
RemoteWorkspaceProjectBinding,
|
||||
RemoteWorkspaceTransport,
|
||||
RemoteWorkspaceTransportBinding,
|
||||
RemoteWorkspaceTransportLease
|
||||
} from '../workspace/remote-workspace-access'
|
||||
import type {
|
||||
AgentProtocolClient,
|
||||
AgentProtocolParams,
|
||||
AgentProtocolResult
|
||||
} from './agent-protocol-client'
|
||||
import type {
|
||||
RemoteAgentConnection,
|
||||
RemoteAgentConnectionManager,
|
||||
RemoteAgentInstallationIdentity
|
||||
} from './remote-agent-connection-manager'
|
||||
|
||||
const WORKSPACE_READ_CAPABILITY = 'workspace/read'
|
||||
const WORKSPACE_READ_CAPABILITY_VERSION = 1
|
||||
export type DurableRemoteWorkspaceValidation = {
|
||||
binding: RemoteWorkspaceTransportBinding
|
||||
workspaceIdentity: string
|
||||
handle: RemoteWorkspaceHandle
|
||||
validatedAt: string
|
||||
}
|
||||
|
||||
export type RemoteWorkspaceInstallationResolution = {
|
||||
installation: RemoteAgentInstallationIdentity
|
||||
/**
|
||||
* This value must come from durable validation storage. The transport never
|
||||
* promotes an in-memory handle or an incomplete record into resume state.
|
||||
*/
|
||||
durableWorkspaceValidation?: DurableRemoteWorkspaceValidation
|
||||
}
|
||||
|
||||
export interface RemoteWorkspaceInstallationIdentityResolver {
|
||||
resolve(
|
||||
hostId: string,
|
||||
installationId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceInstallationResolution>
|
||||
}
|
||||
|
||||
export class ProtocolRemoteWorkspaceTransportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly reason:
|
||||
| 'binding-mismatch'
|
||||
| 'capability-missing'
|
||||
| 'stale-generation'
|
||||
| 'closed'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ProtocolRemoteWorkspaceTransportError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed, read-only Workspace/Git bridge over AgentProtocolClient.
|
||||
* Deliberately no generic protocol request method is exposed.
|
||||
*/
|
||||
export class ProtocolRemoteWorkspaceTransport
|
||||
implements RemoteWorkspaceTransport
|
||||
{
|
||||
constructor(
|
||||
private readonly connections: Pick<
|
||||
RemoteAgentConnectionManager,
|
||||
'acquire'
|
||||
>,
|
||||
private readonly installations:
|
||||
RemoteWorkspaceInstallationIdentityResolver
|
||||
) {}
|
||||
|
||||
async acquireLease(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceTransportLease> {
|
||||
signal?.throwIfAborted()
|
||||
const projectBinding = { ...binding }
|
||||
const resolved = await this.installations.resolve(
|
||||
projectBinding.hostId,
|
||||
projectBinding.agentInstallationId,
|
||||
signal
|
||||
)
|
||||
let resolution: RemoteWorkspaceInstallationResolution | undefined
|
||||
let connection: RemoteAgentConnection | undefined
|
||||
let retained = false
|
||||
try {
|
||||
resolution = snapshotResolution(resolved)
|
||||
signal?.throwIfAborted()
|
||||
assertResolution(projectBinding, resolution)
|
||||
connection = await this.connections.acquire(
|
||||
projectBinding.hostId,
|
||||
requireWorkspaceReadCapability(resolution.installation),
|
||||
signal
|
||||
)
|
||||
const status = connection.status
|
||||
assertConnectionIdentity(projectBinding, resolution, connection)
|
||||
assertConnectionStatus(projectBinding, resolution, status)
|
||||
const capabilities = await connection.client.request(
|
||||
'agent/capabilities',
|
||||
{},
|
||||
{ signal }
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
assertWorkspaceReadCapability(capabilities)
|
||||
if (
|
||||
capabilities.generation !==
|
||||
connection.capabilities.generation
|
||||
) {
|
||||
throw staleGenerationError()
|
||||
}
|
||||
const transportBinding = liveBinding(
|
||||
projectBinding,
|
||||
resolution,
|
||||
connection,
|
||||
capabilities.generation
|
||||
)
|
||||
const resumableHandle = validatedResumableHandle(
|
||||
projectBinding,
|
||||
transportBinding,
|
||||
resolution.durableWorkspaceValidation
|
||||
)
|
||||
const lease = createLease({
|
||||
binding: projectBinding,
|
||||
resolution,
|
||||
connection,
|
||||
acquiredCapabilityGeneration: capabilities.generation,
|
||||
resumableHandle
|
||||
})
|
||||
retained = true
|
||||
return lease
|
||||
} finally {
|
||||
if (!retained) {
|
||||
releaseLeaseResources(connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotResolution(
|
||||
resolution: RemoteWorkspaceInstallationResolution
|
||||
): RemoteWorkspaceInstallationResolution {
|
||||
const installation = resolution.installation
|
||||
const durable = resolution.durableWorkspaceValidation
|
||||
return {
|
||||
installation: {
|
||||
...installation,
|
||||
...(installation.protocol === undefined
|
||||
? {}
|
||||
: { protocol: { ...installation.protocol } }),
|
||||
...(installation.requiredCapabilities === undefined
|
||||
? {}
|
||||
: {
|
||||
requiredCapabilities:
|
||||
installation.requiredCapabilities.map((entry) => ({
|
||||
...entry
|
||||
}))
|
||||
})
|
||||
},
|
||||
...(durable === undefined
|
||||
? {}
|
||||
: {
|
||||
durableWorkspaceValidation: {
|
||||
binding: { ...durable.binding },
|
||||
workspaceIdentity: durable.workspaceIdentity,
|
||||
handle: {
|
||||
...durable.handle,
|
||||
capabilities: [...durable.handle.capabilities]
|
||||
},
|
||||
validatedAt: durable.validatedAt
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type LeaseState = {
|
||||
binding: RemoteWorkspaceProjectBinding
|
||||
resolution: RemoteWorkspaceInstallationResolution
|
||||
connection: RemoteAgentConnection
|
||||
acquiredCapabilityGeneration: number
|
||||
resumableHandle?: RemoteWorkspaceHandle
|
||||
}
|
||||
|
||||
function createLease(state: LeaseState): RemoteWorkspaceTransportLease {
|
||||
let released = false
|
||||
let closing:
|
||||
| {
|
||||
workspaceId: string
|
||||
generation: number
|
||||
promise: Promise<RemoteWorkspaceCloseResult>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const assertOpen = (): void => {
|
||||
if (released) {
|
||||
throw new ProtocolRemoteWorkspaceTransportError(
|
||||
'Remote workspace transport lease is closed',
|
||||
'closed'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const currentClient = (): AgentProtocolClient => {
|
||||
assertOpen()
|
||||
if (state.connection.state !== 'ready') {
|
||||
throw staleGenerationError()
|
||||
}
|
||||
assertConnectionIdentity(
|
||||
state.binding,
|
||||
state.resolution,
|
||||
state.connection
|
||||
)
|
||||
assertConnectionStatus(
|
||||
state.binding,
|
||||
state.resolution,
|
||||
state.connection.status
|
||||
)
|
||||
if (
|
||||
state.connection.capabilities.generation !==
|
||||
state.acquiredCapabilityGeneration
|
||||
) {
|
||||
throw staleGenerationError()
|
||||
}
|
||||
return state.connection.client
|
||||
}
|
||||
|
||||
const request = async <
|
||||
M extends WorkspaceReadProtocolMethod
|
||||
>(
|
||||
method: M,
|
||||
params: AgentProtocolParams<M>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AgentProtocolResult<M>> => {
|
||||
signal?.throwIfAborted()
|
||||
const client = currentClient()
|
||||
const capabilities = state.connection.capabilities
|
||||
assertWorkspaceReadCapability(capabilities)
|
||||
if (
|
||||
capabilities.generation !==
|
||||
state.acquiredCapabilityGeneration
|
||||
) {
|
||||
throw staleGenerationError()
|
||||
}
|
||||
if (currentClient() !== client) {
|
||||
throw staleGenerationError()
|
||||
}
|
||||
return await client.request(method, params, { signal })
|
||||
}
|
||||
|
||||
return {
|
||||
get binding() {
|
||||
currentClient()
|
||||
return liveBinding(
|
||||
state.binding,
|
||||
state.resolution,
|
||||
state.connection,
|
||||
state.acquiredCapabilityGeneration
|
||||
)
|
||||
},
|
||||
...(state.resumableHandle === undefined
|
||||
? {}
|
||||
: { resumableHandle: state.resumableHandle }),
|
||||
validateWorkspace: async (value, signal) =>
|
||||
await request('workspace/validate', value, signal),
|
||||
openWorkspace: async (value, signal) =>
|
||||
await request('workspace/open', value, signal),
|
||||
resumeWorkspace: async (value, signal) =>
|
||||
await request('workspace/resume', value, signal),
|
||||
closeWorkspace: (value) => {
|
||||
currentClient()
|
||||
if (
|
||||
closing?.workspaceId === value.workspaceId &&
|
||||
closing.generation === value.generation
|
||||
) {
|
||||
return closing.promise
|
||||
}
|
||||
const promise = request('workspace/close', value)
|
||||
closing = {
|
||||
workspaceId: value.workspaceId,
|
||||
generation: value.generation,
|
||||
promise
|
||||
}
|
||||
return promise
|
||||
},
|
||||
listWorkspace: async (value, signal) =>
|
||||
await request('workspace/list', value, signal),
|
||||
statWorkspace: async (value, signal) =>
|
||||
await request('workspace/stat', value, signal),
|
||||
readWorkspaceText: async (value, signal) =>
|
||||
await request('workspace/readText', value, signal),
|
||||
searchWorkspace: async (value, signal) =>
|
||||
await request('workspace/search', value, signal),
|
||||
getGitStatus: async (value, signal) =>
|
||||
await request('git/status', value, signal),
|
||||
getGitDiff: async (value, signal) =>
|
||||
await request('git/diff', value, signal),
|
||||
release: () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
releaseLeaseResources(
|
||||
state.connection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function releaseLeaseResources(
|
||||
connection: RemoteAgentConnection | undefined
|
||||
): void {
|
||||
const errors: unknown[] = []
|
||||
if (connection !== undefined) {
|
||||
try {
|
||||
connection.release()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
'Failed to release remote workspace transport resources'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type WorkspaceReadProtocolMethod =
|
||||
| 'workspace/validate'
|
||||
| 'workspace/open'
|
||||
| 'workspace/resume'
|
||||
| 'workspace/close'
|
||||
| 'workspace/list'
|
||||
| 'workspace/stat'
|
||||
| 'workspace/readText'
|
||||
| 'workspace/search'
|
||||
| 'git/status'
|
||||
| 'git/diff'
|
||||
|
||||
function requireWorkspaceReadCapability(
|
||||
installation: RemoteAgentInstallationIdentity
|
||||
): RemoteAgentInstallationIdentity {
|
||||
const requirements = [...(installation.requiredCapabilities ?? [])]
|
||||
const index = requirements.findIndex(
|
||||
(requirement) => requirement.name === WORKSPACE_READ_CAPABILITY
|
||||
)
|
||||
const required = {
|
||||
name: WORKSPACE_READ_CAPABILITY,
|
||||
minimumVersion: WORKSPACE_READ_CAPABILITY_VERSION,
|
||||
critical: true
|
||||
}
|
||||
if (index < 0) {
|
||||
requirements.push(required)
|
||||
} else {
|
||||
const current = requirements[index]!
|
||||
requirements[index] = {
|
||||
name: current.name,
|
||||
...(current.exactVersion === undefined
|
||||
? {
|
||||
minimumVersion: Math.max(
|
||||
current.minimumVersion!,
|
||||
WORKSPACE_READ_CAPABILITY_VERSION
|
||||
)
|
||||
}
|
||||
: { exactVersion: current.exactVersion }),
|
||||
critical: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
...installation,
|
||||
requiredCapabilities: requirements
|
||||
}
|
||||
}
|
||||
|
||||
function assertResolution(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
resolution: RemoteWorkspaceInstallationResolution
|
||||
): void {
|
||||
const protocol = resolution.installation.protocol
|
||||
if (
|
||||
resolution.installation.installationId !==
|
||||
binding.agentInstallationId ||
|
||||
resolution.installation.binaryDigest !==
|
||||
binding.agentBinaryDigest ||
|
||||
resolution.installation.agentVersion !== binding.agentVersion ||
|
||||
resolution.installation.architecture !==
|
||||
binding.agentArchitecture ||
|
||||
protocol === undefined ||
|
||||
protocol.major !== binding.agentProtocolMajor
|
||||
) {
|
||||
throw bindingMismatchError()
|
||||
}
|
||||
}
|
||||
|
||||
function assertConnectionIdentity(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
resolution: RemoteWorkspaceInstallationResolution,
|
||||
connection: RemoteAgentConnection
|
||||
): void {
|
||||
const identity = connection.identity
|
||||
const protocol = resolution.installation.protocol
|
||||
if (
|
||||
identity.hostId !== binding.hostId ||
|
||||
identity.hostRevision !== binding.hostRevision ||
|
||||
identity.hostKeyGeneration !== binding.hostKeyGeneration ||
|
||||
identity.remoteUsername !== binding.remoteUsername ||
|
||||
identity.installationId !== binding.agentInstallationId ||
|
||||
identity.binaryDigest !== binding.agentBinaryDigest ||
|
||||
identity.binaryDigest !== resolution.installation.binaryDigest ||
|
||||
identity.protocolMajor !== binding.agentProtocolMajor ||
|
||||
protocol === undefined ||
|
||||
identity.protocolMinor !== protocol.minor
|
||||
) {
|
||||
throw bindingMismatchError()
|
||||
}
|
||||
}
|
||||
|
||||
function assertConnectionStatus(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
resolution: RemoteWorkspaceInstallationResolution,
|
||||
status: RemoteAgentConnection['status']
|
||||
): void {
|
||||
const installation = resolution.installation
|
||||
if (
|
||||
status.state !== 'ready' ||
|
||||
status.draining ||
|
||||
status.installationId !== binding.agentInstallationId ||
|
||||
status.binaryDigest !== binding.agentBinaryDigest ||
|
||||
status.binaryDigest !== installation.binaryDigest ||
|
||||
status.agentVersion !== binding.agentVersion ||
|
||||
status.agentVersion !== installation.agentVersion ||
|
||||
status.architecture !== binding.agentArchitecture ||
|
||||
status.architecture !== installation.architecture ||
|
||||
status.protocol.major !== binding.agentProtocolMajor ||
|
||||
installation.protocol === undefined ||
|
||||
status.protocol.minor !== installation.protocol.minor ||
|
||||
status.platform !== installation.platform ||
|
||||
status.supervisor !== installation.supervisor ||
|
||||
installation.agentVersion === undefined
|
||||
) {
|
||||
throw bindingMismatchError()
|
||||
}
|
||||
}
|
||||
|
||||
function assertWorkspaceReadCapability(
|
||||
capabilities: RemoteAgentConnection['capabilities']
|
||||
): void {
|
||||
const capability = capabilities.capabilities.find(
|
||||
(entry) => entry.name === WORKSPACE_READ_CAPABILITY
|
||||
)
|
||||
if (
|
||||
!Number.isSafeInteger(capabilities.generation) ||
|
||||
capabilities.generation < 1 ||
|
||||
capability === undefined ||
|
||||
capability.version < WORKSPACE_READ_CAPABILITY_VERSION ||
|
||||
!capability.critical
|
||||
) {
|
||||
throw new ProtocolRemoteWorkspaceTransportError(
|
||||
'Remote Agent does not advertise the critical workspace/read capability',
|
||||
'capability-missing'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function liveBinding(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
resolution: RemoteWorkspaceInstallationResolution,
|
||||
connection: RemoteAgentConnection,
|
||||
capabilityGeneration: number
|
||||
): RemoteWorkspaceTransportBinding {
|
||||
assertConnectionIdentity(binding, resolution, connection)
|
||||
assertConnectionStatus(binding, resolution, connection.status)
|
||||
if (
|
||||
connection.capabilities.generation !== capabilityGeneration
|
||||
) {
|
||||
throw staleGenerationError()
|
||||
}
|
||||
return {
|
||||
hostId: connection.identity.hostId,
|
||||
hostRevision: connection.identity.hostRevision,
|
||||
hostKeyGeneration: connection.identity.hostKeyGeneration,
|
||||
remoteUsername: connection.identity.remoteUsername,
|
||||
agentInstallationId: connection.identity.installationId,
|
||||
agentBinaryDigest: connection.identity.binaryDigest,
|
||||
agentVersion: connection.status.agentVersion,
|
||||
agentArchitecture: connection.status.architecture,
|
||||
agentProtocolMajor: connection.identity.protocolMajor,
|
||||
capabilityGeneration
|
||||
}
|
||||
}
|
||||
|
||||
function validatedResumableHandle(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
transportBinding: RemoteWorkspaceTransportBinding,
|
||||
validation: DurableRemoteWorkspaceValidation | undefined
|
||||
): RemoteWorkspaceHandle | undefined {
|
||||
if (validation === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const timestamp = Date.parse(validation.validatedAt)
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
validation.workspaceIdentity !== binding.workspaceIdentity ||
|
||||
!sameTransportBinding(validation.binding, transportBinding)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = remoteWorkspaceHandleSchema.safeParse(validation.handle)
|
||||
if (
|
||||
!parsed.success ||
|
||||
parsed.data.workspaceIdentity !== binding.workspaceIdentity ||
|
||||
parsed.data.access !== 'read-only' ||
|
||||
REMOTE_WORKSPACE_READ_CAPABILITIES.some(
|
||||
(capability) => !parsed.data.capabilities.includes(capability)
|
||||
)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
function sameTransportBinding(
|
||||
left: RemoteWorkspaceTransportBinding,
|
||||
right: RemoteWorkspaceTransportBinding
|
||||
): boolean {
|
||||
return (
|
||||
left.hostId === right.hostId &&
|
||||
left.hostRevision === right.hostRevision &&
|
||||
left.hostKeyGeneration === right.hostKeyGeneration &&
|
||||
left.remoteUsername === right.remoteUsername &&
|
||||
left.agentInstallationId === right.agentInstallationId &&
|
||||
left.agentBinaryDigest === right.agentBinaryDigest &&
|
||||
left.agentVersion === right.agentVersion &&
|
||||
left.agentArchitecture === right.agentArchitecture &&
|
||||
left.agentProtocolMajor === right.agentProtocolMajor &&
|
||||
left.capabilityGeneration === right.capabilityGeneration
|
||||
)
|
||||
}
|
||||
|
||||
function bindingMismatchError(): ProtocolRemoteWorkspaceTransportError {
|
||||
return new ProtocolRemoteWorkspaceTransportError(
|
||||
'Remote workspace project binding does not match the live Agent connection',
|
||||
'binding-mismatch'
|
||||
)
|
||||
}
|
||||
|
||||
function staleGenerationError(): ProtocolRemoteWorkspaceTransportError {
|
||||
return new ProtocolRemoteWorkspaceTransportError(
|
||||
'Remote workspace capability generation is stale',
|
||||
'stale-generation'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_PROTOCOL_VERSION,
|
||||
type AttachWelcome,
|
||||
type DaemonCapabilities
|
||||
} from '../../shared/agent-protocol'
|
||||
import type {
|
||||
SshConnectionLease,
|
||||
SshConnectionPoolTarget
|
||||
} from '../ssh/ssh-connection-pool'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
import type { AgentAttachTransport } from './agent-attach-transport'
|
||||
import type {
|
||||
AgentProtocolClient,
|
||||
AgentProtocolMethod
|
||||
} from './agent-protocol-client'
|
||||
import {
|
||||
ControllerStateStore,
|
||||
type ControllerStateFile,
|
||||
type PersistedControllerState
|
||||
} from './controller-state-store'
|
||||
import {
|
||||
RemoteAgentConnectionManager,
|
||||
type RemoteAgentSshPool
|
||||
} from './remote-agent-connection-manager'
|
||||
|
||||
class MemoryStateFile implements ControllerStateFile {
|
||||
value?: unknown
|
||||
|
||||
async read(): Promise<unknown | undefined> {
|
||||
return structuredClone(this.value)
|
||||
}
|
||||
|
||||
async write(value: PersistedControllerState): Promise<void> {
|
||||
this.value = structuredClone(value)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeProtocolClient {
|
||||
readonly connectionId: string
|
||||
readonly generation: number
|
||||
capabilitiesGate?: Promise<void>
|
||||
resumeGate?: Promise<void>
|
||||
capabilities: DaemonCapabilities = {
|
||||
generation: 1,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true }
|
||||
],
|
||||
runtimes: []
|
||||
}
|
||||
disposed = false
|
||||
readonly requests: Array<{ method: AgentProtocolMethod; params: unknown }> =
|
||||
[]
|
||||
#closeListeners = new Set<(error: Error) => void>()
|
||||
|
||||
constructor(index = 0) {
|
||||
this.connectionId = `connection-${index + 1}`
|
||||
this.generation = index + 2
|
||||
}
|
||||
|
||||
async request(
|
||||
method: AgentProtocolMethod,
|
||||
params: unknown
|
||||
): Promise<unknown> {
|
||||
this.requests.push({ method, params })
|
||||
if (method === 'agent/status') {
|
||||
return {
|
||||
state: 'ready',
|
||||
installationId: 'agent-v1',
|
||||
binaryDigest: `sha256:${'a'.repeat(64)}`,
|
||||
daemonBootId: 'boot-1',
|
||||
agentVersion: '1.0.0',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: 'user-1',
|
||||
draining: false
|
||||
}
|
||||
}
|
||||
if (method === 'agent/capabilities') {
|
||||
await this.capabilitiesGate
|
||||
return structuredClone(this.capabilities)
|
||||
}
|
||||
if (method === 'controller/resume') {
|
||||
await this.resumeGate
|
||||
const request = params as {
|
||||
daemonBootId: string
|
||||
capabilityGeneration: number
|
||||
}
|
||||
return {
|
||||
resumed: true,
|
||||
generation: this.generation,
|
||||
daemonBootId: request.daemonBootId,
|
||||
capabilityGeneration: request.capabilityGeneration,
|
||||
leaseDeadlineAt: '2030-01-01T00:00:00.000Z'
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`)
|
||||
}
|
||||
|
||||
onClose(listener: (error: Error) => void): () => void {
|
||||
this.#closeListeners.add(listener)
|
||||
return () => this.#closeListeners.delete(listener)
|
||||
}
|
||||
|
||||
transportClose(): void {
|
||||
for (const listener of this.#closeListeners) {
|
||||
listener(new Error('transport closed'))
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
}
|
||||
}
|
||||
|
||||
function target(revision = 1): SshConnectionPoolTarget {
|
||||
return {
|
||||
host: {
|
||||
id: 'host-1',
|
||||
name: 'Builder',
|
||||
hostname: 'builder.example',
|
||||
port: 22,
|
||||
username: 'goodbuddy',
|
||||
authentication: 'password',
|
||||
password: 'not-persisted',
|
||||
hostKey: {
|
||||
algorithm: 'ssh-ed25519',
|
||||
publicKeyBase64: 'a2V5',
|
||||
fingerprintSha256: `SHA256:${'a'.repeat(43)}`,
|
||||
generation: revision
|
||||
}
|
||||
},
|
||||
hostRevision: revision,
|
||||
hostKeyGeneration: revision
|
||||
}
|
||||
}
|
||||
|
||||
const installation = {
|
||||
installationId: verifyAgentInstallationId('agent-v1'),
|
||||
binaryDigest: `sha256:${'a'.repeat(64)}`,
|
||||
agentVersion: '1.0.0',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
platform: 'linux' as const,
|
||||
architecture: 'x64' as const,
|
||||
supervisor: 'detached-on-demand' as const,
|
||||
requiredCapabilities: [
|
||||
{ name: 'workspace/read', minimumVersion: 1, critical: true }
|
||||
]
|
||||
}
|
||||
|
||||
function harness(
|
||||
connectGate?: Promise<void>,
|
||||
reconnectResumeGate?: Promise<void>
|
||||
) {
|
||||
let currentTarget = target()
|
||||
const release = vi.fn()
|
||||
const sshLease = {
|
||||
identity: {
|
||||
hostId: 'host-1',
|
||||
hostRevision: 1,
|
||||
hostKeyGeneration: 1,
|
||||
authenticationIdentity: 'b'.repeat(64)
|
||||
},
|
||||
isUsable: () => true,
|
||||
release
|
||||
} as unknown as SshConnectionLease
|
||||
const sshPool: RemoteAgentSshPool = {
|
||||
acquire: vi.fn(async () => sshLease),
|
||||
disposeHost: vi.fn()
|
||||
}
|
||||
const resolver = {
|
||||
resolve: vi.fn(async () => currentTarget)
|
||||
}
|
||||
const clients: FakeProtocolClient[] = []
|
||||
const stateFile = new MemoryStateFile()
|
||||
const connectTransport = vi.fn(async () => {
|
||||
await connectGate
|
||||
const welcome: AttachWelcome = {
|
||||
type: 'goodbuddy-agent-welcome',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
connectionId: `connection-${connectTransport.mock.calls.length}`,
|
||||
generation: connectTransport.mock.calls.length + 1,
|
||||
installationId: 'agent-v1',
|
||||
binaryDigest: `sha256:${'a'.repeat(64)}`,
|
||||
daemonBootId: 'boot-1',
|
||||
serverNonce: 'server-1'
|
||||
}
|
||||
return {
|
||||
welcome,
|
||||
dispose: vi.fn()
|
||||
} as unknown as AgentAttachTransport
|
||||
})
|
||||
const manager = new RemoteAgentConnectionManager({
|
||||
resolver,
|
||||
sshPool,
|
||||
controllerState: new ControllerStateStore(stateFile),
|
||||
goodBuddyVersion: '0.11.0',
|
||||
idleTimeoutMs: 0,
|
||||
connectTransport,
|
||||
createProtocolClient: () => {
|
||||
const client = new FakeProtocolClient(clients.length)
|
||||
if (clients.length > 0) {
|
||||
client.resumeGate = reconnectResumeGate
|
||||
}
|
||||
clients.push(client)
|
||||
return client as unknown as AgentProtocolClient
|
||||
}
|
||||
})
|
||||
return {
|
||||
manager,
|
||||
resolver,
|
||||
sshPool,
|
||||
release,
|
||||
connectTransport,
|
||||
clients,
|
||||
stateFile,
|
||||
changeRevision: (revision: number) => {
|
||||
currentTarget = target(revision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('RemoteAgentConnectionManager', () => {
|
||||
it('deduplicates concurrent connects and ref-counts the shared connection', async () => {
|
||||
const test = harness()
|
||||
const [first, second] = await Promise.all([
|
||||
test.manager.acquire('host-1', installation),
|
||||
test.manager.acquire('host-1', installation)
|
||||
])
|
||||
expect(first.client).toBe(second.client)
|
||||
expect(test.connectTransport).toHaveBeenCalledTimes(1)
|
||||
expect(test.sshPool.acquire).toHaveBeenCalledTimes(1)
|
||||
first.release()
|
||||
expect(test.release).not.toHaveBeenCalled()
|
||||
second.release()
|
||||
expect(test.release).toHaveBeenCalledTimes(1)
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('isolates cache entries across Host revisions', async () => {
|
||||
const test = harness()
|
||||
const first = await test.manager.acquire('host-1', installation)
|
||||
test.changeRevision(2)
|
||||
const second = await test.manager.acquire('host-1', installation)
|
||||
expect(first.identity.cacheKey).not.toBe(second.identity.cacheKey)
|
||||
expect(test.connectTransport).toHaveBeenCalledTimes(2)
|
||||
first.release()
|
||||
second.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('cancels one waiter without canceling a shared connect', async () => {
|
||||
let openTransport!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
openTransport = resolve
|
||||
})
|
||||
const test = harness(gate)
|
||||
const controller = new AbortController()
|
||||
const canceled = test.manager.acquire(
|
||||
'host-1',
|
||||
installation,
|
||||
controller.signal
|
||||
)
|
||||
const retained = test.manager.acquire('host-1', installation)
|
||||
await vi.waitFor(() =>
|
||||
expect(test.connectTransport).toHaveBeenCalledTimes(1)
|
||||
)
|
||||
controller.abort(new Error('waiter canceled'))
|
||||
await expect(canceled).rejects.toThrow('waiter canceled')
|
||||
openTransport()
|
||||
const lease = await retained
|
||||
expect(lease.state).toBe('ready')
|
||||
lease.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('invalidates every connection for a Host without stopping its daemon', async () => {
|
||||
const test = harness()
|
||||
const lease = await test.manager.acquire('host-1', installation)
|
||||
await test.manager.invalidateHost('host-1')
|
||||
expect(test.sshPool.disposeHost).toHaveBeenCalledWith('host-1')
|
||||
expect(() => lease.client).toThrow()
|
||||
lease.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('does not publish a connection that finishes after Host invalidation', async () => {
|
||||
let finishTransport!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finishTransport = resolve
|
||||
})
|
||||
const test = harness(gate)
|
||||
const acquiring = test.manager.acquire('host-1', installation)
|
||||
await vi.waitFor(() =>
|
||||
expect(test.connectTransport).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
await test.manager.invalidateHost('host-1')
|
||||
finishTransport()
|
||||
|
||||
await expect(acquiring).rejects.toMatchObject({
|
||||
disposition: 'transient',
|
||||
reason: 'network'
|
||||
})
|
||||
expect(test.clients[0]?.disposed).toBe(true)
|
||||
expect(test.release).toHaveBeenCalledOnce()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('refreshes and validates capabilities on the current client generation', async () => {
|
||||
const test = harness()
|
||||
const lease = await test.manager.acquire('host-1', installation)
|
||||
const refreshed: DaemonCapabilities = {
|
||||
generation: 2,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true },
|
||||
{ name: 'runtime/acp', version: 3, critical: true }
|
||||
],
|
||||
runtimes: [
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
version: '1.0.0',
|
||||
bundleDigest: `sha256:${'b'.repeat(64)}`,
|
||||
acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}`,
|
||||
sessionLoad: true,
|
||||
sessionResume: true
|
||||
}
|
||||
]
|
||||
}
|
||||
test.clients[0]!.capabilities = refreshed
|
||||
|
||||
await expect(lease.refreshCapabilities()).resolves.toEqual(
|
||||
refreshed
|
||||
)
|
||||
expect(lease.capabilities).toEqual(refreshed)
|
||||
|
||||
test.clients[0]!.capabilities = {
|
||||
generation: 3,
|
||||
capabilities: [
|
||||
{ name: 'runtime/acp', version: 3, critical: true }
|
||||
],
|
||||
runtimes: refreshed.runtimes
|
||||
}
|
||||
await expect(lease.refreshCapabilities()).rejects.toMatchObject({
|
||||
reason: 'capability',
|
||||
disposition: 'terminal'
|
||||
})
|
||||
expect(lease.capabilities).toEqual(refreshed)
|
||||
lease.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('rejects versions above an exact capability requirement', async () => {
|
||||
const test = harness()
|
||||
const lease = await test.manager.acquire('host-1', {
|
||||
...installation,
|
||||
requiredCapabilities: [
|
||||
{
|
||||
name: 'workspace/read',
|
||||
exactVersion: 1,
|
||||
critical: true
|
||||
}
|
||||
]
|
||||
})
|
||||
test.clients[0]!.capabilities = {
|
||||
generation: 2,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 2, critical: true }
|
||||
],
|
||||
runtimes: []
|
||||
}
|
||||
|
||||
await expect(lease.refreshCapabilities()).rejects.toMatchObject({
|
||||
reason: 'capability',
|
||||
disposition: 'terminal'
|
||||
})
|
||||
lease.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('refuses capability refresh after its lease is released', async () => {
|
||||
const test = harness()
|
||||
const lease = await test.manager.acquire('host-1', installation)
|
||||
lease.release()
|
||||
await expect(lease.refreshCapabilities()).rejects.toThrow(
|
||||
'lease is released'
|
||||
)
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('does not publish a capability refresh after its lease is released in flight', async () => {
|
||||
const test = harness()
|
||||
const [refreshing, retained] = await Promise.all([
|
||||
test.manager.acquire('host-1', installation),
|
||||
test.manager.acquire('host-1', installation)
|
||||
])
|
||||
let finishRefresh!: () => void
|
||||
test.clients[0]!.capabilitiesGate = new Promise<void>((resolve) => {
|
||||
finishRefresh = resolve
|
||||
})
|
||||
test.clients[0]!.capabilities = {
|
||||
generation: 2,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true }
|
||||
],
|
||||
runtimes: []
|
||||
}
|
||||
|
||||
const refresh = refreshing.refreshCapabilities()
|
||||
refreshing.release()
|
||||
finishRefresh()
|
||||
|
||||
await expect(refresh).rejects.toThrow('lease is released')
|
||||
expect(retained.capabilities.generation).toBe(1)
|
||||
retained.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('proves the exact predecessor tuple and notifies retained leases after a client swap', async () => {
|
||||
const test = harness()
|
||||
const lease = await test.manager.acquire('host-1', installation)
|
||||
const originalClient = lease.client
|
||||
await lease.updateAcpBinding('binding-1', {
|
||||
bindingId: 'binding-1',
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '2',
|
||||
lastOutboundDeliveredSequence: '2',
|
||||
lastInboundJournaledSequence: '4',
|
||||
lastMainAckSequence: '3'
|
||||
}
|
||||
})
|
||||
const changed = vi.fn()
|
||||
lease.onClientChange?.(changed)
|
||||
|
||||
test.clients[0]!.transportClose()
|
||||
expect(lease.state).toBe('offline')
|
||||
await lease.reconnect()
|
||||
|
||||
expect(lease.client).not.toBe(originalClient)
|
||||
expect(lease.client.generation).toBe(3)
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
test.clients[1]!.requests.find(
|
||||
(request) => request.method === 'controller/resume'
|
||||
)
|
||||
).toEqual({
|
||||
method: 'controller/resume',
|
||||
params: {
|
||||
previousGeneration: 2,
|
||||
previousConnectionId: 'connection-1',
|
||||
daemonBootId: 'boot-1',
|
||||
capabilityGeneration: 1
|
||||
}
|
||||
})
|
||||
lease.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('persists ACP recovery cursors while the retained transport is offline', async () => {
|
||||
const test = harness()
|
||||
const lease = await test.manager.acquire('host-1', installation)
|
||||
await lease.updateAcpBinding('binding-1', {
|
||||
bindingId: 'binding-1',
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '2',
|
||||
lastOutboundDeliveredSequence: '2',
|
||||
lastInboundJournaledSequence: '3',
|
||||
lastMainAckSequence: '1'
|
||||
}
|
||||
})
|
||||
test.clients[0]!.transportClose()
|
||||
expect(lease.state).toBe('offline')
|
||||
|
||||
await expect(
|
||||
lease.updateAcpBinding('binding-1', {
|
||||
bindingId: 'binding-1',
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '2',
|
||||
lastOutboundDeliveredSequence: '2',
|
||||
lastInboundJournaledSequence: '3',
|
||||
lastMainAckSequence: '2'
|
||||
}
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
await expect(lease.flushAcpBindings()).resolves.toBeUndefined()
|
||||
expect(test.stateFile.value).toMatchObject({
|
||||
connections: [
|
||||
{
|
||||
acpBindings: [
|
||||
{
|
||||
bindingId: 'binding-1',
|
||||
cursors: {
|
||||
lastMainAckSequence: '2'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
lease.release()
|
||||
await expect(
|
||||
lease.flushAcpBindings()
|
||||
).rejects.toThrow(/released/iu)
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('preserves ACP cursors persisted while reconnect resume is in flight', async () => {
|
||||
let finishResume!: () => void
|
||||
const resumeGate = new Promise<void>((resolve) => {
|
||||
finishResume = resolve
|
||||
})
|
||||
const test = harness(undefined, resumeGate)
|
||||
const lease = await test.manager.acquire('host-1', installation)
|
||||
await lease.updateAcpBinding('binding-1', {
|
||||
bindingId: 'binding-1',
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '2',
|
||||
lastOutboundDeliveredSequence: '2',
|
||||
lastInboundJournaledSequence: '3',
|
||||
lastMainAckSequence: '1'
|
||||
}
|
||||
})
|
||||
await lease.flushAcpBindings()
|
||||
test.clients[0]!.transportClose()
|
||||
const reconnect = lease.reconnect()
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
test.clients[1]?.requests.some(
|
||||
(request) => request.method === 'controller/resume'
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
|
||||
await lease.updateAcpBinding('binding-1', {
|
||||
bindingId: 'binding-1',
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '2',
|
||||
lastOutboundDeliveredSequence: '2',
|
||||
lastInboundJournaledSequence: '3',
|
||||
lastMainAckSequence: '2'
|
||||
}
|
||||
})
|
||||
await lease.flushAcpBindings()
|
||||
finishResume()
|
||||
await reconnect
|
||||
|
||||
expect(test.stateFile.value).toMatchObject({
|
||||
connections: [
|
||||
{
|
||||
acpBindings: [
|
||||
{
|
||||
bindingId: 'binding-1',
|
||||
cursors: {
|
||||
lastMainAckSequence: '2'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
lease.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('shuts down channels and refuses later acquisition', async () => {
|
||||
const test = harness()
|
||||
const lease = await test.manager.acquire('host-1', installation)
|
||||
await test.manager.dispose()
|
||||
expect(test.clients[0]!.disposed).toBe(true)
|
||||
expect(() => lease.client).toThrow()
|
||||
await expect(
|
||||
test.manager.acquire('host-1', installation)
|
||||
).rejects.toMatchObject({ reason: 'shutdown' })
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SshConnectionPoolTarget } from '../ssh/ssh-connection-pool'
|
||||
import type { SshHostStore } from '../ssh/ssh-host-store'
|
||||
import type { AgentInstallationManager } from './agent-installation-manager'
|
||||
import type { ControllerStateStore } from './controller-state-store'
|
||||
import type { RemoteAgentConnectionManager } from './remote-agent-connection-manager'
|
||||
import {
|
||||
RemoteAgentServices,
|
||||
type RemoteAgentServicesOptions
|
||||
} from './remote-agent-services'
|
||||
|
||||
type Factories = NonNullable<RemoteAgentServicesOptions['factories']>
|
||||
type InstallationManagerOptions = Parameters<
|
||||
NonNullable<Factories['createAgentInstallationManager']>
|
||||
>[0]
|
||||
type ConnectionManagerOptions = Parameters<
|
||||
NonNullable<Factories['createConnectionManager']>
|
||||
>[0]
|
||||
|
||||
const target: SshConnectionPoolTarget = {
|
||||
host: {
|
||||
id: 'host-1',
|
||||
name: 'Builder',
|
||||
hostname: 'builder.example',
|
||||
port: 22,
|
||||
username: 'goodbuddy',
|
||||
authentication: 'password',
|
||||
password: 'not-persisted',
|
||||
hostKey: {
|
||||
algorithm: 'ssh-ed25519',
|
||||
publicKeyBase64: 'a2V5',
|
||||
fingerprintSha256: `SHA256:${'a'.repeat(43)}`,
|
||||
generation: 1
|
||||
}
|
||||
},
|
||||
hostRevision: 1,
|
||||
hostKeyGeneration: 1
|
||||
}
|
||||
|
||||
function harness(overrides: {
|
||||
installationDispose?: () => Promise<void>
|
||||
connectionDispose?: () => Promise<void>
|
||||
controllerFlush?: () => Promise<void>
|
||||
controllerDispose?: () => void
|
||||
poolDispose?: () => void
|
||||
invalidateHost?: (hostId: string) => Promise<void>
|
||||
} = {}) {
|
||||
const resolveConnectionTarget = vi.fn(async () => target)
|
||||
const sshHostStore = {
|
||||
resolveConnectionTarget
|
||||
} as unknown as SshHostStore
|
||||
const sshPool = {
|
||||
acquire: vi.fn(),
|
||||
disposeHost: vi.fn(),
|
||||
dispose: vi.fn(overrides.poolDispose ?? (() => undefined))
|
||||
}
|
||||
const controllerState = {
|
||||
flush: vi.fn(overrides.controllerFlush ?? (async () => undefined)),
|
||||
dispose: vi.fn(overrides.controllerDispose ?? (() => undefined))
|
||||
}
|
||||
const installationManager = {
|
||||
ensureInstalled: vi.fn(),
|
||||
dispose: vi.fn(
|
||||
overrides.installationDispose ?? (async () => undefined)
|
||||
)
|
||||
}
|
||||
const connectionManager = {
|
||||
acquire: vi.fn(),
|
||||
invalidateHost: vi.fn(
|
||||
overrides.invalidateHost ?? (async () => undefined)
|
||||
),
|
||||
dispose: vi.fn(
|
||||
overrides.connectionDispose ?? (async () => undefined)
|
||||
)
|
||||
}
|
||||
const createSshConnectionPool = vi.fn(() => sshPool)
|
||||
const createControllerStateStore = vi.fn(
|
||||
(filePath: string) => {
|
||||
void filePath
|
||||
return controllerState as unknown as ControllerStateStore
|
||||
}
|
||||
)
|
||||
const createAgentInstallationManager = vi.fn(
|
||||
(options: InstallationManagerOptions) => {
|
||||
void options
|
||||
return installationManager as unknown as AgentInstallationManager
|
||||
}
|
||||
)
|
||||
const createConnectionManager = vi.fn(
|
||||
(options: ConnectionManagerOptions) => {
|
||||
void options
|
||||
return connectionManager as unknown as RemoteAgentConnectionManager
|
||||
}
|
||||
)
|
||||
const services = new RemoteAgentServices({
|
||||
sshHostStore,
|
||||
goodBuddyVersion: '0.11.0',
|
||||
userDataPath: join('profile', 'GoodBuddy'),
|
||||
appPath: join('workspace', 'goodbuddy'),
|
||||
resourcesPath: join('installed', 'resources'),
|
||||
packaged: false,
|
||||
factories: {
|
||||
createSshConnectionPool:
|
||||
createSshConnectionPool as never,
|
||||
createControllerStateStore,
|
||||
createAgentInstallationManager,
|
||||
createConnectionManager
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
services,
|
||||
resolveConnectionTarget,
|
||||
sshPool,
|
||||
controllerState,
|
||||
installationManager,
|
||||
connectionManager,
|
||||
createSshConnectionPool,
|
||||
createControllerStateStore,
|
||||
createAgentInstallationManager,
|
||||
createConnectionManager
|
||||
}
|
||||
}
|
||||
|
||||
describe('RemoteAgentServices', () => {
|
||||
it('shares one atomic host resolver and SSH pool across services', async () => {
|
||||
const instance = harness()
|
||||
const installerOptions =
|
||||
instance.createAgentInstallationManager.mock.calls[0]?.[0]
|
||||
const connectionOptions =
|
||||
instance.createConnectionManager.mock.calls[0]?.[0]
|
||||
|
||||
expect(instance.createSshConnectionPool).toHaveBeenCalledOnce()
|
||||
expect(installerOptions?.resolver).toBe(
|
||||
connectionOptions?.resolver
|
||||
)
|
||||
expect(installerOptions?.resolver).toBe(
|
||||
instance.services.targetResolver
|
||||
)
|
||||
expect(installerOptions?.sshPool).toBe(connectionOptions?.sshPool)
|
||||
expect(installerOptions?.sshPool).toBe(instance.services.sshPool)
|
||||
|
||||
await expect(
|
||||
instance.services.targetResolver.resolve('host-1')
|
||||
).resolves.toBe(target)
|
||||
expect(instance.resolveConnectionTarget).toHaveBeenCalledWith(
|
||||
'host-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('constructs lazily with bundled paths and no host or network work', () => {
|
||||
const instance = harness()
|
||||
|
||||
expect(instance.resolveConnectionTarget).not.toHaveBeenCalled()
|
||||
expect(instance.sshPool.acquire).not.toHaveBeenCalled()
|
||||
expect(instance.installationManager.ensureInstalled).not.toHaveBeenCalled()
|
||||
expect(instance.connectionManager.acquire).not.toHaveBeenCalled()
|
||||
expect(instance.services.resourcePaths).toEqual({
|
||||
keyRegistryPath: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'resources',
|
||||
'agent-release-keys.json'
|
||||
),
|
||||
runtimeLockPath: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'agent-runtime-lock.json'
|
||||
),
|
||||
bundleDirectories: {
|
||||
x64: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'.agent-resources',
|
||||
'linux-x64'
|
||||
),
|
||||
arm64: join(
|
||||
'workspace',
|
||||
'goodbuddy',
|
||||
'.agent-resources',
|
||||
'linux-arm64'
|
||||
)
|
||||
}
|
||||
})
|
||||
expect(
|
||||
instance.createControllerStateStore
|
||||
).toHaveBeenCalledWith(
|
||||
join(
|
||||
'profile',
|
||||
'GoodBuddy',
|
||||
'remote-agent-controller-state.json'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps default service construction and shutdown lazy', async () => {
|
||||
const resolveConnectionTarget = vi.fn(async () => target)
|
||||
const services = new RemoteAgentServices({
|
||||
sshHostStore: {
|
||||
resolveConnectionTarget
|
||||
} as unknown as SshHostStore,
|
||||
goodBuddyVersion: '0.11.0',
|
||||
userDataPath: join('profile', 'GoodBuddy'),
|
||||
appPath: join('workspace', 'goodbuddy'),
|
||||
resourcesPath: join('installed', 'resources'),
|
||||
packaged: false
|
||||
})
|
||||
|
||||
expect(resolveConnectionTarget).not.toHaveBeenCalled()
|
||||
await services.dispose()
|
||||
expect(resolveConnectionTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates credential-free host invalidation immediately', async () => {
|
||||
let finishPersistence: (() => void) | undefined
|
||||
const persistence = new Promise<void>((resolve) => {
|
||||
finishPersistence = resolve
|
||||
})
|
||||
const invalidateHost = vi.fn(() => persistence)
|
||||
const instance = harness({ invalidateHost })
|
||||
|
||||
const invalidation = instance.services.invalidateHost('host-1')
|
||||
|
||||
expect(invalidateHost).toHaveBeenCalledWith('host-1')
|
||||
expect(invalidation).toBe(persistence)
|
||||
finishPersistence?.()
|
||||
await invalidation
|
||||
})
|
||||
|
||||
it('disposes in dependency order once and aggregates failures', async () => {
|
||||
const order: string[] = []
|
||||
const installerError = new Error('installer failed')
|
||||
const connectionError = new Error('connections failed')
|
||||
const controllerError = new Error('controller failed')
|
||||
const poolError = new Error('pool failed')
|
||||
const instance = harness({
|
||||
installationDispose: async () => {
|
||||
order.push('installer')
|
||||
throw installerError
|
||||
},
|
||||
connectionDispose: async () => {
|
||||
order.push('connections')
|
||||
throw connectionError
|
||||
},
|
||||
controllerFlush: async () => {
|
||||
order.push('controller-flush')
|
||||
throw controllerError
|
||||
},
|
||||
controllerDispose: () => {
|
||||
order.push('controller-dispose')
|
||||
},
|
||||
poolDispose: () => {
|
||||
order.push('ssh-pool')
|
||||
throw poolError
|
||||
}
|
||||
})
|
||||
|
||||
const first = instance.services.dispose()
|
||||
const second = instance.services.dispose()
|
||||
|
||||
expect(second).toBe(first)
|
||||
const result = await first.catch((error: unknown) => error)
|
||||
|
||||
expect(result).toBeInstanceOf(AggregateError)
|
||||
expect((result as AggregateError).errors).toEqual([
|
||||
installerError,
|
||||
connectionError,
|
||||
controllerError,
|
||||
poolError
|
||||
])
|
||||
expect(order).toEqual([
|
||||
'installer',
|
||||
'connections',
|
||||
'controller-flush',
|
||||
'controller-dispose',
|
||||
'ssh-pool'
|
||||
])
|
||||
expect(instance.installationManager.dispose).toHaveBeenCalledOnce()
|
||||
expect(instance.connectionManager.dispose).toHaveBeenCalledOnce()
|
||||
expect(instance.controllerState.flush).toHaveBeenCalledOnce()
|
||||
expect(instance.controllerState.dispose).toHaveBeenCalledOnce()
|
||||
expect(instance.sshPool.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { join } from 'node:path'
|
||||
import { SshConnectionPool } from '../ssh/ssh-connection-pool'
|
||||
import type { SshHostStore } from '../ssh/ssh-host-store'
|
||||
import { AgentInstallationManager } from './agent-installation-manager'
|
||||
import {
|
||||
resolveBundledAgentResourcePaths,
|
||||
type BundledAgentResourcePaths
|
||||
} from './bundled-agent-resources'
|
||||
import {
|
||||
ControllerStateStore,
|
||||
JsonControllerStateFile
|
||||
} from './controller-state-store'
|
||||
import {
|
||||
RemoteAgentConnectionManager,
|
||||
type RemoteAgentTargetResolver
|
||||
} from './remote-agent-connection-manager'
|
||||
|
||||
const CONTROLLER_STATE_FILE_NAME = 'remote-agent-controller-state.json'
|
||||
|
||||
type RemoteAgentServiceFactories = {
|
||||
createSshConnectionPool: () => SshConnectionPool
|
||||
createControllerStateStore: (
|
||||
filePath: string
|
||||
) => ControllerStateStore
|
||||
createAgentInstallationManager: (options: {
|
||||
resolver: RemoteAgentTargetResolver
|
||||
sshPool: SshConnectionPool
|
||||
resourcePaths: BundledAgentResourcePaths
|
||||
}) => AgentInstallationManager
|
||||
createConnectionManager: (options: {
|
||||
resolver: RemoteAgentTargetResolver
|
||||
sshPool: SshConnectionPool
|
||||
controllerState: ControllerStateStore
|
||||
goodBuddyVersion: string
|
||||
}) => RemoteAgentConnectionManager
|
||||
}
|
||||
|
||||
export type RemoteAgentServicesOptions = {
|
||||
sshHostStore: SshHostStore
|
||||
goodBuddyVersion: string
|
||||
userDataPath: string
|
||||
appPath: string
|
||||
resourcesPath: string
|
||||
packaged: boolean
|
||||
factories?: Partial<RemoteAgentServiceFactories>
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-process lifecycle owner for the dormant remote Agent control plane.
|
||||
* Construction only composes local objects and paths; network, disk, bundle
|
||||
* verification, and installation work remain lazy until an explicit caller
|
||||
* uses the corresponding service.
|
||||
*/
|
||||
export class RemoteAgentServices {
|
||||
readonly targetResolver: RemoteAgentTargetResolver
|
||||
readonly resourcePaths: BundledAgentResourcePaths
|
||||
readonly sshPool: SshConnectionPool
|
||||
readonly controllerState: ControllerStateStore
|
||||
readonly installationManager: AgentInstallationManager
|
||||
readonly connectionManager: RemoteAgentConnectionManager
|
||||
|
||||
#disposePromise: Promise<void> | undefined
|
||||
|
||||
constructor(options: RemoteAgentServicesOptions) {
|
||||
const factories: RemoteAgentServiceFactories = {
|
||||
createSshConnectionPool: () => new SshConnectionPool(),
|
||||
createControllerStateStore: (filePath) =>
|
||||
new ControllerStateStore(new JsonControllerStateFile(filePath)),
|
||||
createAgentInstallationManager: (managerOptions) =>
|
||||
new AgentInstallationManager(managerOptions),
|
||||
createConnectionManager: (managerOptions) =>
|
||||
new RemoteAgentConnectionManager(managerOptions),
|
||||
...options.factories
|
||||
}
|
||||
|
||||
this.targetResolver = {
|
||||
resolve: (hostId) =>
|
||||
options.sshHostStore.resolveConnectionTarget(hostId)
|
||||
}
|
||||
this.resourcePaths = resolveBundledAgentResourcePaths({
|
||||
appPath: options.appPath,
|
||||
resourcesPath: options.resourcesPath,
|
||||
packaged: options.packaged
|
||||
})
|
||||
this.sshPool = factories.createSshConnectionPool()
|
||||
this.controllerState = factories.createControllerStateStore(
|
||||
join(options.userDataPath, CONTROLLER_STATE_FILE_NAME)
|
||||
)
|
||||
this.installationManager =
|
||||
factories.createAgentInstallationManager({
|
||||
resolver: this.targetResolver,
|
||||
sshPool: this.sshPool,
|
||||
resourcePaths: this.resourcePaths
|
||||
})
|
||||
this.connectionManager = factories.createConnectionManager({
|
||||
resolver: this.targetResolver,
|
||||
sshPool: this.sshPool,
|
||||
controllerState: this.controllerState,
|
||||
goodBuddyVersion: options.goodBuddyVersion
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates live and persisted controller state. The connection manager
|
||||
* closes matching connections and pool entries before its first await, so a
|
||||
* credential-free SSH host lifecycle hook starts fail-closed synchronously.
|
||||
*/
|
||||
invalidateHost(hostId: string): Promise<void> {
|
||||
return this.connectionManager.invalidateHost(hostId)
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
this.#disposePromise ??= this.#dispose()
|
||||
return this.#disposePromise
|
||||
}
|
||||
|
||||
async #dispose(): Promise<void> {
|
||||
const errors: unknown[] = []
|
||||
const settle = async (
|
||||
operation: () => void | Promise<void>
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await operation()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
|
||||
await settle(() => this.installationManager.dispose())
|
||||
await settle(() => this.connectionManager.dispose())
|
||||
await settle(() => this.controllerState.flush())
|
||||
await settle(() => this.controllerState.dispose())
|
||||
await settle(() => this.sshPool.dispose())
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
'Remote Agent services failed to dispose completely'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import type { SshConnectionTarget } from '../ssh/ssh-host-store'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
import type { AgentInstallationIdentity } from './agent-installation-manager'
|
||||
import type { RemoteAgentConnection } from './remote-agent-connection-manager'
|
||||
import {
|
||||
RemoteProjectSaveService,
|
||||
type RemoteProjectSaveOwner,
|
||||
type RemoteProjectSaveServiceOptions,
|
||||
type RemoteProjectRuntimeValidationLease
|
||||
} from './remote-project-save-service'
|
||||
|
||||
const hostId = '00000000-0000-4000-8000-000000000001'
|
||||
const projectId = '00000000-0000-4000-8000-000000000002'
|
||||
const root = '/srv/project'
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
const services: RemoteProjectSaveService[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(services.splice(0).map((service) => service.dispose()))
|
||||
})
|
||||
|
||||
class Owner extends EventEmitter implements RemoteProjectSaveOwner {
|
||||
destroyed = false
|
||||
constructor(readonly id: number) {
|
||||
super()
|
||||
}
|
||||
isDestroyed(): boolean {
|
||||
return this.destroyed
|
||||
}
|
||||
destroy(): void {
|
||||
this.destroyed = true
|
||||
this.emit('destroyed')
|
||||
}
|
||||
}
|
||||
|
||||
const draft = {
|
||||
name: 'Remote',
|
||||
description: 'Validated remotely',
|
||||
defaultWorkMode: 'ask' as const,
|
||||
runtimeSelection: { provider: 'opencode' as const },
|
||||
hostId,
|
||||
remoteRootPath: root
|
||||
}
|
||||
|
||||
function target(): SshConnectionTarget {
|
||||
return {
|
||||
host: {
|
||||
id: hostId,
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'system-agent',
|
||||
hostKey: {
|
||||
algorithm: 'ssh-ed25519',
|
||||
publicKeyBase64: Buffer.from('key').toString('base64'),
|
||||
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
|
||||
acceptedAt: '2030-01-01T00:00:00.000Z',
|
||||
generation: 2
|
||||
}
|
||||
},
|
||||
hostRevision: 3,
|
||||
hostKeyGeneration: 2
|
||||
}
|
||||
}
|
||||
|
||||
const installation: AgentInstallationIdentity = {
|
||||
installationId: verifyAgentInstallationId('agent-installation'),
|
||||
binaryDigest: digest,
|
||||
agentVersion: '1.2.3',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
supervisor: 'detached-on-demand'
|
||||
}
|
||||
|
||||
function project(): AssistantProject {
|
||||
return {
|
||||
id: projectId,
|
||||
...draft,
|
||||
rootPath: root,
|
||||
kind: 'user',
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: root },
|
||||
status: 'active',
|
||||
createdAt: '2030-01-01T00:00:00.000Z',
|
||||
updatedAt: '2030-01-01T00:00:00.000Z'
|
||||
}
|
||||
}
|
||||
|
||||
function harness(options: {
|
||||
ensureInstalled?: (signal: AbortSignal) => Promise<AgentInstallationIdentity>
|
||||
validateWorkspace?: () => unknown
|
||||
create?: () => AssistantProject
|
||||
getProject?: () => AssistantProject
|
||||
resolveRuntimeSelection?: (
|
||||
selection: AgentRuntimeSelection
|
||||
) => Promise<AgentRuntimeSelection>
|
||||
} = {}) {
|
||||
const calls: string[] = []
|
||||
const currentTarget = target()
|
||||
const workspace = {
|
||||
workspaceId: 'workspace-id',
|
||||
workspaceIdentity: 'workspace-identity',
|
||||
canonicalDisplayPath: root,
|
||||
access: 'read-only' as const,
|
||||
git: 'available' as const,
|
||||
capabilities: [
|
||||
'list',
|
||||
'stat',
|
||||
'read-text',
|
||||
'search',
|
||||
'git-status',
|
||||
'git-diff'
|
||||
],
|
||||
generation: 1
|
||||
}
|
||||
const connectionRelease = vi.fn(() => calls.push('connection-release'))
|
||||
const request = vi.fn(async (method: string) => {
|
||||
calls.push(method)
|
||||
if (method === 'workspace/validate') {
|
||||
return (
|
||||
options.validateWorkspace?.() ?? {
|
||||
handle: workspace,
|
||||
validatedAt: '2030-01-01T00:00:01.000Z'
|
||||
}
|
||||
)
|
||||
}
|
||||
if (method === 'workspace/close') {
|
||||
return {
|
||||
workspaceId: workspace.workspaceId,
|
||||
generation: workspace.generation,
|
||||
closed: true
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`)
|
||||
})
|
||||
const connection = {
|
||||
identity: {
|
||||
cacheKey: 'cache',
|
||||
hostId,
|
||||
hostRevision: 3,
|
||||
hostKeyGeneration: 2,
|
||||
remoteUsername: 'builder',
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: digest,
|
||||
protocolMajor: 1,
|
||||
protocolMinor: 0
|
||||
},
|
||||
status: {
|
||||
state: 'ready',
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: digest,
|
||||
daemonBootId: 'boot',
|
||||
agentVersion: '1.2.3',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: 'user',
|
||||
draining: false
|
||||
},
|
||||
capabilities: {
|
||||
generation: 1,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true }
|
||||
],
|
||||
runtimes: []
|
||||
},
|
||||
client: { request },
|
||||
state: 'ready',
|
||||
release: connectionRelease
|
||||
} as unknown as RemoteAgentConnection
|
||||
const runtimeRelease = vi.fn(() => calls.push('runtime-release'))
|
||||
const runtimeLease: RemoteProjectRuntimeValidationLease = {
|
||||
evidence: {
|
||||
runtimeSelectionKey: agentRuntimeSelectionKey(
|
||||
draft.runtimeSelection
|
||||
),
|
||||
runtimeBundleDigest: digest,
|
||||
runtimeAdapterDigest: `sha256:${'b'.repeat(64)}`,
|
||||
agentInstallationIdAtValidation: installation.installationId,
|
||||
validatedAt: '2030-01-01T00:00:02.000Z',
|
||||
workMode: 'ask'
|
||||
},
|
||||
assertCurrent: vi.fn(() => calls.push('runtime-current')),
|
||||
release: runtimeRelease
|
||||
}
|
||||
const assertWrite = (write: Parameters<
|
||||
RemoteProjectSaveServiceOptions['database']['createValidatedSshProject']
|
||||
>[0]) => {
|
||||
write.assertSshHostCurrent({
|
||||
hostId,
|
||||
hostRevision: 3,
|
||||
hostKeyGeneration: 2,
|
||||
remoteUsername: 'builder',
|
||||
remoteRootPath: root,
|
||||
workspaceIdentity: workspace.workspaceIdentity,
|
||||
agentProtocolMajor: 1,
|
||||
agentInstallationId: installation.installationId,
|
||||
agentBinaryDigest: digest,
|
||||
agentVersion: '1.2.3',
|
||||
agentArchitecture: 'x64'
|
||||
})
|
||||
}
|
||||
const createProject = vi.fn((write) => {
|
||||
calls.push('database-create')
|
||||
assertWrite(write)
|
||||
return options.create?.() ?? project()
|
||||
})
|
||||
const updateProject = vi.fn((_id, _updatedAt, write) => {
|
||||
calls.push('database-update')
|
||||
assertWrite(write)
|
||||
return project()
|
||||
})
|
||||
const progress: string[] = []
|
||||
const service = new RemoteProjectSaveService({
|
||||
database: {
|
||||
getProject: vi.fn(() => options.getProject?.() ?? project()),
|
||||
createValidatedSshProject: createProject,
|
||||
updateValidatedSshProject: updateProject
|
||||
},
|
||||
sshHosts: {
|
||||
resolveConnectionTarget: vi.fn(async () => {
|
||||
calls.push('host')
|
||||
return structuredClone(currentTarget)
|
||||
}),
|
||||
assertConnectionTargetCurrent: vi.fn(() => calls.push('host-current'))
|
||||
},
|
||||
installationManager: {
|
||||
ensureInstalled: vi.fn(async (_hostId, requestOptions) => {
|
||||
calls.push('install')
|
||||
return options.ensureInstalled
|
||||
? options.ensureInstalled(requestOptions.signal!)
|
||||
: installation
|
||||
})
|
||||
},
|
||||
connectionManager: {
|
||||
acquire: vi.fn(async () => {
|
||||
calls.push('attach')
|
||||
return connection
|
||||
})
|
||||
},
|
||||
resolveRuntimeSelection:
|
||||
options.resolveRuntimeSelection ??
|
||||
(async (selection) => selection),
|
||||
runtimeValidator: {
|
||||
validate: vi.fn(async (input) => {
|
||||
calls.push('runtime')
|
||||
return {
|
||||
...runtimeLease,
|
||||
evidence: {
|
||||
...runtimeLease.evidence,
|
||||
runtimeSelectionKey: input.runtimeSelectionKey
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
notify: (_owner, value) => progress.push(value.phase)
|
||||
})
|
||||
services.push(service)
|
||||
return {
|
||||
service,
|
||||
calls,
|
||||
createProject,
|
||||
updateProject,
|
||||
runtimeRelease,
|
||||
connectionRelease,
|
||||
progress
|
||||
}
|
||||
}
|
||||
|
||||
describe('RemoteProjectSaveService awaited save', () => {
|
||||
it('validates and creates in one awaited request, then cleans up in reverse order', async () => {
|
||||
const value = harness()
|
||||
await expect(
|
||||
value.service.save(new Owner(1), { intent: 'create', draft })
|
||||
).resolves.toMatchObject({ id: projectId })
|
||||
expect(value.progress).toEqual([
|
||||
'host',
|
||||
'agent',
|
||||
'workspace',
|
||||
'runtime',
|
||||
'saving'
|
||||
])
|
||||
expect(value.createProject).toHaveBeenCalledOnce()
|
||||
expect(value.calls.slice(-3)).toEqual([
|
||||
'runtime-release',
|
||||
'workspace/close',
|
||||
'connection-release'
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts a readable non-Git remote workspace', async () => {
|
||||
const value = harness({
|
||||
validateWorkspace: () => ({
|
||||
handle: {
|
||||
workspaceId: 'workspace-id',
|
||||
workspaceIdentity: 'workspace-identity',
|
||||
canonicalDisplayPath: root,
|
||||
access: 'read-only' as const,
|
||||
git: 'not-a-repository' as const,
|
||||
capabilities: [
|
||||
'list',
|
||||
'stat',
|
||||
'read-text',
|
||||
'search'
|
||||
] as const,
|
||||
generation: 1
|
||||
},
|
||||
validatedAt: '2030-01-01T00:00:01.000Z'
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
value.service.save(new Owner(1), {
|
||||
intent: 'create',
|
||||
draft
|
||||
})
|
||||
).resolves.toMatchObject({ id: projectId })
|
||||
expect(value.createProject).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('updates using the project revision captured before remote validation', async () => {
|
||||
const value = harness()
|
||||
await value.service.save(new Owner(1), {
|
||||
intent: 'update',
|
||||
draft: { projectId, ...draft }
|
||||
})
|
||||
expect(value.updateProject).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
'2030-01-01T00:00:00.000Z',
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('validates and persists the configured Runtime profile selection', async () => {
|
||||
const configuredSelection = {
|
||||
provider: 'opencode' as const,
|
||||
profileId: '00000000-0000-4000-8000-000000000003'
|
||||
}
|
||||
const value = harness({
|
||||
resolveRuntimeSelection: vi.fn(async () => configuredSelection)
|
||||
})
|
||||
|
||||
await value.service.save(new Owner(1), {
|
||||
intent: 'create',
|
||||
draft
|
||||
})
|
||||
|
||||
expect(value.createProject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
project: expect.objectContaining({
|
||||
runtimeSelection: configuredSelection
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('activates a persisted SSH project through the full update pipeline', async () => {
|
||||
const value = harness()
|
||||
|
||||
await expect(
|
||||
value.service.activate(new Owner(1), projectId)
|
||||
).resolves.toMatchObject({ id: projectId })
|
||||
|
||||
expect(value.updateProject).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
'2030-01-01T00:00:00.000Z',
|
||||
expect.objectContaining({
|
||||
project: expect.objectContaining({
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
defaultWorkMode: draft.defaultWorkMode,
|
||||
runtimeSelection: draft.runtimeSelection
|
||||
}),
|
||||
executionSpace: expect.objectContaining({
|
||||
hostId,
|
||||
remoteRootPath: root
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(value.calls).toEqual(expect.arrayContaining([
|
||||
'host',
|
||||
'install',
|
||||
'attach',
|
||||
'workspace/validate',
|
||||
'runtime',
|
||||
'database-update'
|
||||
]))
|
||||
})
|
||||
|
||||
it('coalesces repeated activation for the same owner and project', async () => {
|
||||
let releaseInstallation!: () => void
|
||||
const installationGate = new Promise<void>((resolve) => {
|
||||
releaseInstallation = resolve
|
||||
})
|
||||
const value = harness({
|
||||
ensureInstalled: async () => {
|
||||
await installationGate
|
||||
return installation
|
||||
}
|
||||
})
|
||||
const owner = new Owner(1)
|
||||
|
||||
const first = value.service.activate(owner, projectId)
|
||||
await vi.waitFor(() => expect(value.calls).toContain('install'))
|
||||
const second = value.service.activate(owner, projectId)
|
||||
await expect(
|
||||
value.service.save(owner, { intent: 'create', draft })
|
||||
).rejects.toThrow('already in progress')
|
||||
|
||||
expect(
|
||||
value.calls.filter((call) => call === 'install')
|
||||
).toHaveLength(1)
|
||||
releaseInstallation()
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
expect.objectContaining({ id: projectId }),
|
||||
expect.objectContaining({ id: projectId })
|
||||
])
|
||||
expect(value.updateProject).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects activation for a local project before remote work starts', async () => {
|
||||
const value = harness({
|
||||
getProject: () => ({
|
||||
...project(),
|
||||
rootPath: 'C:\\workspace',
|
||||
executionSpace: {
|
||||
kind: 'local',
|
||||
rootPath: 'C:\\workspace'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
value.service.activate(new Owner(1), projectId)
|
||||
).rejects.toThrow('active managed SSH projects')
|
||||
expect(value.calls).toEqual([])
|
||||
expect(value.updateProject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not write when workspace validation fails', async () => {
|
||||
const value = harness({
|
||||
validateWorkspace: () => {
|
||||
throw new Error('workspace unavailable')
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
value.service.save(new Owner(1), { intent: 'create', draft })
|
||||
).rejects.toThrow('workspace unavailable')
|
||||
expect(value.createProject).not.toHaveBeenCalled()
|
||||
expect(value.connectionRelease).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels the current owner save without an operation id', async () => {
|
||||
const value = harness({
|
||||
ensureInstalled: (signal) =>
|
||||
new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
})
|
||||
const owner = new Owner(1)
|
||||
const saving = value.service.save(owner, { intent: 'create', draft })
|
||||
await vi.waitFor(() => expect(value.calls).toContain('install'))
|
||||
value.service.cancelCurrent(owner)
|
||||
await expect(saving).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(value.createProject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels when the renderer owner is destroyed', async () => {
|
||||
const value = harness({
|
||||
ensureInstalled: (signal) =>
|
||||
new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
})
|
||||
const owner = new Owner(1)
|
||||
const saving = value.service.save(owner, { intent: 'create', draft })
|
||||
await vi.waitFor(() => expect(value.calls).toContain('install'))
|
||||
owner.destroy()
|
||||
await expect(saving).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('surfaces transactional conflicts and permits a later retry', async () => {
|
||||
const value = harness({
|
||||
create: () => {
|
||||
throw new Error('project conflict')
|
||||
}
|
||||
})
|
||||
const owner = new Owner(1)
|
||||
await expect(
|
||||
value.service.save(owner, { intent: 'create', draft })
|
||||
).rejects.toThrow('project conflict')
|
||||
await expect(
|
||||
value.service.save(owner, {
|
||||
intent: 'update',
|
||||
draft: { projectId, ...draft }
|
||||
})
|
||||
).resolves.toMatchObject({ id: projectId })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,673 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type {
|
||||
AssistantProject,
|
||||
ProjectRuntimeValidation,
|
||||
SshExecutionValidation
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { projectRuntimeValidationSchema } from '../../shared/assistant-contracts'
|
||||
import {
|
||||
remoteProjectSaveRequestSchema,
|
||||
type RemoteProjectSavePhase,
|
||||
type RemoteProjectSaveProgress,
|
||||
type RemoteProjectSaveRequest
|
||||
} from '../../shared/remote-project-candidate-contracts'
|
||||
import { sha256DigestSchema } from '../../shared/agent-protocol/contracts'
|
||||
import {
|
||||
REMOTE_WORKSPACE_READ_CAPABILITIES,
|
||||
remoteWorkspaceValidateResultSchema,
|
||||
type RemoteWorkspaceHandle
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import {
|
||||
agentRuntimeSelectionKey,
|
||||
type AgentRuntimeSelection
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
import type {
|
||||
AssistantDatabase,
|
||||
ValidatedSshHostPrecondition,
|
||||
ValidatedSshProjectWrite
|
||||
} from '../assistant/assistant-database'
|
||||
import type {
|
||||
CurrentSshConnectionTarget,
|
||||
SshConnectionTarget,
|
||||
SshHostStore
|
||||
} from '../ssh/ssh-host-store'
|
||||
import type {
|
||||
AgentInstallationIdentity,
|
||||
AgentInstallationManager
|
||||
} from './agent-installation-manager'
|
||||
import type {
|
||||
RemoteAgentConnection,
|
||||
RemoteAgentConnectionManager
|
||||
} from './remote-agent-connection-manager'
|
||||
|
||||
const CLEANUP_TIMEOUT_MS = 5_000
|
||||
const WORKSPACE_READ_CAPABILITY = {
|
||||
name: 'workspace/read',
|
||||
minimumVersion: 1,
|
||||
critical: true
|
||||
} as const
|
||||
|
||||
export interface RemoteProjectSaveOwner {
|
||||
readonly id: number
|
||||
isDestroyed(): boolean
|
||||
on(event: 'destroyed', listener: () => void): unknown
|
||||
removeListener(event: 'destroyed', listener: () => void): unknown
|
||||
}
|
||||
|
||||
export type RemoteProjectRuntimeValidationInput = Readonly<{
|
||||
selection: AgentRuntimeSelection
|
||||
runtimeSelectionKey: string
|
||||
workMode: 'ask' | 'execute'
|
||||
host: Readonly<{
|
||||
hostId: string
|
||||
hostRevision: number
|
||||
hostKeyGeneration: number
|
||||
remoteUsername: string
|
||||
}>
|
||||
agent: Readonly<{
|
||||
installationId: string
|
||||
binaryDigest: string
|
||||
version: string
|
||||
architecture: 'x64' | 'arm64'
|
||||
protocolMajor: number
|
||||
}>
|
||||
workspace: Readonly<{
|
||||
canonicalRemoteRoot: string
|
||||
workspaceIdentity: string
|
||||
workspaceId: string
|
||||
generation: number
|
||||
access: 'read-only'
|
||||
capabilities: readonly string[]
|
||||
}>
|
||||
connection: RemoteAgentConnection
|
||||
signal: AbortSignal
|
||||
}>
|
||||
|
||||
export type RemoteProjectRuntimeValidationEvidence =
|
||||
ProjectRuntimeValidation
|
||||
|
||||
export interface RemoteProjectRuntimeValidationLease {
|
||||
readonly evidence: RemoteProjectRuntimeValidationEvidence
|
||||
assertCurrent(): void
|
||||
release(): void
|
||||
}
|
||||
|
||||
export interface RemoteProjectRuntimeValidator {
|
||||
validate(
|
||||
input: RemoteProjectRuntimeValidationInput
|
||||
): Promise<RemoteProjectRuntimeValidationLease>
|
||||
}
|
||||
|
||||
export class UnavailableRemoteProjectRuntimeValidator
|
||||
implements RemoteProjectRuntimeValidator
|
||||
{
|
||||
async validate(): Promise<never> {
|
||||
throw new Error('Remote Runtime validation is unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
type ActiveSave = {
|
||||
controller: AbortController
|
||||
done: Promise<void>
|
||||
resolveDone: () => void
|
||||
destroyedListener: () => void
|
||||
installation?: AgentInstallationIdentity
|
||||
connection?: RemoteAgentConnection
|
||||
workspace?: RemoteWorkspaceHandle
|
||||
runtimeLease?: RemoteProjectRuntimeValidationLease
|
||||
}
|
||||
|
||||
export type RemoteProjectSaveServiceOptions = {
|
||||
database: Pick<
|
||||
AssistantDatabase,
|
||||
| 'getProject'
|
||||
| 'createValidatedSshProject'
|
||||
| 'updateValidatedSshProject'
|
||||
>
|
||||
sshHosts: Pick<
|
||||
SshHostStore,
|
||||
'resolveConnectionTarget' | 'assertConnectionTargetCurrent'
|
||||
>
|
||||
installationManager: Pick<AgentInstallationManager, 'ensureInstalled'>
|
||||
connectionManager: Pick<RemoteAgentConnectionManager, 'acquire'>
|
||||
resolveRuntimeSelection(
|
||||
selection: AgentRuntimeSelection
|
||||
): Promise<AgentRuntimeSelection>
|
||||
runtimeValidator?: RemoteProjectRuntimeValidator
|
||||
notify?: (
|
||||
owner: RemoteProjectSaveOwner,
|
||||
progress: RemoteProjectSaveProgress
|
||||
) => void
|
||||
cleanupTimeoutMs?: number
|
||||
}
|
||||
|
||||
export class RemoteProjectSaveService {
|
||||
readonly #database: RemoteProjectSaveServiceOptions['database']
|
||||
readonly #sshHosts: RemoteProjectSaveServiceOptions['sshHosts']
|
||||
readonly #installationManager: RemoteProjectSaveServiceOptions['installationManager']
|
||||
readonly #connectionManager: RemoteProjectSaveServiceOptions['connectionManager']
|
||||
readonly #resolveRuntimeSelection: RemoteProjectSaveServiceOptions['resolveRuntimeSelection']
|
||||
readonly #runtimeValidator: RemoteProjectRuntimeValidator
|
||||
readonly #notify?: RemoteProjectSaveServiceOptions['notify']
|
||||
readonly #cleanupTimeoutMs: number
|
||||
readonly #active = new Map<RemoteProjectSaveOwner, ActiveSave>()
|
||||
readonly #activations = new Map<
|
||||
RemoteProjectSaveOwner,
|
||||
{ projectId: string; promise: Promise<AssistantProject> }
|
||||
>()
|
||||
#disposed = false
|
||||
|
||||
constructor(options: RemoteProjectSaveServiceOptions) {
|
||||
this.#database = options.database
|
||||
this.#sshHosts = options.sshHosts
|
||||
this.#installationManager = options.installationManager
|
||||
this.#connectionManager = options.connectionManager
|
||||
this.#resolveRuntimeSelection = options.resolveRuntimeSelection
|
||||
this.#runtimeValidator =
|
||||
options.runtimeValidator ??
|
||||
new UnavailableRemoteProjectRuntimeValidator()
|
||||
this.#notify = options.notify
|
||||
this.#cleanupTimeoutMs =
|
||||
options.cleanupTimeoutMs ?? CLEANUP_TIMEOUT_MS
|
||||
if (
|
||||
!Number.isSafeInteger(this.#cleanupTimeoutMs) ||
|
||||
this.#cleanupTimeoutMs < 1
|
||||
) {
|
||||
throw new RangeError('Invalid remote project cleanup timeout')
|
||||
}
|
||||
}
|
||||
|
||||
async save(
|
||||
owner: RemoteProjectSaveOwner,
|
||||
input: unknown
|
||||
): Promise<AssistantProject> {
|
||||
this.#assertOwnerAvailable(owner)
|
||||
if (this.#active.has(owner)) {
|
||||
throw new Error('A remote project save is already in progress')
|
||||
}
|
||||
const request = remoteProjectSaveRequestSchema.parse(input)
|
||||
let resolveDone!: () => void
|
||||
const active: ActiveSave = {
|
||||
controller: new AbortController(),
|
||||
done: new Promise<void>((resolve) => {
|
||||
resolveDone = resolve
|
||||
}),
|
||||
resolveDone,
|
||||
destroyedListener: () => {
|
||||
active.controller.abort(
|
||||
new DOMException('Remote project owner was destroyed', 'AbortError')
|
||||
)
|
||||
}
|
||||
}
|
||||
this.#active.set(owner, active)
|
||||
owner.on('destroyed', active.destroyedListener)
|
||||
|
||||
try {
|
||||
return await this.#performSave(owner, active, request)
|
||||
} finally {
|
||||
await this.#cleanup(active)
|
||||
owner.removeListener('destroyed', active.destroyedListener)
|
||||
if (this.#active.get(owner) === active) {
|
||||
this.#active.delete(owner)
|
||||
}
|
||||
active.resolveDone()
|
||||
}
|
||||
}
|
||||
|
||||
async activate(
|
||||
owner: RemoteProjectSaveOwner,
|
||||
projectId: string
|
||||
): Promise<AssistantProject> {
|
||||
this.#assertOwnerAvailable(owner)
|
||||
const existing = this.#activations.get(owner)
|
||||
if (existing) {
|
||||
if (existing.projectId === projectId) {
|
||||
return await existing.promise
|
||||
}
|
||||
throw new Error('A remote project save is already in progress')
|
||||
}
|
||||
const project = this.#database.getProject(projectId)
|
||||
if (
|
||||
project.kind !== 'user' ||
|
||||
project.channel !== undefined ||
|
||||
project.status !== 'active' ||
|
||||
project.executionSpace.kind !== 'ssh' ||
|
||||
project.runtimeSelection === undefined
|
||||
) {
|
||||
throw new Error('Only active managed SSH projects can be activated')
|
||||
}
|
||||
const promise = this.save(owner, {
|
||||
intent: 'update',
|
||||
draft: {
|
||||
projectId: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
defaultWorkMode: project.defaultWorkMode,
|
||||
runtimeSelection: project.runtimeSelection,
|
||||
hostId: project.executionSpace.hostId,
|
||||
remoteRootPath: project.executionSpace.remoteRootPath
|
||||
}
|
||||
})
|
||||
this.#activations.set(owner, { projectId, promise })
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
if (this.#activations.get(owner)?.promise === promise) {
|
||||
this.#activations.delete(owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelCurrent(owner: RemoteProjectSaveOwner): void {
|
||||
this.#active.get(owner)?.controller.abort(
|
||||
new DOMException('Remote project save cancelled', 'AbortError')
|
||||
)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.#disposed) return
|
||||
this.#disposed = true
|
||||
const saves = [...this.#active.entries()]
|
||||
for (const [, active] of saves) {
|
||||
active.controller.abort(
|
||||
new DOMException('Remote project service disposed', 'AbortError')
|
||||
)
|
||||
}
|
||||
await Promise.allSettled(saves.map(([, active]) => active.done))
|
||||
}
|
||||
|
||||
async #performSave(
|
||||
owner: RemoteProjectSaveOwner,
|
||||
active: ActiveSave,
|
||||
request: RemoteProjectSaveRequest
|
||||
): Promise<AssistantProject> {
|
||||
const { signal } = active.controller
|
||||
const expectedUpdatedAt =
|
||||
request.intent === 'update'
|
||||
? this.#readUpdateRevision(request)
|
||||
: undefined
|
||||
const runtimeSelection = await this.#resolveRuntimeSelection(
|
||||
request.draft.runtimeSelection
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
|
||||
this.#progress(owner, 'host')
|
||||
const target = await this.#sshHosts.resolveConnectionTarget(
|
||||
request.draft.hostId
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
assertTarget(request.draft.hostId, target)
|
||||
|
||||
this.#progress(owner, 'agent')
|
||||
const installation = await this.#installationManager.ensureInstalled(
|
||||
request.draft.hostId,
|
||||
{ signal }
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
assertInstallation(installation)
|
||||
active.installation = installation
|
||||
const connection = await this.#connectionManager.acquire(
|
||||
request.draft.hostId,
|
||||
{
|
||||
...installation,
|
||||
requiredCapabilities: [WORKSPACE_READ_CAPABILITY]
|
||||
},
|
||||
signal
|
||||
)
|
||||
active.connection = connection
|
||||
signal.throwIfAborted()
|
||||
assertConnectionIdentity(target, installation, connection)
|
||||
|
||||
this.#progress(owner, 'workspace')
|
||||
const validated = remoteWorkspaceValidateResultSchema.parse(
|
||||
await connection.client.request(
|
||||
'workspace/validate',
|
||||
{
|
||||
remoteRootPath: request.draft.remoteRootPath,
|
||||
requestedAccess: 'read-only',
|
||||
requiredCapabilities: [...REMOTE_WORKSPACE_READ_CAPABILITIES]
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
)
|
||||
active.workspace = validated.handle
|
||||
signal.throwIfAborted()
|
||||
assertWorkspace(validated.handle)
|
||||
if (
|
||||
validated.handle.canonicalDisplayPath !==
|
||||
request.draft.remoteRootPath
|
||||
) {
|
||||
throw new Error('Remote workspace path changed during validation')
|
||||
}
|
||||
assertCriticalWorkspaceRead(connection)
|
||||
|
||||
this.#progress(owner, 'runtime')
|
||||
const selectionKey = agentRuntimeSelectionKey(
|
||||
runtimeSelection
|
||||
)
|
||||
const runtimeLease = await this.#runtimeValidator.validate({
|
||||
selection: runtimeSelection,
|
||||
runtimeSelectionKey: selectionKey,
|
||||
workMode: request.draft.defaultWorkMode,
|
||||
host: {
|
||||
hostId: target.host.id,
|
||||
hostRevision: target.hostRevision,
|
||||
hostKeyGeneration: target.hostKeyGeneration,
|
||||
remoteUsername: target.host.username
|
||||
},
|
||||
agent: {
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: installation.binaryDigest,
|
||||
version: installation.agentVersion,
|
||||
architecture: installation.architecture,
|
||||
protocolMajor: installation.protocol.major
|
||||
},
|
||||
workspace: {
|
||||
canonicalRemoteRoot: validated.handle.canonicalDisplayPath,
|
||||
workspaceIdentity: validated.handle.workspaceIdentity,
|
||||
workspaceId: validated.handle.workspaceId,
|
||||
generation: validated.handle.generation,
|
||||
access: 'read-only',
|
||||
capabilities: validated.handle.capabilities
|
||||
},
|
||||
connection,
|
||||
signal
|
||||
})
|
||||
active.runtimeLease = runtimeLease
|
||||
signal.throwIfAborted()
|
||||
const runtimeEvidence = projectRuntimeValidationSchema.parse(
|
||||
runtimeLease.evidence
|
||||
)
|
||||
assertRuntimeEvidence(
|
||||
runtimeEvidence,
|
||||
selectionKey,
|
||||
installation.installationId,
|
||||
request.draft.defaultWorkMode
|
||||
)
|
||||
|
||||
this.#progress(owner, 'saving')
|
||||
signal.throwIfAborted()
|
||||
const write = this.#validatedWrite(
|
||||
request,
|
||||
runtimeSelection,
|
||||
target,
|
||||
installation,
|
||||
connection,
|
||||
validated.handle,
|
||||
runtimeLease,
|
||||
runtimeEvidence,
|
||||
validated.validatedAt
|
||||
)
|
||||
return request.intent === 'create'
|
||||
? this.#database.createValidatedSshProject(write)
|
||||
: this.#database.updateValidatedSshProject(
|
||||
request.draft.projectId,
|
||||
expectedUpdatedAt!,
|
||||
write
|
||||
)
|
||||
}
|
||||
|
||||
#readUpdateRevision(
|
||||
request: Extract<RemoteProjectSaveRequest, { intent: 'update' }>
|
||||
): string {
|
||||
const project = this.#database.getProject(request.draft.projectId)
|
||||
if (
|
||||
project.kind !== 'user' ||
|
||||
project.channel !== undefined ||
|
||||
project.executionSpace.kind !== 'ssh' ||
|
||||
project.executionSpace.hostId !== request.draft.hostId ||
|
||||
project.executionSpace.remoteRootPath !==
|
||||
request.draft.remoteRootPath
|
||||
) {
|
||||
throw new Error(
|
||||
'Only an existing user SSH project with the same Host and root can be updated'
|
||||
)
|
||||
}
|
||||
return project.updatedAt
|
||||
}
|
||||
|
||||
#validatedWrite(
|
||||
request: RemoteProjectSaveRequest,
|
||||
runtimeSelection: AgentRuntimeSelection,
|
||||
target: SshConnectionTarget,
|
||||
installation: AgentInstallationIdentity,
|
||||
connection: RemoteAgentConnection,
|
||||
workspace: RemoteWorkspaceHandle,
|
||||
runtimeLease: RemoteProjectRuntimeValidationLease,
|
||||
runtime: ProjectRuntimeValidation,
|
||||
validatedAt: string
|
||||
): ValidatedSshProjectWrite {
|
||||
const executionValidation: SshExecutionValidation = {
|
||||
hostRevision: target.hostRevision,
|
||||
hostKeyGeneration: target.hostKeyGeneration,
|
||||
remoteUsername: target.host.username,
|
||||
workspaceIdentity: workspace.workspaceIdentity,
|
||||
agentProtocolMajor: installation.protocol.major,
|
||||
agentInstallationIdAtValidation: installation.installationId,
|
||||
agentBinaryDigestAtValidation: installation.binaryDigest,
|
||||
agentVersionAtValidation: installation.agentVersion,
|
||||
agentArchitectureAtValidation: installation.architecture,
|
||||
validatedAt
|
||||
}
|
||||
return {
|
||||
project: {
|
||||
name: request.draft.name,
|
||||
description: request.draft.description,
|
||||
rootPath: workspace.canonicalDisplayPath,
|
||||
defaultWorkMode: request.draft.defaultWorkMode,
|
||||
runtimeSelection
|
||||
},
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId: target.host.id,
|
||||
remoteRootPath: workspace.canonicalDisplayPath,
|
||||
validation: executionValidation
|
||||
},
|
||||
runtimeValidation: structuredClone(runtime),
|
||||
assertSshHostCurrent: (expected) => {
|
||||
assertPrecondition(
|
||||
expected,
|
||||
target,
|
||||
installation,
|
||||
workspace
|
||||
)
|
||||
this.#sshHosts.assertConnectionTargetCurrent(
|
||||
currentTargetEvidence(target)
|
||||
)
|
||||
assertConnectionIdentity(target, installation, connection)
|
||||
assertCriticalWorkspaceRead(connection)
|
||||
runtimeLease.assertCurrent()
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
projectRuntimeValidationSchema.parse(runtimeLease.evidence),
|
||||
runtime
|
||||
)
|
||||
) {
|
||||
throw new Error('Remote Runtime validation changed before save')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #cleanup(active: ActiveSave): Promise<void> {
|
||||
try {
|
||||
active.runtimeLease?.release()
|
||||
} catch {
|
||||
// Continue releasing independently owned resources.
|
||||
}
|
||||
const workspace = active.workspace
|
||||
const connection = active.connection
|
||||
if (workspace && connection) {
|
||||
try {
|
||||
await connection.client.request(
|
||||
'workspace/close',
|
||||
{
|
||||
workspaceId: workspace.workspaceId,
|
||||
generation: workspace.generation
|
||||
},
|
||||
{ signal: AbortSignal.timeout(this.#cleanupTimeoutMs) }
|
||||
)
|
||||
} catch {
|
||||
// The connection may already be offline after cancellation.
|
||||
}
|
||||
}
|
||||
try {
|
||||
connection?.release()
|
||||
} catch {
|
||||
// A concurrently closed connection is already released.
|
||||
}
|
||||
}
|
||||
|
||||
#progress(
|
||||
owner: RemoteProjectSaveOwner,
|
||||
phase: RemoteProjectSavePhase
|
||||
): void {
|
||||
if (!this.#notify || owner.isDestroyed()) return
|
||||
try {
|
||||
this.#notify(owner, { phase })
|
||||
} catch {
|
||||
// Renderer progress observers cannot affect the save.
|
||||
}
|
||||
}
|
||||
|
||||
#assertOwnerAvailable(owner: RemoteProjectSaveOwner): void {
|
||||
if (this.#disposed) {
|
||||
throw new Error('Remote project save service is disposed')
|
||||
}
|
||||
if (
|
||||
!owner ||
|
||||
!Number.isSafeInteger(owner.id) ||
|
||||
owner.id < 0 ||
|
||||
owner.isDestroyed()
|
||||
) {
|
||||
throw new Error('Remote project owner is unavailable')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertTarget(hostId: string, target: SshConnectionTarget): void {
|
||||
if (
|
||||
target.host.id !== hostId ||
|
||||
!target.host.hostKey ||
|
||||
target.host.hostKey.generation !== target.hostKeyGeneration ||
|
||||
target.hostRevision < 1 ||
|
||||
target.hostKeyGeneration < 1
|
||||
) {
|
||||
throw new Error('SSH Host binding is unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
function assertInstallation(
|
||||
installation: AgentInstallationIdentity
|
||||
): void {
|
||||
if (
|
||||
!/^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/u.test(
|
||||
installation.installationId
|
||||
) ||
|
||||
!sha256DigestSchema.safeParse(installation.binaryDigest).success ||
|
||||
installation.platform !== 'linux' ||
|
||||
!['x64', 'arm64'].includes(installation.architecture) ||
|
||||
installation.supervisor !== 'detached-on-demand'
|
||||
) {
|
||||
throw new Error('Remote Agent installation is incompatible')
|
||||
}
|
||||
}
|
||||
|
||||
function assertConnectionIdentity(
|
||||
target: SshConnectionTarget,
|
||||
installation: AgentInstallationIdentity,
|
||||
connection: RemoteAgentConnection
|
||||
): void {
|
||||
if (
|
||||
connection.state !== 'ready' ||
|
||||
connection.identity.hostId !== target.host.id ||
|
||||
connection.identity.hostRevision !== target.hostRevision ||
|
||||
connection.identity.hostKeyGeneration !== target.hostKeyGeneration ||
|
||||
connection.identity.remoteUsername !== target.host.username ||
|
||||
connection.identity.installationId !== installation.installationId ||
|
||||
connection.identity.binaryDigest !== installation.binaryDigest ||
|
||||
connection.status.state !== 'ready' ||
|
||||
connection.status.draining ||
|
||||
connection.status.installationId !== installation.installationId ||
|
||||
connection.status.binaryDigest !== installation.binaryDigest
|
||||
) {
|
||||
throw new Error('Remote Agent identity is incompatible')
|
||||
}
|
||||
}
|
||||
|
||||
function assertCriticalWorkspaceRead(
|
||||
connection: RemoteAgentConnection
|
||||
): void {
|
||||
const capability = connection.capabilities.capabilities.find(
|
||||
(entry) => entry.name === WORKSPACE_READ_CAPABILITY.name
|
||||
)
|
||||
if (
|
||||
!capability ||
|
||||
capability.version < WORKSPACE_READ_CAPABILITY.minimumVersion ||
|
||||
!capability.critical
|
||||
) {
|
||||
throw new Error('Remote Agent lacks workspace read capability')
|
||||
}
|
||||
}
|
||||
|
||||
function assertWorkspace(handle: RemoteWorkspaceHandle): void {
|
||||
if (
|
||||
handle.access !== 'read-only' ||
|
||||
REMOTE_WORKSPACE_READ_CAPABILITIES.some(
|
||||
(capability) => !handle.capabilities.includes(capability)
|
||||
) ||
|
||||
handle.capabilities.includes('write-text-atomic') ||
|
||||
handle.capabilities.includes('apply-change-set')
|
||||
) {
|
||||
throw new Error('Remote workspace validation is incompatible')
|
||||
}
|
||||
}
|
||||
|
||||
function assertRuntimeEvidence(
|
||||
evidence: ProjectRuntimeValidation,
|
||||
selectionKey: string,
|
||||
installationId: string,
|
||||
workMode: 'ask' | 'execute'
|
||||
): void {
|
||||
if (
|
||||
evidence.runtimeSelectionKey !== selectionKey ||
|
||||
evidence.agentInstallationIdAtValidation !== installationId ||
|
||||
evidence.workMode !== workMode
|
||||
) {
|
||||
throw new Error('Remote Runtime validation does not match the project')
|
||||
}
|
||||
}
|
||||
|
||||
function assertPrecondition(
|
||||
expected: ValidatedSshHostPrecondition,
|
||||
target: SshConnectionTarget,
|
||||
installation: AgentInstallationIdentity,
|
||||
workspace: RemoteWorkspaceHandle
|
||||
): void {
|
||||
if (
|
||||
expected.hostId !== target.host.id ||
|
||||
expected.hostRevision !== target.hostRevision ||
|
||||
expected.hostKeyGeneration !== target.hostKeyGeneration ||
|
||||
expected.remoteUsername !== target.host.username ||
|
||||
expected.remoteRootPath !== workspace.canonicalDisplayPath ||
|
||||
expected.workspaceIdentity !== workspace.workspaceIdentity ||
|
||||
expected.agentProtocolMajor !== installation.protocol.major ||
|
||||
expected.agentInstallationId !== installation.installationId ||
|
||||
expected.agentBinaryDigest !== installation.binaryDigest ||
|
||||
expected.agentVersion !== installation.agentVersion ||
|
||||
expected.agentArchitecture !== installation.architecture
|
||||
) {
|
||||
throw new Error('Validated SSH project precondition changed')
|
||||
}
|
||||
}
|
||||
|
||||
function currentTargetEvidence(
|
||||
target: SshConnectionTarget
|
||||
): CurrentSshConnectionTarget {
|
||||
return {
|
||||
hostId: target.host.id,
|
||||
hostRevision: target.hostRevision,
|
||||
hostKeyGeneration: target.hostKeyGeneration,
|
||||
username: target.host.username
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentArchitecture,
|
||||
AgentReleaseKeyRegistry
|
||||
} from '../../shared/agent-installation-contracts'
|
||||
import {
|
||||
digestRemoteRuntimeBundleIdentity,
|
||||
digestRemoteRuntimeBundleManifest,
|
||||
type RemoteRuntimeBundleManifest,
|
||||
type RemoteRuntimeLock
|
||||
} from '../../shared/remote-runtime-launch-contracts'
|
||||
import type { BundledRemoteRuntimeResourcePaths } from './bundled-remote-runtime-resources'
|
||||
import {
|
||||
createRemoteRuntimeResourceLoader,
|
||||
loadRemoteRuntimeVerificationMetadata,
|
||||
loadVerifiedRemoteRuntimeResourceBundle
|
||||
} from './remote-runtime-resource-loader'
|
||||
import {
|
||||
RemoteRuntimeBundleResourcesUnavailableError
|
||||
} from './remote-runtime-installation-manager'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const registry: AgentReleaseKeyRegistry = {
|
||||
formatVersion: 1,
|
||||
keys: [],
|
||||
revocations: []
|
||||
}
|
||||
const runtimeLock: 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=='
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
describe('remote Runtime resource loader', () => {
|
||||
it('loads one verified digest directory and preserves metadata bytes', async () => {
|
||||
const fixture = await createFixture()
|
||||
const verify = vi.fn(async (bundleDirectory) => ({
|
||||
bundleDirectory,
|
||||
executablePath: join(bundleDirectory, 'bin', 'opencode'),
|
||||
manifest: fixture.manifest,
|
||||
manifestDigest:
|
||||
await digestRemoteRuntimeBundleManifest(fixture.manifest)
|
||||
}))
|
||||
|
||||
const loaded = await loadVerifiedRemoteRuntimeResourceBundle(
|
||||
fixture.paths,
|
||||
'x64',
|
||||
{
|
||||
verificationEnvironment: 'test',
|
||||
verifyRuntimeBundle: verify
|
||||
}
|
||||
)
|
||||
|
||||
expect(verify).toHaveBeenCalledWith(fixture.bundleDirectory, {
|
||||
architecture: 'x64',
|
||||
releaseKeyRegistry: registry,
|
||||
runtimeLock,
|
||||
verificationEnvironment: 'test'
|
||||
})
|
||||
expect(loaded).toEqual({
|
||||
bundleDirectory: fixture.bundleDirectory,
|
||||
manifest: fixture.manifest,
|
||||
manifestDigest:
|
||||
await digestRemoteRuntimeBundleManifest(fixture.manifest),
|
||||
canonicalReleaseKeyRegistryBytes: canonical(registry),
|
||||
canonicalRemoteRuntimeLockBytes: canonical(runtimeLock)
|
||||
})
|
||||
expect(Object.keys(loaded)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('rejects verification injection outside test verification', async () => {
|
||||
const fixture = await createFixture()
|
||||
const verify = vi.fn()
|
||||
|
||||
expect(() =>
|
||||
createRemoteRuntimeResourceLoader(fixture.paths, {
|
||||
verifyRuntimeBundle: verify
|
||||
})
|
||||
).toThrow('allowed only in test verification')
|
||||
await expect(
|
||||
loadVerifiedRemoteRuntimeResourceBundle(
|
||||
fixture.paths,
|
||||
'x64',
|
||||
{ verifyRuntimeBundle: verify }
|
||||
)
|
||||
).rejects.toThrow('allowed only in test verification')
|
||||
})
|
||||
|
||||
it('rejects missing and multiple digest directories', async () => {
|
||||
const missing = await createFixture({ digestDirectories: 0 })
|
||||
const multiple = await createFixture({ digestDirectories: 2 })
|
||||
const verify = vi.fn()
|
||||
|
||||
await expect(loadWithTestVerifier(missing.paths, verify))
|
||||
.rejects.toThrow('exactly one digest directory')
|
||||
await expect(loadWithTestVerifier(multiple.paths, verify))
|
||||
.rejects.toThrow('exactly one digest directory')
|
||||
expect(verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads verification metadata when installable Runtime resources are not packaged', async () => {
|
||||
const fixture = await createFixture()
|
||||
await rm(fixture.paths.runtimeRoots.x64, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadVerifiedRemoteRuntimeResourceBundle(
|
||||
fixture.paths,
|
||||
'x64',
|
||||
{ verificationEnvironment: 'test' }
|
||||
)
|
||||
).rejects.toBeInstanceOf(
|
||||
RemoteRuntimeBundleResourcesUnavailableError
|
||||
)
|
||||
await expect(
|
||||
loadRemoteRuntimeVerificationMetadata(fixture.paths)
|
||||
).resolves.toEqual({
|
||||
releaseKeyRegistry: registry,
|
||||
runtimeLock,
|
||||
canonicalReleaseKeyRegistryBytes: canonical(registry),
|
||||
canonicalRemoteRuntimeLockBytes: canonical(runtimeLock)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a symlinked digest directory', async () => {
|
||||
const fixture = await createFixture({ digestDirectories: 0 })
|
||||
const target = join(fixture.root, 'target')
|
||||
await mkdir(target)
|
||||
await symlink(
|
||||
target,
|
||||
join(fixture.paths.runtimeRoots.x64, 'a'.repeat(64)),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
|
||||
await expect(loadWithTestVerifier(fixture.paths, vi.fn()))
|
||||
.rejects.toThrow('not a real directory')
|
||||
})
|
||||
|
||||
it('rejects architecture and digest-directory mismatches', async () => {
|
||||
const fixture = await createFixture()
|
||||
const wrongArchitecture = {
|
||||
...fixture.manifest,
|
||||
architecture: 'arm64' as const
|
||||
}
|
||||
const architectureVerifier = vi.fn(async (bundleDirectory) => ({
|
||||
bundleDirectory,
|
||||
executablePath: join(bundleDirectory, 'bin', 'opencode'),
|
||||
manifest: wrongArchitecture,
|
||||
manifestDigest:
|
||||
await digestRemoteRuntimeBundleManifest(wrongArchitecture)
|
||||
}))
|
||||
await expect(
|
||||
loadWithTestVerifier(fixture.paths, architectureVerifier)
|
||||
).rejects.toThrow('does not match the requested OpenCode target')
|
||||
|
||||
const wrongDigest = {
|
||||
...fixture.manifest,
|
||||
bundleDigest: `sha256:${'f'.repeat(64)}` as const
|
||||
}
|
||||
const digestVerifier = vi.fn(async (bundleDirectory) => ({
|
||||
bundleDirectory,
|
||||
executablePath: join(bundleDirectory, 'bin', 'opencode'),
|
||||
manifest: wrongDigest,
|
||||
manifestDigest:
|
||||
await digestRemoteRuntimeBundleManifest(wrongDigest)
|
||||
}))
|
||||
await expect(
|
||||
loadWithTestVerifier(fixture.paths, digestVerifier)
|
||||
).rejects.toThrow('does not match its resource directory')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'malformed',
|
||||
bytes: Buffer.from('not json\n'),
|
||||
error: 'invalid JSON'
|
||||
},
|
||||
{
|
||||
name: 'noncanonical',
|
||||
bytes: Buffer.from(JSON.stringify(registry)),
|
||||
error: 'not canonical'
|
||||
},
|
||||
{
|
||||
name: 'oversize',
|
||||
bytes: Buffer.alloc(1024 * 1024 + 1, 0x20),
|
||||
error: 'exceeds its safety limit'
|
||||
}
|
||||
])('rejects $name metadata', async ({ bytes, error }) => {
|
||||
const fixture = await createFixture()
|
||||
await writeFile(fixture.paths.keyRegistryPath, bytes)
|
||||
|
||||
await expect(loadWithTestVerifier(fixture.paths, vi.fn()))
|
||||
.rejects.toThrow(error)
|
||||
})
|
||||
})
|
||||
|
||||
async function createFixture(options: {
|
||||
digestDirectories?: number
|
||||
} = {}): Promise<{
|
||||
root: string
|
||||
paths: BundledRemoteRuntimeResourcePaths
|
||||
bundleDirectory: string
|
||||
manifest: RemoteRuntimeBundleManifest
|
||||
}> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'runtime-loader-'))
|
||||
temporaryDirectories.push(root)
|
||||
const paths: BundledRemoteRuntimeResourcePaths = {
|
||||
keyRegistryPath: join(root, 'agent-release-keys.json'),
|
||||
runtimeLockPath: join(root, 'remote-runtime-lock.json'),
|
||||
runtimeRoots: {
|
||||
x64: join(root, 'linux-x64', 'opencode'),
|
||||
arm64: join(root, 'linux-arm64', 'opencode')
|
||||
}
|
||||
}
|
||||
await mkdir(paths.runtimeRoots.x64, { recursive: true })
|
||||
await mkdir(paths.runtimeRoots.arm64, { recursive: true })
|
||||
await writeFile(paths.keyRegistryPath, canonical(registry))
|
||||
await writeFile(paths.runtimeLockPath, canonical(runtimeLock))
|
||||
const manifest = await createManifest('x64')
|
||||
const digest = manifest.bundleDigest.slice('sha256:'.length)
|
||||
const directoryCount = options.digestDirectories ?? 1
|
||||
for (let index = 0; index < directoryCount; index += 1) {
|
||||
await mkdir(
|
||||
join(
|
||||
paths.runtimeRoots.x64,
|
||||
index === 0 ? digest : 'e'.repeat(64)
|
||||
)
|
||||
)
|
||||
}
|
||||
return {
|
||||
root,
|
||||
paths,
|
||||
bundleDirectory: join(paths.runtimeRoots.x64, digest),
|
||||
manifest
|
||||
}
|
||||
}
|
||||
|
||||
async function createManifest(
|
||||
architecture: AgentArchitecture
|
||||
): Promise<RemoteRuntimeBundleManifest> {
|
||||
const executable = Buffer.from('opencode')
|
||||
const license = Buffer.from('license')
|
||||
const initial: RemoteRuntimeBundleManifest = {
|
||||
formatVersion: 2,
|
||||
product: 'GoodBuddy',
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: '1.18.9',
|
||||
provider: 'opencode',
|
||||
platform: 'linux',
|
||||
architecture,
|
||||
signingKeyId: 'test-key',
|
||||
bundleDigest: `sha256:${'0'.repeat(64)}`,
|
||||
adapterDigest: `sha256:${'1'.repeat(64)}`,
|
||||
sourcePackage: {
|
||||
name:
|
||||
runtimeLock.runtimes.opencode.targets[architecture].package,
|
||||
integrity:
|
||||
runtimeLock.runtimes.opencode.targets[architecture].integrity
|
||||
},
|
||||
entrypoint: {
|
||||
identity: 'opencode-acp',
|
||||
path: 'bin/opencode',
|
||||
sha256: sha256(executable),
|
||||
argvPrefix: ['acp']
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'bin/opencode',
|
||||
size: executable.byteLength,
|
||||
sha256: sha256(executable),
|
||||
mode: '0755'
|
||||
},
|
||||
{
|
||||
path: 'licenses/LICENSE',
|
||||
size: license.byteLength,
|
||||
sha256: sha256(license),
|
||||
mode: '0644'
|
||||
}
|
||||
],
|
||||
licenses: [
|
||||
{
|
||||
package: 'opencode',
|
||||
version: '1.18.9',
|
||||
spdx: 'MIT',
|
||||
path: 'licenses/LICENSE'
|
||||
}
|
||||
],
|
||||
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:${'3'.repeat(64)}`,
|
||||
limits: {
|
||||
maximumPromptRuntimeMilliseconds: 60_000,
|
||||
maximumPromptInputBytes: 1024 * 1024,
|
||||
maximumPromptOutputBytes: 1024 * 1024
|
||||
}
|
||||
}
|
||||
return {
|
||||
...initial,
|
||||
bundleDigest: await digestRemoteRuntimeBundleIdentity(initial)
|
||||
}
|
||||
}
|
||||
|
||||
function loadWithTestVerifier(
|
||||
paths: BundledRemoteRuntimeResourcePaths,
|
||||
verifyRuntimeBundle: NonNullable<
|
||||
Parameters<
|
||||
typeof loadVerifiedRemoteRuntimeResourceBundle
|
||||
>[2]
|
||||
>['verifyRuntimeBundle']
|
||||
) {
|
||||
return loadVerifiedRemoteRuntimeResourceBundle(paths, 'x64', {
|
||||
verificationEnvironment: 'test',
|
||||
verifyRuntimeBundle
|
||||
})
|
||||
}
|
||||
|
||||
function canonical(value: unknown): Buffer {
|
||||
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function sha256(contents: Uint8Array): string {
|
||||
return createHash('sha256').update(contents).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { constants } from 'node:fs'
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
readdir
|
||||
} from 'node:fs/promises'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import {
|
||||
verifyRuntimeBundle,
|
||||
type VerifiedRuntimeBundle,
|
||||
type VerifyRuntimeBundleOptions
|
||||
} from '../../shared/node/runtime-bundle-verifier'
|
||||
import {
|
||||
agentReleaseKeyRegistrySchema,
|
||||
type AgentArchitecture,
|
||||
type AgentReleaseKeyRegistry
|
||||
} from '../../shared/agent-installation-contracts'
|
||||
import {
|
||||
digestRemoteRuntimeBundleManifest,
|
||||
remoteRuntimeBundleManifestSchema,
|
||||
remoteRuntimeLockSchema,
|
||||
type RemoteRuntimeLock
|
||||
} from '../../shared/remote-runtime-launch-contracts'
|
||||
import {
|
||||
RemoteRuntimeBundleResourcesUnavailableError,
|
||||
type RemoteRuntimeInstallationBundleLoader,
|
||||
type VerifiedRemoteRuntimeInstallationBundle
|
||||
} from './remote-runtime-installation-manager'
|
||||
import {
|
||||
getBundledRemoteRuntimeRoot,
|
||||
type BundledRemoteRuntimeResourcePaths
|
||||
} from './bundled-remote-runtime-resources'
|
||||
|
||||
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
||||
const digestDirectoryPattern = /^[a-f0-9]{64}$/u
|
||||
|
||||
type RuntimeBundleVerifier = (
|
||||
bundleDirectory: string,
|
||||
options: VerifyRuntimeBundleOptions
|
||||
) => Promise<VerifiedRuntimeBundle>
|
||||
|
||||
export type RemoteRuntimeResourceLoaderOptions = {
|
||||
verificationEnvironment?: 'production' | 'test'
|
||||
verifyRuntimeBundle?: RuntimeBundleVerifier
|
||||
}
|
||||
|
||||
type CanonicalMetadata<T> = {
|
||||
bytes: Buffer
|
||||
value: T
|
||||
}
|
||||
|
||||
export type RemoteRuntimeVerificationMetadata = {
|
||||
releaseKeyRegistry: AgentReleaseKeyRegistry
|
||||
runtimeLock: RemoteRuntimeLock
|
||||
canonicalReleaseKeyRegistryBytes: Buffer
|
||||
canonicalRemoteRuntimeLockBytes: Buffer
|
||||
}
|
||||
|
||||
export function createRemoteRuntimeResourceLoader(
|
||||
paths: BundledRemoteRuntimeResourcePaths,
|
||||
options: RemoteRuntimeResourceLoaderOptions = {}
|
||||
): RemoteRuntimeInstallationBundleLoader {
|
||||
assertSafeVerificationInjection(options)
|
||||
return (architecture) =>
|
||||
loadVerifiedRemoteRuntimeResourceBundle(
|
||||
paths,
|
||||
architecture,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export async function loadVerifiedRemoteRuntimeResourceBundle(
|
||||
paths: BundledRemoteRuntimeResourcePaths,
|
||||
architecture: AgentArchitecture,
|
||||
options: RemoteRuntimeResourceLoaderOptions = {}
|
||||
): Promise<VerifiedRemoteRuntimeInstallationBundle> {
|
||||
assertSafeVerificationInjection(options)
|
||||
const verificationEnvironment =
|
||||
options.verificationEnvironment ?? 'production'
|
||||
const metadata =
|
||||
await loadRemoteRuntimeVerificationMetadata(paths)
|
||||
const bundleDirectory = await resolveSingleDigestDirectory(
|
||||
getBundledRemoteRuntimeRoot(paths, architecture)
|
||||
)
|
||||
const verifier = options.verifyRuntimeBundle ?? verifyRuntimeBundle
|
||||
const verified = await verifier(bundleDirectory, {
|
||||
architecture,
|
||||
releaseKeyRegistry: metadata.releaseKeyRegistry,
|
||||
runtimeLock: metadata.runtimeLock,
|
||||
verificationEnvironment
|
||||
})
|
||||
|
||||
return validateVerifiedBundle(
|
||||
verified,
|
||||
bundleDirectory,
|
||||
architecture,
|
||||
metadata.canonicalReleaseKeyRegistryBytes,
|
||||
metadata.canonicalRemoteRuntimeLockBytes
|
||||
)
|
||||
}
|
||||
|
||||
export async function loadRemoteRuntimeVerificationMetadata(
|
||||
paths: BundledRemoteRuntimeResourcePaths
|
||||
): Promise<RemoteRuntimeVerificationMetadata> {
|
||||
const [keyRegistry, runtimeLock] = await Promise.all([
|
||||
readCanonicalMetadata(
|
||||
paths.keyRegistryPath,
|
||||
agentReleaseKeyRegistrySchema,
|
||||
'Runtime release-key registry'
|
||||
),
|
||||
readCanonicalMetadata(
|
||||
paths.runtimeLockPath,
|
||||
remoteRuntimeLockSchema,
|
||||
'Remote Runtime lock'
|
||||
)
|
||||
])
|
||||
return {
|
||||
releaseKeyRegistry: keyRegistry.value,
|
||||
runtimeLock: runtimeLock.value,
|
||||
canonicalReleaseKeyRegistryBytes: keyRegistry.bytes,
|
||||
canonicalRemoteRuntimeLockBytes: runtimeLock.bytes
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeVerificationInjection(
|
||||
options: RemoteRuntimeResourceLoaderOptions
|
||||
): void {
|
||||
if (
|
||||
options.verifyRuntimeBundle !== undefined &&
|
||||
options.verificationEnvironment !== 'test'
|
||||
) {
|
||||
throw new Error(
|
||||
'Injected Runtime bundle verification is allowed only in test verification'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readCanonicalMetadata<T>(
|
||||
filePathInput: string,
|
||||
schema: { parse(value: unknown): T },
|
||||
label: string
|
||||
): Promise<CanonicalMetadata<T>> {
|
||||
const filePath = resolve(filePathInput)
|
||||
const pathStat = await lstat(filePath)
|
||||
if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
|
||||
throw new Error(`${label} is not a regular file`)
|
||||
}
|
||||
if (
|
||||
pathStat.size === 0 ||
|
||||
pathStat.size > MAXIMUM_METADATA_BYTES
|
||||
) {
|
||||
throw new Error(`${label} exceeds its safety limit`)
|
||||
}
|
||||
|
||||
const handle = await open(
|
||||
filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
|
||||
)
|
||||
let bytes: Buffer
|
||||
try {
|
||||
const openedStat = await handle.stat()
|
||||
if (
|
||||
!openedStat.isFile() ||
|
||||
openedStat.dev !== pathStat.dev ||
|
||||
openedStat.ino !== pathStat.ino ||
|
||||
openedStat.size === 0 ||
|
||||
openedStat.size > MAXIMUM_METADATA_BYTES
|
||||
) {
|
||||
throw new Error(`${label} changed while it was being opened`)
|
||||
}
|
||||
const bounded = Buffer.alloc(MAXIMUM_METADATA_BYTES + 1)
|
||||
let offset = 0
|
||||
while (offset < bounded.byteLength) {
|
||||
const { bytesRead } = await handle.read(
|
||||
bounded,
|
||||
offset,
|
||||
bounded.byteLength - offset,
|
||||
offset
|
||||
)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
offset += bytesRead
|
||||
}
|
||||
bytes = bounded.subarray(0, offset)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
if (
|
||||
bytes.byteLength === 0 ||
|
||||
bytes.byteLength > MAXIMUM_METADATA_BYTES
|
||||
) {
|
||||
throw new Error(`${label} exceeds its safety limit`)
|
||||
}
|
||||
|
||||
let parsedJson: unknown
|
||||
try {
|
||||
parsedJson = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error(`${label} is invalid JSON`, { cause: error })
|
||||
}
|
||||
let value: T
|
||||
try {
|
||||
value = schema.parse(parsedJson)
|
||||
} catch (error) {
|
||||
throw new Error(`${label} contract is invalid`, { cause: error })
|
||||
}
|
||||
const canonicalBytes = Buffer.from(
|
||||
`${JSON.stringify(value, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
if (!canonicalBytes.equals(bytes)) {
|
||||
throw new Error(`${label} is not canonical`)
|
||||
}
|
||||
return { bytes: canonicalBytes, value }
|
||||
}
|
||||
|
||||
async function resolveSingleDigestDirectory(
|
||||
runtimeRootInput: string
|
||||
): Promise<string> {
|
||||
const runtimeRoot = resolve(runtimeRootInput)
|
||||
let rootStat
|
||||
try {
|
||||
rootStat = await lstat(runtimeRoot)
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
throw new RemoteRuntimeBundleResourcesUnavailableError(error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
||||
throw new Error('OpenCode Runtime resource root is not a real directory')
|
||||
}
|
||||
const entries = await readdir(runtimeRoot, { withFileTypes: true })
|
||||
if (entries.length !== 1) {
|
||||
throw new Error(
|
||||
'OpenCode Runtime resource root must contain exactly one digest directory'
|
||||
)
|
||||
}
|
||||
const entry = entries[0]
|
||||
if (entry === undefined || !digestDirectoryPattern.test(entry.name)) {
|
||||
throw new Error(
|
||||
'OpenCode Runtime resource directory name is not a SHA-256 digest'
|
||||
)
|
||||
}
|
||||
const bundleDirectory = join(runtimeRoot, entry.name)
|
||||
const bundleStat = await lstat(bundleDirectory)
|
||||
if (
|
||||
entry.isSymbolicLink() ||
|
||||
!bundleStat.isDirectory() ||
|
||||
bundleStat.isSymbolicLink()
|
||||
) {
|
||||
throw new Error(
|
||||
'OpenCode Runtime digest entry is not a real directory'
|
||||
)
|
||||
}
|
||||
return bundleDirectory
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: unknown }).code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
async function validateVerifiedBundle(
|
||||
verified: VerifiedRuntimeBundle,
|
||||
bundleDirectory: string,
|
||||
architecture: AgentArchitecture,
|
||||
keyRegistryBytes: Buffer,
|
||||
runtimeLockBytes: Buffer
|
||||
): Promise<VerifiedRemoteRuntimeInstallationBundle> {
|
||||
const manifest = remoteRuntimeBundleManifestSchema.parse(
|
||||
verified.manifest
|
||||
)
|
||||
if (
|
||||
verified.bundleDirectory !== bundleDirectory ||
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.provider !== 'opencode' ||
|
||||
manifest.platform !== 'linux' ||
|
||||
manifest.architecture !== architecture
|
||||
) {
|
||||
throw new Error(
|
||||
'Verified Runtime bundle does not match the requested OpenCode target'
|
||||
)
|
||||
}
|
||||
if (
|
||||
manifest.bundleDigest !==
|
||||
`sha256:${basename(bundleDirectory)}`
|
||||
) {
|
||||
throw new Error(
|
||||
'Runtime bundle digest does not match its resource directory'
|
||||
)
|
||||
}
|
||||
if (
|
||||
verified.manifestDigest !==
|
||||
await digestRemoteRuntimeBundleManifest(manifest)
|
||||
) {
|
||||
throw new Error('Verified Runtime manifest digest is invalid')
|
||||
}
|
||||
|
||||
return {
|
||||
bundleDirectory,
|
||||
manifest,
|
||||
manifestDigest: verified.manifestDigest,
|
||||
canonicalReleaseKeyRegistryBytes: keyRegistryBytes,
|
||||
canonicalRemoteRuntimeLockBytes: runtimeLockBytes
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user