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,128 @@
|
||||
import {
|
||||
operationIdentitySchema,
|
||||
operationMethodSchema,
|
||||
operationScopeSchema,
|
||||
type OperationIdentity,
|
||||
type OperationScope
|
||||
} from './contracts'
|
||||
|
||||
type JsonPrimitive = boolean | null | number | string
|
||||
export type CanonicalJsonValue =
|
||||
| JsonPrimitive
|
||||
| CanonicalJsonValue[]
|
||||
| { [key: string]: CanonicalJsonValue }
|
||||
|
||||
export class CanonicalPayloadError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'CanonicalPayloadError'
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return serialize(value, new Set())
|
||||
}
|
||||
|
||||
export async function digestCanonicalOperation(input: {
|
||||
method: string
|
||||
scope: OperationScope
|
||||
payload: unknown
|
||||
}): Promise<string> {
|
||||
const scope = operationScopeSchema.parse(input.scope)
|
||||
const method = operationMethodSchema.parse(input.method)
|
||||
const canonical = canonicalJson({
|
||||
method,
|
||||
payload: input.payload,
|
||||
scope
|
||||
})
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(canonical)
|
||||
)
|
||||
return `sha256:${Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, '0')
|
||||
).join('')}`
|
||||
}
|
||||
|
||||
export async function createOperationIdentity(input: {
|
||||
controllerId: string
|
||||
operationId: string
|
||||
scope: OperationScope
|
||||
method: string
|
||||
payload: unknown
|
||||
}): Promise<OperationIdentity> {
|
||||
return operationIdentitySchema.parse({
|
||||
controllerId: input.controllerId,
|
||||
operationId: input.operationId,
|
||||
scope: input.scope,
|
||||
method: input.method,
|
||||
payloadDigest: await digestCanonicalOperation(input)
|
||||
})
|
||||
}
|
||||
|
||||
function serialize(value: unknown, ancestors: Set<object>): string {
|
||||
if (value === null || typeof value === 'boolean') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new CanonicalPayloadError('Non-finite numbers are not JSON')
|
||||
}
|
||||
return JSON.stringify(Object.is(value, -0) ? 0 : value)
|
||||
}
|
||||
if (
|
||||
typeof value === 'bigint' ||
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol' ||
|
||||
typeof value === 'undefined'
|
||||
) {
|
||||
throw new CanonicalPayloadError(
|
||||
`Unsupported JSON value: ${typeof value}`
|
||||
)
|
||||
}
|
||||
if (typeof value !== 'object') {
|
||||
throw new CanonicalPayloadError('Unsupported JSON value')
|
||||
}
|
||||
if (ancestors.has(value)) {
|
||||
throw new CanonicalPayloadError('Circular JSON value')
|
||||
}
|
||||
ancestors.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value
|
||||
.map((item) => {
|
||||
if (item === undefined) {
|
||||
throw new CanonicalPayloadError(
|
||||
'Undefined array values are not allowed'
|
||||
)
|
||||
}
|
||||
return serialize(item, ancestors)
|
||||
})
|
||||
.join(',')}]`
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new CanonicalPayloadError(
|
||||
'Only plain JSON objects are allowed'
|
||||
)
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const keys = Object.keys(record).sort()
|
||||
return `{${keys
|
||||
.map((key) => {
|
||||
const item = record[key]
|
||||
if (item === undefined) {
|
||||
throw new CanonicalPayloadError(
|
||||
'Undefined object values are not allowed'
|
||||
)
|
||||
}
|
||||
return `${JSON.stringify(key)}:${serialize(item, ancestors)}`
|
||||
})
|
||||
.join(',')}}`
|
||||
} finally {
|
||||
ancestors.delete(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
agentFrameHeaderSchema,
|
||||
agentIdentifierSchema,
|
||||
positiveAgentSequenceSchema,
|
||||
type AgentFrameHeader
|
||||
} from './contracts'
|
||||
|
||||
export class ChannelProtocolError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code:
|
||||
| 'identity-mismatch'
|
||||
| 'generation-mismatch'
|
||||
| 'epoch-mismatch'
|
||||
| 'direction-mismatch'
|
||||
| 'sequence-mismatch'
|
||||
| 'closed'
|
||||
| 'channel-limit'
|
||||
| 'channel-lifetime-exhausted'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ChannelProtocolError'
|
||||
}
|
||||
}
|
||||
|
||||
export type ChannelStateOptions = {
|
||||
connectionId: string
|
||||
generation: number
|
||||
channelId: string
|
||||
channelEpoch: string
|
||||
inboundDirection: AgentFrameHeader['direction']
|
||||
nextInboundSequence?: string
|
||||
nextOutboundSequence?: string
|
||||
}
|
||||
|
||||
export class AgentChannelState {
|
||||
readonly connectionId: string
|
||||
readonly generation: number
|
||||
readonly channelId: string
|
||||
readonly channelEpoch: string
|
||||
readonly inboundDirection: AgentFrameHeader['direction']
|
||||
#nextInboundSequence: bigint
|
||||
#nextOutboundSequence: bigint
|
||||
#nextInboundAckSequence = 1n
|
||||
#nextOutboundAckSequence = 1n
|
||||
#closing = false
|
||||
#closed = false
|
||||
|
||||
constructor(options: ChannelStateOptions) {
|
||||
agentIdentifierSchema.parse(options.connectionId)
|
||||
agentIdentifierSchema.parse(options.channelId)
|
||||
positiveAgentSequenceSchema.parse(options.channelEpoch)
|
||||
positiveAgentSequenceSchema.parse(options.nextInboundSequence ?? '1')
|
||||
positiveAgentSequenceSchema.parse(options.nextOutboundSequence ?? '1')
|
||||
if (
|
||||
!Number.isSafeInteger(options.generation) ||
|
||||
options.generation < 1 ||
|
||||
options.generation > 0xffff_ffff
|
||||
) {
|
||||
throw new RangeError('Invalid connection generation')
|
||||
}
|
||||
this.connectionId = options.connectionId
|
||||
this.generation = options.generation
|
||||
this.channelId = options.channelId
|
||||
this.channelEpoch = options.channelEpoch
|
||||
this.inboundDirection = options.inboundDirection
|
||||
this.#nextInboundSequence = BigInt(options.nextInboundSequence ?? '1')
|
||||
this.#nextOutboundSequence = BigInt(options.nextOutboundSequence ?? '1')
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.#closed
|
||||
}
|
||||
|
||||
get closing(): boolean {
|
||||
return this.#closing
|
||||
}
|
||||
|
||||
acceptInbound(headerInput: AgentFrameHeader): void {
|
||||
this.#assertInboundOpen()
|
||||
let header: AgentFrameHeader
|
||||
try {
|
||||
header = agentFrameHeaderSchema.parse(headerInput)
|
||||
this.#assertIdentity(header)
|
||||
} catch (error) {
|
||||
this.#closed = true
|
||||
throw error
|
||||
}
|
||||
if (header.direction !== this.inboundDirection) {
|
||||
this.#closed = true
|
||||
throw new ChannelProtocolError(
|
||||
'Frame direction does not match channel',
|
||||
'direction-mismatch'
|
||||
)
|
||||
}
|
||||
const expectedSequence =
|
||||
header.kind === 'ack'
|
||||
? this.#nextInboundAckSequence
|
||||
: this.#nextInboundSequence
|
||||
if (BigInt(header.sequence) !== expectedSequence) {
|
||||
this.#closed = true
|
||||
throw new ChannelProtocolError(
|
||||
'Frame sequence is not the next expected value',
|
||||
'sequence-mismatch'
|
||||
)
|
||||
}
|
||||
if (header.kind === 'ack') {
|
||||
this.#nextInboundAckSequence += 1n
|
||||
} else {
|
||||
this.#nextInboundSequence += 1n
|
||||
}
|
||||
}
|
||||
|
||||
reserveOutbound(
|
||||
header: Omit<AgentFrameHeader, 'sequence'>
|
||||
): AgentFrameHeader {
|
||||
this.#assertOpen()
|
||||
const sequence =
|
||||
header.kind === 'ack'
|
||||
? this.#nextOutboundAckSequence
|
||||
: this.#nextOutboundSequence
|
||||
const candidate = agentFrameHeaderSchema.parse({
|
||||
...header,
|
||||
sequence: sequence.toString()
|
||||
})
|
||||
this.#assertIdentity(candidate)
|
||||
if (candidate.direction === this.inboundDirection) {
|
||||
throw new ChannelProtocolError(
|
||||
'Outbound direction matches inbound direction',
|
||||
'direction-mismatch'
|
||||
)
|
||||
}
|
||||
if (candidate.kind === 'ack') {
|
||||
this.#nextOutboundAckSequence += 1n
|
||||
} else {
|
||||
this.#nextOutboundSequence += 1n
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
beginClose(): void {
|
||||
this.#assertOpen()
|
||||
this.#closing = true
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#closing = true
|
||||
this.#closed = true
|
||||
}
|
||||
|
||||
#assertInboundOpen(): void {
|
||||
this.#assertOpen()
|
||||
if (this.#closing) {
|
||||
this.#closed = true
|
||||
throw new ChannelProtocolError('Channel is closing', 'closed')
|
||||
}
|
||||
}
|
||||
|
||||
#assertOpen(): void {
|
||||
if (this.#closed) {
|
||||
throw new ChannelProtocolError('Channel is closed', 'closed')
|
||||
}
|
||||
}
|
||||
|
||||
#assertIdentity(header: AgentFrameHeader): void {
|
||||
if (
|
||||
header.connectionId !== this.connectionId ||
|
||||
header.channelId !== this.channelId
|
||||
) {
|
||||
throw new ChannelProtocolError(
|
||||
'Frame identity does not match channel',
|
||||
'identity-mismatch'
|
||||
)
|
||||
}
|
||||
if (header.generation !== this.generation) {
|
||||
throw new ChannelProtocolError(
|
||||
'Frame belongs to an obsolete connection generation',
|
||||
'generation-mismatch'
|
||||
)
|
||||
}
|
||||
if (header.channelEpoch !== this.channelEpoch) {
|
||||
throw new ChannelProtocolError(
|
||||
'Frame belongs to another channel epoch',
|
||||
'epoch-mismatch'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentConnectionChannels {
|
||||
readonly #channels = new Map<string, AgentChannelState>()
|
||||
readonly #retiredChannelIds = new Set<string>()
|
||||
readonly #connectionId: string
|
||||
readonly #generation: number
|
||||
readonly #maximumChannels: number
|
||||
readonly #maximumLifetimeChannels: number
|
||||
#openedChannels = 0
|
||||
|
||||
constructor(options: {
|
||||
connectionId: string
|
||||
generation: number
|
||||
maximumChannels?: number
|
||||
maximumLifetimeChannels?: number
|
||||
}) {
|
||||
agentIdentifierSchema.parse(options.connectionId)
|
||||
if (
|
||||
!Number.isSafeInteger(options.generation) ||
|
||||
options.generation < 1 ||
|
||||
options.generation > 0xffff_ffff
|
||||
) {
|
||||
throw new RangeError('Invalid connection generation')
|
||||
}
|
||||
if (
|
||||
options.maximumChannels !== undefined &&
|
||||
(!Number.isSafeInteger(options.maximumChannels) ||
|
||||
options.maximumChannels < 1 ||
|
||||
options.maximumChannels >
|
||||
AGENT_PROTOCOL_LIMITS.maximumChannelsPerConnection)
|
||||
) {
|
||||
throw new RangeError('Invalid connection channel limit')
|
||||
}
|
||||
const maximumChannels =
|
||||
options.maximumChannels ??
|
||||
AGENT_PROTOCOL_LIMITS.maximumChannelsPerConnection
|
||||
const maximumLifetimeChannels =
|
||||
options.maximumLifetimeChannels ??
|
||||
AGENT_PROTOCOL_LIMITS.maximumChannelsPerConnection * 64
|
||||
if (
|
||||
!Number.isSafeInteger(maximumLifetimeChannels) ||
|
||||
maximumLifetimeChannels < maximumChannels ||
|
||||
maximumLifetimeChannels >
|
||||
AGENT_PROTOCOL_LIMITS.maximumChannelsPerConnection * 64
|
||||
) {
|
||||
throw new RangeError('Invalid connection channel lifetime limit')
|
||||
}
|
||||
this.#connectionId = options.connectionId
|
||||
this.#generation = options.generation
|
||||
this.#maximumChannels = maximumChannels
|
||||
this.#maximumLifetimeChannels = maximumLifetimeChannels
|
||||
}
|
||||
|
||||
open(
|
||||
options: Omit<
|
||||
ChannelStateOptions,
|
||||
'connectionId' | 'generation'
|
||||
>
|
||||
): AgentChannelState {
|
||||
if (
|
||||
this.#channels.has(options.channelId) ||
|
||||
this.#retiredChannelIds.has(options.channelId)
|
||||
) {
|
||||
throw new ChannelProtocolError(
|
||||
'Channel ID cannot be reused in a connection',
|
||||
'identity-mismatch'
|
||||
)
|
||||
}
|
||||
if (this.#openedChannels >= this.#maximumLifetimeChannels) {
|
||||
throw new ChannelProtocolError(
|
||||
'Connection channel lifetime is exhausted',
|
||||
'channel-lifetime-exhausted'
|
||||
)
|
||||
}
|
||||
if (this.#channels.size >= this.#maximumChannels) {
|
||||
throw new ChannelProtocolError(
|
||||
'Connection channel limit reached',
|
||||
'channel-limit'
|
||||
)
|
||||
}
|
||||
const channel = new AgentChannelState({
|
||||
...options,
|
||||
connectionId: this.#connectionId,
|
||||
generation: this.#generation
|
||||
})
|
||||
this.#channels.set(options.channelId, channel)
|
||||
this.#openedChannels += 1
|
||||
return channel
|
||||
}
|
||||
|
||||
get(channelId: string): AgentChannelState | undefined {
|
||||
return this.#channels.get(channelId)
|
||||
}
|
||||
|
||||
close(channelId: string): void {
|
||||
const channel = this.#channels.get(channelId)
|
||||
if (channel === undefined) {
|
||||
return
|
||||
}
|
||||
channel.close()
|
||||
this.#channels.delete(channelId)
|
||||
this.#retiredChannelIds.add(channelId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const AGENT_PROTOCOL_VERSION = {
|
||||
major: 2,
|
||||
minor: 0
|
||||
} as const
|
||||
|
||||
export const AGENT_PROTOCOL_LIMITS = {
|
||||
maximumControlFrameBytes: 1024 * 1024,
|
||||
maximumAcpFrameBytes: 1024 * 1024,
|
||||
maximumBlobFrameBytes: 2 * 1024 * 1024,
|
||||
maximumAckFrameBytes: 1024,
|
||||
maximumBufferedProtocolBytes: 32 * 1024 * 1024,
|
||||
maximumChannelsPerConnection: 64,
|
||||
maximumIdentifierBytes: 128,
|
||||
maximumMethodLength: 200,
|
||||
maximumEventPayloadBytes: 1024 * 1024,
|
||||
runEventJournalBytes: 8 * 1024 * 1024,
|
||||
runTerminalReserveBytes: 512 * 1024,
|
||||
controllerJournalBytes: 128 * 1024 * 1024,
|
||||
daemonJournalBytes: 512 * 1024 * 1024,
|
||||
runPendingEvents: 1_000,
|
||||
maximumAcpFramesPerChannel: 1_000,
|
||||
maximumAcpFramesPerController: 32_000,
|
||||
maximumAcpFramesPerDaemon: 128_000
|
||||
} as const
|
||||
|
||||
const identifierPattern = /^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/u
|
||||
const decimalPattern = /^(?:0|[1-9][0-9]{0,15})$/u
|
||||
|
||||
export const agentIdentifierSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(AGENT_PROTOCOL_LIMITS.maximumIdentifierBytes)
|
||||
.regex(identifierPattern)
|
||||
|
||||
export const agentSequenceSchema = z
|
||||
.string()
|
||||
.regex(decimalPattern)
|
||||
.refine(
|
||||
(value) => BigInt(value) <= BigInt(Number.MAX_SAFE_INTEGER),
|
||||
'Sequence exceeds the process-neutral safe integer range'
|
||||
)
|
||||
|
||||
export const positiveAgentSequenceSchema = agentSequenceSchema.refine(
|
||||
(value) => value !== '0',
|
||||
'Sequence must be positive'
|
||||
)
|
||||
|
||||
export const protocolVersionSchema = z
|
||||
.object({
|
||||
major: z.number().int().min(0).max(65_535),
|
||||
minor: z.number().int().min(0).max(65_535)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const agentFrameKindSchema = z.enum([
|
||||
'control',
|
||||
'acp',
|
||||
'blob',
|
||||
'ack'
|
||||
])
|
||||
|
||||
export const agentFrameDirectionSchema = z.enum([
|
||||
'main-to-agent',
|
||||
'agent-to-main'
|
||||
])
|
||||
|
||||
export const agentFrameHeaderSchema = z
|
||||
.object({
|
||||
protocolMajor: protocolVersionSchema.shape.major,
|
||||
protocolMinor: protocolVersionSchema.shape.minor,
|
||||
connectionId: agentIdentifierSchema,
|
||||
generation: z.number().int().min(1).max(0xffff_ffff),
|
||||
channelId: agentIdentifierSchema,
|
||||
channelEpoch: positiveAgentSequenceSchema,
|
||||
direction: agentFrameDirectionSchema,
|
||||
sequence: positiveAgentSequenceSchema,
|
||||
kind: agentFrameKindSchema,
|
||||
payloadLength: z.number().int().min(0)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((header, context) => {
|
||||
const maximum = maximumPayloadLength(header.kind)
|
||||
if (header.payloadLength > maximum) {
|
||||
context.addIssue({
|
||||
code: 'too_big',
|
||||
origin: 'number',
|
||||
maximum,
|
||||
inclusive: true,
|
||||
path: ['payloadLength'],
|
||||
message: `${header.kind} payload exceeds ${maximum} bytes`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type AgentFrameKind = z.infer<typeof agentFrameKindSchema>
|
||||
export type AgentFrameDirection = z.infer<
|
||||
typeof agentFrameDirectionSchema
|
||||
>
|
||||
export type AgentFrameHeader = z.infer<typeof agentFrameHeaderSchema>
|
||||
|
||||
export const frameAcknowledgmentSchema = z
|
||||
.object({
|
||||
acknowledgedSequence: positiveAgentSequenceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type FrameAcknowledgment = z.infer<
|
||||
typeof frameAcknowledgmentSchema
|
||||
>
|
||||
|
||||
export const operationScopeSchema = z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('project-candidate'),
|
||||
candidateId: agentIdentifierSchema
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('workspace'),
|
||||
workspaceIdentity: agentIdentifierSchema
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('run'),
|
||||
sessionId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('terminal'),
|
||||
terminalId: agentIdentifierSchema
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('managed-process'),
|
||||
processId: agentIdentifierSchema
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('installation'),
|
||||
installationId: agentIdentifierSchema
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
export type OperationScope = z.infer<typeof operationScopeSchema>
|
||||
|
||||
export const sha256DigestSchema = z
|
||||
.string()
|
||||
.regex(/^sha256:[a-f0-9]{64}$/u)
|
||||
|
||||
export function utf8ByteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
|
||||
export function utf8StringSchema(
|
||||
maximumBytes: number,
|
||||
options: {
|
||||
minimumBytes?: number
|
||||
label?: string
|
||||
} = {}
|
||||
): z.ZodType<string> {
|
||||
if (
|
||||
!Number.isSafeInteger(maximumBytes) ||
|
||||
maximumBytes < 0 ||
|
||||
!Number.isSafeInteger(options.minimumBytes ?? 0) ||
|
||||
(options.minimumBytes ?? 0) < 0 ||
|
||||
(options.minimumBytes ?? 0) > maximumBytes
|
||||
) {
|
||||
throw new RangeError('Invalid UTF-8 string byte limits')
|
||||
}
|
||||
const label = options.label ?? 'String'
|
||||
const minimumBytes = options.minimumBytes ?? 0
|
||||
return z.string().superRefine((value, context) => {
|
||||
const bytes = utf8ByteLength(value)
|
||||
if (bytes < minimumBytes) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: `${label} must contain at least ${minimumBytes} UTF-8 bytes`
|
||||
})
|
||||
}
|
||||
if (bytes > maximumBytes) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: `${label} exceeds ${maximumBytes} UTF-8 bytes`
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const operationMethodSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(AGENT_PROTOCOL_LIMITS.maximumMethodLength)
|
||||
.regex(/^[a-z][A-Za-z0-9-]*\/[A-Za-z][A-Za-z0-9-]*$/u)
|
||||
|
||||
export const operationIdentitySchema = z
|
||||
.object({
|
||||
controllerId: agentIdentifierSchema,
|
||||
operationId: agentIdentifierSchema,
|
||||
scope: operationScopeSchema,
|
||||
method: operationMethodSchema,
|
||||
payloadDigest: sha256DigestSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type OperationIdentity = z.infer<typeof operationIdentitySchema>
|
||||
|
||||
export const eventIdentitySchema = z
|
||||
.object({
|
||||
controllerId: agentIdentifierSchema,
|
||||
streamId: agentIdentifierSchema,
|
||||
streamEpoch: positiveAgentSequenceSchema,
|
||||
sequence: positiveAgentSequenceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type EventIdentity = z.infer<typeof eventIdentitySchema>
|
||||
|
||||
export const acpFrameDirectionSchema = z.enum([
|
||||
'main-to-runtime',
|
||||
'runtime-to-main'
|
||||
])
|
||||
|
||||
export type AcpFrameDirection = z.infer<
|
||||
typeof acpFrameDirectionSchema
|
||||
>
|
||||
|
||||
export function maximumPayloadLength(kind: AgentFrameKind): number {
|
||||
switch (kind) {
|
||||
case 'control':
|
||||
return AGENT_PROTOCOL_LIMITS.maximumControlFrameBytes
|
||||
case 'acp':
|
||||
return AGENT_PROTOCOL_LIMITS.maximumAcpFrameBytes
|
||||
case 'blob':
|
||||
return AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes
|
||||
case 'ack':
|
||||
return AGENT_PROTOCOL_LIMITS.maximumAckFrameBytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
agentIdentifierSchema,
|
||||
agentSequenceSchema,
|
||||
positiveAgentSequenceSchema,
|
||||
protocolVersionSchema,
|
||||
sha256DigestSchema,
|
||||
utf8StringSchema
|
||||
} from './contracts'
|
||||
|
||||
const generationSchema = z.number().int().min(1).max(0xffff_ffff)
|
||||
const timestampSchema = z.string().datetime({ offset: true })
|
||||
|
||||
export const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
|
||||
z.union([
|
||||
z.null(),
|
||||
z.boolean(),
|
||||
z.number().finite(),
|
||||
z.string(),
|
||||
z.array(jsonValueSchema),
|
||||
z.record(z.string(), jsonValueSchema)
|
||||
])
|
||||
)
|
||||
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue }
|
||||
|
||||
export const jsonRpcIdSchema = z.union([
|
||||
z.string().min(1).max(AGENT_PROTOCOL_LIMITS.maximumIdentifierBytes),
|
||||
z.number().int().safe()
|
||||
])
|
||||
|
||||
export const jsonRpcResponseIdSchema = z.union([
|
||||
jsonRpcIdSchema,
|
||||
z.null()
|
||||
])
|
||||
|
||||
export const jsonRpcParamsSchema = z.union([
|
||||
z.array(jsonValueSchema),
|
||||
z.record(z.string(), jsonValueSchema)
|
||||
])
|
||||
|
||||
const jsonRpcMethodSchema = utf8StringSchema(
|
||||
AGENT_PROTOCOL_LIMITS.maximumMethodLength,
|
||||
{ minimumBytes: 1, label: 'JSON-RPC method' }
|
||||
)
|
||||
|
||||
export const jsonRpcRequestSchema = z
|
||||
.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: jsonRpcIdSchema,
|
||||
method: jsonRpcMethodSchema,
|
||||
params: jsonRpcParamsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const jsonRpcNotificationSchema = z
|
||||
.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
method: jsonRpcMethodSchema,
|
||||
params: jsonRpcParamsSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const jsonRpcSuccessResponseSchema = z
|
||||
.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: jsonRpcResponseIdSchema,
|
||||
result: jsonValueSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const jsonRpcErrorObjectSchema = z
|
||||
.object({
|
||||
code: z.number().int().safe(),
|
||||
message: utf8StringSchema(8 * 1024, {
|
||||
minimumBytes: 1,
|
||||
label: 'JSON-RPC error message'
|
||||
}),
|
||||
data: jsonValueSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const jsonRpcErrorResponseSchema = z
|
||||
.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: jsonRpcResponseIdSchema,
|
||||
error: jsonRpcErrorObjectSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const jsonRpcMessageSchema = z.union([
|
||||
jsonRpcRequestSchema,
|
||||
jsonRpcNotificationSchema,
|
||||
jsonRpcSuccessResponseSchema,
|
||||
jsonRpcErrorResponseSchema
|
||||
])
|
||||
|
||||
export type JsonRpcRequest = z.infer<typeof jsonRpcRequestSchema>
|
||||
export type JsonRpcNotification = z.infer<
|
||||
typeof jsonRpcNotificationSchema
|
||||
>
|
||||
export type JsonRpcSuccessResponse = z.infer<
|
||||
typeof jsonRpcSuccessResponseSchema
|
||||
>
|
||||
export type JsonRpcErrorResponse = z.infer<
|
||||
typeof jsonRpcErrorResponseSchema
|
||||
>
|
||||
export type JsonRpcMessage = z.infer<typeof jsonRpcMessageSchema>
|
||||
|
||||
export const attachPrefaceSchema = z
|
||||
.object({
|
||||
type: z.literal('goodbuddy-agent-attach'),
|
||||
protocol: protocolVersionSchema,
|
||||
goodBuddyVersion: utf8StringSchema(64, {
|
||||
minimumBytes: 1,
|
||||
label: 'GoodBuddy version'
|
||||
}),
|
||||
controllerId: agentIdentifierSchema,
|
||||
clientNonce: agentIdentifierSchema,
|
||||
hostRevision: generationSchema,
|
||||
hostKeyGeneration: generationSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const attachWelcomeSchema = z
|
||||
.object({
|
||||
type: z.literal('goodbuddy-agent-welcome'),
|
||||
protocol: protocolVersionSchema,
|
||||
connectionId: agentIdentifierSchema,
|
||||
generation: generationSchema,
|
||||
installationId: agentIdentifierSchema,
|
||||
binaryDigest: sha256DigestSchema,
|
||||
daemonBootId: agentIdentifierSchema,
|
||||
serverNonce: agentIdentifierSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type AttachPreface = z.infer<typeof attachPrefaceSchema>
|
||||
export type AttachWelcome = z.infer<typeof attachWelcomeSchema>
|
||||
|
||||
export const agentCapabilitySchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[a-z][a-z0-9.-]*(?:\/[a-z][a-z0-9.-]*)?$/u),
|
||||
version: z.number().int().min(1).max(65_535),
|
||||
critical: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const daemonStatusSchema = z
|
||||
.object({
|
||||
state: z.enum([
|
||||
'unknown',
|
||||
'inspecting',
|
||||
'missing',
|
||||
'installed',
|
||||
'installing',
|
||||
'starting',
|
||||
'handshaking',
|
||||
'synchronizing',
|
||||
'ready',
|
||||
'draining',
|
||||
'offline',
|
||||
'incompatible',
|
||||
'corrupt',
|
||||
'supervisor-unavailable',
|
||||
'upgrade-rollback'
|
||||
]),
|
||||
installationId: agentIdentifierSchema,
|
||||
binaryDigest: sha256DigestSchema,
|
||||
daemonBootId: agentIdentifierSchema,
|
||||
agentVersion: utf8StringSchema(64, {
|
||||
minimumBytes: 1,
|
||||
label: 'Agent version'
|
||||
}),
|
||||
protocol: protocolVersionSchema,
|
||||
platform: z.literal('linux'),
|
||||
architecture: z.enum(['x64', 'arm64']),
|
||||
supervisor: z.literal('detached-on-demand'),
|
||||
remoteUserIdentity: agentIdentifierSchema,
|
||||
draining: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const daemonCapabilitiesSchema = z
|
||||
.object({
|
||||
generation: generationSchema,
|
||||
capabilities: z.array(agentCapabilitySchema).max(256),
|
||||
runtimes: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
runtimeId: agentIdentifierSchema,
|
||||
version: utf8StringSchema(64, {
|
||||
minimumBytes: 1,
|
||||
label: 'Runtime version'
|
||||
}),
|
||||
bundleDigest: sha256DigestSchema,
|
||||
acpCapabilitiesDigest: sha256DigestSchema,
|
||||
sessionLoad: z.boolean(),
|
||||
sessionResume: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(64)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
const names = new Set<string>()
|
||||
for (let index = 0; index < value.capabilities.length; index += 1) {
|
||||
const name = value.capabilities[index]!.name
|
||||
if (names.has(name)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['capabilities', index, 'name'],
|
||||
message: 'Capability names must be unique'
|
||||
})
|
||||
}
|
||||
names.add(name)
|
||||
}
|
||||
})
|
||||
|
||||
export const controllerResumeRequestSchema = z
|
||||
.object({
|
||||
previousGeneration: generationSchema,
|
||||
previousConnectionId: agentIdentifierSchema,
|
||||
daemonBootId: agentIdentifierSchema,
|
||||
capabilityGeneration: generationSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const controllerResumeResultSchema = z
|
||||
.object({
|
||||
resumed: z.boolean(),
|
||||
generation: generationSchema,
|
||||
daemonBootId: agentIdentifierSchema,
|
||||
capabilityGeneration: generationSchema,
|
||||
leaseDeadlineAt: timestampSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpOpenChannelRequestSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
runtimeId: agentIdentifierSchema,
|
||||
runtimeBundleDigest: sha256DigestSchema,
|
||||
workspaceIdentity: agentIdentifierSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpOpenChannelResultSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
channelId: agentIdentifierSchema,
|
||||
channelEpoch: positiveAgentSequenceSchema,
|
||||
acpCapabilitiesDigest: sha256DigestSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpCloseChannelRequestSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
channelId: agentIdentifierSchema,
|
||||
channelEpoch: positiveAgentSequenceSchema,
|
||||
reason: z.enum(['released', 'cancelled', 'draining', 'failed'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpCloseChannelResultSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
channelEpoch: positiveAgentSequenceSchema,
|
||||
closed: z.literal(true)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpGetCursorsRequestSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpResumeChannelRequestSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
channelId: agentIdentifierSchema,
|
||||
channelEpoch: positiveAgentSequenceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpBindingCursorsSchema = z
|
||||
.object({
|
||||
lastOutboundJournaledSequence: agentSequenceSchema,
|
||||
lastOutboundDeliveredSequence: agentSequenceSchema,
|
||||
lastInboundJournaledSequence: agentSequenceSchema,
|
||||
lastMainAckSequence: agentSequenceSchema
|
||||
})
|
||||
.strict()
|
||||
.superRefine((cursors, context) => {
|
||||
if (
|
||||
BigInt(cursors.lastOutboundDeliveredSequence) >
|
||||
BigInt(cursors.lastOutboundJournaledSequence)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['lastOutboundDeliveredSequence'],
|
||||
message: 'Delivered cursor cannot exceed journaled cursor'
|
||||
})
|
||||
}
|
||||
if (
|
||||
BigInt(cursors.lastMainAckSequence) >
|
||||
BigInt(cursors.lastInboundJournaledSequence)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['lastMainAckSequence'],
|
||||
message: 'Main ACK cursor cannot exceed journaled cursor'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const acpResumeChannelResultSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
channelId: agentIdentifierSchema,
|
||||
channelEpoch: positiveAgentSequenceSchema,
|
||||
deadlineAt: timestampSchema,
|
||||
cursors: acpBindingCursorsSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpReplayChannelRequestSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
channelId: agentIdentifierSchema,
|
||||
channelEpoch: positiveAgentSequenceSchema,
|
||||
acknowledgedSequence: agentSequenceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpReplayChannelResultSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
channelId: agentIdentifierSchema,
|
||||
channelEpoch: positiveAgentSequenceSchema,
|
||||
replayedThroughSequence: agentSequenceSchema,
|
||||
live: z.literal(true)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpCancellationReasonSchema = z.enum([
|
||||
'aborted',
|
||||
'timeout',
|
||||
'error',
|
||||
'requested',
|
||||
'unspecified'
|
||||
])
|
||||
|
||||
export const acpEscalateCancellationRequestSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
sessionId: agentIdentifierSchema,
|
||||
operationId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema,
|
||||
reason: acpCancellationReasonSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpEscalateCancellationResultSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
stopped: z.literal(true)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpReconcilePromptRequestSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
operationId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpCompletePromptRequestSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
operationId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpCompletePromptResultSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
operationId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema,
|
||||
status: z.literal('completed'),
|
||||
processTree: z.enum(['running', 'empty'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const acpReconcilePromptResultSchema = z.discriminatedUnion(
|
||||
'status',
|
||||
[
|
||||
z
|
||||
.object({
|
||||
status: z.literal('terminal'),
|
||||
terminalState: z.enum([
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'interrupted'
|
||||
]),
|
||||
processTree: z.enum(['running', 'empty'])
|
||||
})
|
||||
.strict()
|
||||
.superRefine((result, context) => {
|
||||
if (
|
||||
result.processTree === 'running' &&
|
||||
result.terminalState !== 'completed'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['processTree'],
|
||||
message:
|
||||
'Only a completed prompt may retain a running process tree'
|
||||
})
|
||||
}
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
status: z.literal('running'),
|
||||
processTree: z.literal('running')
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
status: z.literal('outcome-unknown'),
|
||||
processTree: z.literal('unknown')
|
||||
})
|
||||
.strict()
|
||||
]
|
||||
)
|
||||
|
||||
export const channelCloseRequestSchema = z
|
||||
.object({
|
||||
channelId: agentIdentifierSchema,
|
||||
channelEpoch: positiveAgentSequenceSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type DaemonStatus = z.infer<typeof daemonStatusSchema>
|
||||
export type DaemonCapabilities = z.infer<typeof daemonCapabilitiesSchema>
|
||||
export type ControllerResumeRequest = z.infer<
|
||||
typeof controllerResumeRequestSchema
|
||||
>
|
||||
export type ControllerResumeResult = z.infer<
|
||||
typeof controllerResumeResultSchema
|
||||
>
|
||||
export type AcpOpenChannelRequest = z.infer<
|
||||
typeof acpOpenChannelRequestSchema
|
||||
>
|
||||
export type AcpOpenChannelResult = z.infer<
|
||||
typeof acpOpenChannelResultSchema
|
||||
>
|
||||
export type AcpCloseChannelRequest = z.infer<
|
||||
typeof acpCloseChannelRequestSchema
|
||||
>
|
||||
export type AcpCloseChannelResult = z.infer<
|
||||
typeof acpCloseChannelResultSchema
|
||||
>
|
||||
export type AcpGetCursorsRequest = z.infer<
|
||||
typeof acpGetCursorsRequestSchema
|
||||
>
|
||||
export type AcpResumeChannelRequest = z.infer<
|
||||
typeof acpResumeChannelRequestSchema
|
||||
>
|
||||
export type AcpResumeChannelResult = z.infer<
|
||||
typeof acpResumeChannelResultSchema
|
||||
>
|
||||
export type AcpReplayChannelRequest = z.infer<
|
||||
typeof acpReplayChannelRequestSchema
|
||||
>
|
||||
export type AcpReplayChannelResult = z.infer<
|
||||
typeof acpReplayChannelResultSchema
|
||||
>
|
||||
export type AcpBindingCursors = z.infer<
|
||||
typeof acpBindingCursorsSchema
|
||||
>
|
||||
export type AcpEscalateCancellationRequest = z.infer<
|
||||
typeof acpEscalateCancellationRequestSchema
|
||||
>
|
||||
export type AcpEscalateCancellationResult = z.infer<
|
||||
typeof acpEscalateCancellationResultSchema
|
||||
>
|
||||
export type AcpReconcilePromptRequest = z.infer<
|
||||
typeof acpReconcilePromptRequestSchema
|
||||
>
|
||||
export type AcpReconcilePromptResult = z.infer<
|
||||
typeof acpReconcilePromptResultSchema
|
||||
>
|
||||
export type AcpCompletePromptRequest = z.infer<
|
||||
typeof acpCompletePromptRequestSchema
|
||||
>
|
||||
export type AcpCompletePromptResult = z.infer<
|
||||
typeof acpCompletePromptResultSchema
|
||||
>
|
||||
export const agentAttachPrefaceSchema = attachPrefaceSchema
|
||||
export const agentAttachWelcomeSchema = attachWelcomeSchema
|
||||
export const agentStatusSchema = daemonStatusSchema
|
||||
export const agentCapabilitiesSchema = daemonCapabilitiesSchema
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
import { agentIdentifierSchema } from './contracts'
|
||||
|
||||
export const AGENT_PROTOCOL_FAILURE_RECORD_NAME =
|
||||
'protocol-failure.json'
|
||||
export const AGENT_PROTOCOL_FAILURE_STDERR_PREFIX =
|
||||
'GoodBuddy Agent protocol failure: '
|
||||
|
||||
export const agentProtocolFailureCategorySchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[a-z][a-z0-9-]*(?:\/[a-z][a-z0-9-]*)*$/u)
|
||||
|
||||
export const agentProtocolFailureRecordSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
connectionId: agentIdentifierSchema,
|
||||
category: agentProtocolFailureCategorySchema,
|
||||
createdAt: z.number().int().safe().nonnegative()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type AgentProtocolFailureCategory = z.infer<
|
||||
typeof agentProtocolFailureCategorySchema
|
||||
>
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
agentFrameHeaderSchema,
|
||||
type AgentFrameHeader,
|
||||
type AgentFrameKind,
|
||||
maximumPayloadLength
|
||||
} from './contracts'
|
||||
|
||||
const MAGIC = new Uint8Array([0x47, 0x42, 0x41, 0x31])
|
||||
const ENVELOPE_VERSION = 1
|
||||
export const AGENT_FRAME_FIXED_HEADER_BYTES = 40
|
||||
const FIXED_HEADER_BYTES = AGENT_FRAME_FIXED_HEADER_BYTES
|
||||
export const MAXIMUM_ENCODED_AGENT_FRAME_BYTES =
|
||||
AGENT_FRAME_FIXED_HEADER_BYTES +
|
||||
AGENT_PROTOCOL_LIMITS.maximumIdentifierBytes * 2 +
|
||||
Math.max(
|
||||
AGENT_PROTOCOL_LIMITS.maximumControlFrameBytes,
|
||||
AGENT_PROTOCOL_LIMITS.maximumAcpFrameBytes,
|
||||
AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes,
|
||||
AGENT_PROTOCOL_LIMITS.maximumAckFrameBytes
|
||||
)
|
||||
|
||||
const kindCodes = {
|
||||
control: 0,
|
||||
acp: 1,
|
||||
blob: 2,
|
||||
ack: 3
|
||||
} as const satisfies Record<AgentFrameKind, number>
|
||||
|
||||
const kinds = [
|
||||
'control',
|
||||
'acp',
|
||||
'blob',
|
||||
'ack'
|
||||
] as const satisfies readonly AgentFrameKind[]
|
||||
|
||||
export type AgentFrame = {
|
||||
header: AgentFrameHeader
|
||||
payload: Uint8Array
|
||||
}
|
||||
|
||||
export class AgentFrameError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code:
|
||||
| 'incomplete'
|
||||
| 'invalid'
|
||||
| 'unsupported'
|
||||
| 'oversized'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AgentFrameError'
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectAgentFramePrefix(bytes: Uint8Array): {
|
||||
kind: AgentFrameKind
|
||||
encodedByteLength: number
|
||||
} {
|
||||
if (bytes.byteLength < FIXED_HEADER_BYTES) {
|
||||
throw new AgentFrameError('Incomplete fixed frame header', 'incomplete')
|
||||
}
|
||||
for (let index = 0; index < MAGIC.byteLength; index += 1) {
|
||||
if (bytes[index] !== MAGIC[index]) {
|
||||
throw new AgentFrameError('Invalid frame magic', 'invalid')
|
||||
}
|
||||
}
|
||||
const view = new DataView(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength
|
||||
)
|
||||
if (view.getUint8(4) !== ENVELOPE_VERSION) {
|
||||
throw new AgentFrameError(
|
||||
'Unsupported envelope version',
|
||||
'unsupported'
|
||||
)
|
||||
}
|
||||
if (view.getUint8(7) !== 0) {
|
||||
throw new AgentFrameError('Reserved frame flags are non-zero', 'invalid')
|
||||
}
|
||||
const kind = kinds[view.getUint8(5)]
|
||||
if (kind === undefined) {
|
||||
throw new AgentFrameError('Unknown frame kind', 'unsupported')
|
||||
}
|
||||
if (view.getUint8(6) > 1) {
|
||||
throw new AgentFrameError('Unknown frame direction', 'invalid')
|
||||
}
|
||||
const connectionLength = view.getUint16(32)
|
||||
const channelLength = view.getUint16(34)
|
||||
if (
|
||||
connectionLength === 0 ||
|
||||
channelLength === 0 ||
|
||||
connectionLength > AGENT_PROTOCOL_LIMITS.maximumIdentifierBytes ||
|
||||
channelLength > AGENT_PROTOCOL_LIMITS.maximumIdentifierBytes
|
||||
) {
|
||||
throw new AgentFrameError('Invalid frame identifier length', 'invalid')
|
||||
}
|
||||
const payloadLength = view.getUint32(36)
|
||||
if (payloadLength > maximumPayloadLength(kind)) {
|
||||
throw new AgentFrameError('Frame payload exceeds its limit', 'oversized')
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
encodedByteLength:
|
||||
FIXED_HEADER_BYTES +
|
||||
connectionLength +
|
||||
channelLength +
|
||||
payloadLength
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeAgentFrame(frame: AgentFrame): Uint8Array {
|
||||
const header = agentFrameHeaderSchema.parse({
|
||||
...frame.header,
|
||||
payloadLength: frame.payload.byteLength
|
||||
})
|
||||
const connection = encodeIdentifier(header.connectionId)
|
||||
const channel = encodeIdentifier(header.channelId)
|
||||
const output = new Uint8Array(
|
||||
FIXED_HEADER_BYTES +
|
||||
connection.byteLength +
|
||||
channel.byteLength +
|
||||
frame.payload.byteLength
|
||||
)
|
||||
output.set(MAGIC, 0)
|
||||
const view = new DataView(output.buffer)
|
||||
view.setUint8(4, ENVELOPE_VERSION)
|
||||
view.setUint8(5, kindCodes[header.kind])
|
||||
view.setUint8(6, header.direction === 'main-to-agent' ? 0 : 1)
|
||||
view.setUint8(7, 0)
|
||||
view.setUint16(8, header.protocolMajor)
|
||||
view.setUint16(10, header.protocolMinor)
|
||||
view.setUint32(12, header.generation)
|
||||
view.setBigUint64(16, BigInt(header.channelEpoch))
|
||||
view.setBigUint64(24, BigInt(header.sequence))
|
||||
view.setUint16(32, connection.byteLength)
|
||||
view.setUint16(34, channel.byteLength)
|
||||
view.setUint32(36, frame.payload.byteLength)
|
||||
output.set(connection, FIXED_HEADER_BYTES)
|
||||
output.set(channel, FIXED_HEADER_BYTES + connection.byteLength)
|
||||
output.set(
|
||||
frame.payload,
|
||||
FIXED_HEADER_BYTES + connection.byteLength + channel.byteLength
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
export function decodeAgentFrame(
|
||||
bytes: Uint8Array,
|
||||
expected?: {
|
||||
protocolMajor?: number
|
||||
maximumProtocolMinor?: number
|
||||
connectionId?: string
|
||||
generation?: number
|
||||
}
|
||||
): AgentFrame {
|
||||
if (bytes.byteLength < FIXED_HEADER_BYTES) {
|
||||
throw new AgentFrameError('Incomplete fixed frame header', 'incomplete')
|
||||
}
|
||||
for (let index = 0; index < MAGIC.byteLength; index += 1) {
|
||||
if (bytes[index] !== MAGIC[index]) {
|
||||
throw new AgentFrameError('Invalid frame magic', 'invalid')
|
||||
}
|
||||
}
|
||||
const view = new DataView(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength
|
||||
)
|
||||
if (view.getUint8(4) !== ENVELOPE_VERSION) {
|
||||
throw new AgentFrameError(
|
||||
'Unsupported envelope version',
|
||||
'unsupported'
|
||||
)
|
||||
}
|
||||
if (view.getUint8(7) !== 0) {
|
||||
throw new AgentFrameError('Reserved frame flags are non-zero', 'invalid')
|
||||
}
|
||||
const kind = kinds[view.getUint8(5)]
|
||||
if (kind === undefined) {
|
||||
throw new AgentFrameError('Unknown frame kind', 'unsupported')
|
||||
}
|
||||
const directionCode = view.getUint8(6)
|
||||
if (directionCode > 1) {
|
||||
throw new AgentFrameError('Unknown frame direction', 'invalid')
|
||||
}
|
||||
const connectionLength = view.getUint16(32)
|
||||
const channelLength = view.getUint16(34)
|
||||
if (
|
||||
connectionLength === 0 ||
|
||||
channelLength === 0 ||
|
||||
connectionLength > AGENT_PROTOCOL_LIMITS.maximumIdentifierBytes ||
|
||||
channelLength > AGENT_PROTOCOL_LIMITS.maximumIdentifierBytes
|
||||
) {
|
||||
throw new AgentFrameError('Invalid frame identifier length', 'invalid')
|
||||
}
|
||||
const payloadLength = view.getUint32(36)
|
||||
if (payloadLength > maximumPayloadLength(kind)) {
|
||||
throw new AgentFrameError('Frame payload exceeds its limit', 'oversized')
|
||||
}
|
||||
const expectedLength =
|
||||
FIXED_HEADER_BYTES +
|
||||
connectionLength +
|
||||
channelLength +
|
||||
payloadLength
|
||||
if (bytes.byteLength < expectedLength) {
|
||||
throw new AgentFrameError('Incomplete frame payload', 'incomplete')
|
||||
}
|
||||
if (bytes.byteLength !== expectedLength) {
|
||||
throw new AgentFrameError('Trailing bytes after frame', 'invalid')
|
||||
}
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
let connectionId: string
|
||||
let channelId: string
|
||||
try {
|
||||
connectionId = decoder.decode(
|
||||
bytes.subarray(
|
||||
FIXED_HEADER_BYTES,
|
||||
FIXED_HEADER_BYTES + connectionLength
|
||||
)
|
||||
)
|
||||
channelId = decoder.decode(
|
||||
bytes.subarray(
|
||||
FIXED_HEADER_BYTES + connectionLength,
|
||||
FIXED_HEADER_BYTES + connectionLength + channelLength
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
throw new AgentFrameError('Invalid UTF-8 frame identifier', 'invalid')
|
||||
}
|
||||
const header = agentFrameHeaderSchema.parse({
|
||||
protocolMajor: view.getUint16(8),
|
||||
protocolMinor: view.getUint16(10),
|
||||
connectionId,
|
||||
generation: view.getUint32(12),
|
||||
channelId,
|
||||
channelEpoch: view.getBigUint64(16).toString(),
|
||||
direction:
|
||||
directionCode === 0 ? 'main-to-agent' : 'agent-to-main',
|
||||
sequence: view.getBigUint64(24).toString(),
|
||||
kind,
|
||||
payloadLength
|
||||
})
|
||||
if (
|
||||
expected?.protocolMajor !== undefined &&
|
||||
header.protocolMajor !== expected.protocolMajor
|
||||
) {
|
||||
throw new AgentFrameError('Protocol major mismatch', 'unsupported')
|
||||
}
|
||||
if (
|
||||
expected?.maximumProtocolMinor !== undefined &&
|
||||
header.protocolMinor > expected.maximumProtocolMinor
|
||||
) {
|
||||
throw new AgentFrameError('Protocol minor was not negotiated', 'unsupported')
|
||||
}
|
||||
if (
|
||||
expected?.connectionId !== undefined &&
|
||||
header.connectionId !== expected.connectionId
|
||||
) {
|
||||
throw new AgentFrameError('Connection identity mismatch', 'invalid')
|
||||
}
|
||||
if (
|
||||
expected?.generation !== undefined &&
|
||||
header.generation !== expected.generation
|
||||
) {
|
||||
throw new AgentFrameError('Connection generation mismatch', 'invalid')
|
||||
}
|
||||
const payloadOffset =
|
||||
FIXED_HEADER_BYTES + connectionLength + channelLength
|
||||
return {
|
||||
header,
|
||||
payload: bytes.slice(payloadOffset, expectedLength)
|
||||
}
|
||||
}
|
||||
|
||||
function encodeIdentifier(value: string): Uint8Array {
|
||||
const encoded = new TextEncoder().encode(value)
|
||||
if (
|
||||
encoded.byteLength === 0 ||
|
||||
encoded.byteLength > AGENT_PROTOCOL_LIMITS.maximumIdentifierBytes
|
||||
) {
|
||||
throw new AgentFrameError('Frame identifier is oversized', 'oversized')
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './canonical'
|
||||
export * from './channel-state'
|
||||
export * from './contracts'
|
||||
export * from './control-contracts'
|
||||
export * from './frame'
|
||||
export * from './failure-diagnostic'
|
||||
@@ -0,0 +1,402 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_PROTOCOL_LIMITS,
|
||||
AGENT_PROTOCOL_VERSION,
|
||||
AgentChannelState,
|
||||
CanonicalPayloadError,
|
||||
MAXIMUM_ENCODED_AGENT_FRAME_BYTES,
|
||||
acpReplayChannelRequestSchema,
|
||||
acpResumeChannelResultSchema,
|
||||
agentFrameHeaderSchema,
|
||||
attachPrefaceSchema,
|
||||
canonicalJson,
|
||||
controllerResumeRequestSchema,
|
||||
controllerResumeResultSchema,
|
||||
decodeAgentFrame,
|
||||
daemonStatusSchema,
|
||||
daemonCapabilitiesSchema,
|
||||
digestCanonicalOperation,
|
||||
encodeAgentFrame,
|
||||
jsonRpcMessageSchema,
|
||||
operationIdentitySchema,
|
||||
type AgentFrameHeader
|
||||
} from '.'
|
||||
|
||||
const header: AgentFrameHeader = {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: 'connection-1',
|
||||
generation: 3,
|
||||
channelId: 'control-1',
|
||||
channelEpoch: '7',
|
||||
direction: 'main-to-agent',
|
||||
sequence: '42',
|
||||
kind: 'control',
|
||||
payloadLength: 0
|
||||
}
|
||||
|
||||
describe('agent protocol contracts', () => {
|
||||
it('strictly rejects unknown operation identity fields and malformed digests', () => {
|
||||
const base = {
|
||||
controllerId: 'controller-1',
|
||||
operationId: 'operation-1',
|
||||
scope: {
|
||||
kind: 'workspace' as const,
|
||||
workspaceIdentity: 'workspace-1'
|
||||
},
|
||||
method: 'workspace/writeTextAtomic',
|
||||
payloadDigest: `sha256:${'a'.repeat(64)}`
|
||||
}
|
||||
expect(operationIdentitySchema.parse(base)).toEqual(base)
|
||||
expect(() =>
|
||||
operationIdentitySchema.parse({ ...base, transportGeneration: 4 })
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
operationIdentitySchema.parse({
|
||||
...base,
|
||||
payloadDigest: 'not-a-digest'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('canonicalizes object keys without changing array order', () => {
|
||||
expect(
|
||||
canonicalJson({
|
||||
z: 1,
|
||||
nested: { beta: true, alpha: null },
|
||||
list: [3, 2, 1]
|
||||
})
|
||||
).toBe(
|
||||
'{"list":[3,2,1],"nested":{"alpha":null,"beta":true},"z":1}'
|
||||
)
|
||||
expect(canonicalJson({ value: -0 })).toBe('{"value":0}')
|
||||
})
|
||||
|
||||
it('produces the same operation digest for equivalent key order', async () => {
|
||||
const scope = {
|
||||
kind: 'workspace' as const,
|
||||
workspaceIdentity: 'workspace-1'
|
||||
}
|
||||
const first = await digestCanonicalOperation({
|
||||
method: 'workspace/writeTextAtomic',
|
||||
scope,
|
||||
payload: { path: 'a.ts', content: 'x' }
|
||||
})
|
||||
const second = await digestCanonicalOperation({
|
||||
method: 'workspace/writeTextAtomic',
|
||||
scope,
|
||||
payload: { content: 'x', path: 'a.ts' }
|
||||
})
|
||||
expect(first).toBe(second)
|
||||
expect(first).toMatch(/^sha256:[a-f0-9]{64}$/u)
|
||||
})
|
||||
|
||||
it('rejects non-JSON and cyclic digest inputs instead of silently dropping values', () => {
|
||||
expect(() => canonicalJson({ missing: undefined })).toThrow(
|
||||
CanonicalPayloadError
|
||||
)
|
||||
expect(() => canonicalJson(Number.POSITIVE_INFINITY)).toThrow(
|
||||
CanonicalPayloadError
|
||||
)
|
||||
const cyclic: { self?: unknown } = {}
|
||||
cyclic.self = cyclic
|
||||
expect(() => canonicalJson(cyclic)).toThrow(CanonicalPayloadError)
|
||||
})
|
||||
|
||||
it('strictly distinguishes all JSON-RPC message variants', () => {
|
||||
const messages = [
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'agent/status',
|
||||
params: {}
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'channel/close',
|
||||
params: { channelId: 'channel-1', channelEpoch: '1' }
|
||||
},
|
||||
{ jsonrpc: '2.0', id: 'request-1', result: null },
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32700, message: 'Parse error' }
|
||||
}
|
||||
]
|
||||
for (const message of messages) {
|
||||
expect(jsonRpcMessageSchema.parse(message)).toEqual(message)
|
||||
}
|
||||
expect(() =>
|
||||
jsonRpcMessageSchema.parse({
|
||||
...messages[0],
|
||||
result: null
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
jsonRpcMessageSchema.parse({
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
method: 'agent/status'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
jsonRpcMessageSchema.parse({
|
||||
...messages[1],
|
||||
unexpected: true
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('validates attach, capability, and controller synchronization boundaries', () => {
|
||||
const preface = {
|
||||
type: 'goodbuddy-agent-attach' as const,
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
goodBuddyVersion: '0.11.0',
|
||||
controllerId: 'controller-1',
|
||||
clientNonce: 'nonce-1',
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3
|
||||
}
|
||||
expect(attachPrefaceSchema.parse(preface)).toEqual(preface)
|
||||
expect(() =>
|
||||
attachPrefaceSchema.parse({ ...preface, connectionId: 'client-chosen' })
|
||||
).toThrow()
|
||||
|
||||
const capabilities = {
|
||||
generation: 1,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true }
|
||||
],
|
||||
runtimes: []
|
||||
}
|
||||
expect(daemonCapabilitiesSchema.parse(capabilities)).toEqual(
|
||||
capabilities
|
||||
)
|
||||
expect(() =>
|
||||
daemonCapabilitiesSchema.parse({
|
||||
...capabilities,
|
||||
capabilities: [
|
||||
...capabilities.capabilities,
|
||||
capabilities.capabilities[0]
|
||||
]
|
||||
})
|
||||
).toThrow(/unique/iu)
|
||||
|
||||
const resume = {
|
||||
previousGeneration: 1,
|
||||
previousConnectionId: 'connection-1',
|
||||
daemonBootId: 'boot-1',
|
||||
capabilityGeneration: 1
|
||||
}
|
||||
expect(controllerResumeRequestSchema.parse(resume)).toEqual(resume)
|
||||
expect(() =>
|
||||
controllerResumeRequestSchema.parse({
|
||||
...resume,
|
||||
controllerId: 'controller-from-client'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
controllerResumeRequestSchema.parse({
|
||||
...resume,
|
||||
eventCursors: []
|
||||
})
|
||||
).toThrow()
|
||||
|
||||
const resumeResult = {
|
||||
resumed: true,
|
||||
generation: 2,
|
||||
daemonBootId: 'boot-1',
|
||||
capabilityGeneration: 1,
|
||||
leaseDeadlineAt: '2030-01-01T00:00:00.000Z'
|
||||
}
|
||||
expect(controllerResumeResultSchema.parse(resumeResult)).toEqual(
|
||||
resumeResult
|
||||
)
|
||||
expect(() =>
|
||||
controllerResumeResultSchema.parse({
|
||||
...resumeResult,
|
||||
synchronizationRequired: true
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('accepts only the detached on-demand Agent supervisor', () => {
|
||||
const status = {
|
||||
state: 'ready' as const,
|
||||
installationId: 'agent-v1',
|
||||
binaryDigest: `sha256:${'a'.repeat(64)}`,
|
||||
daemonBootId: 'boot-1',
|
||||
agentVersion: '1.0.0',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
platform: 'linux' as const,
|
||||
architecture: 'x64' as const,
|
||||
supervisor: 'detached-on-demand' as const,
|
||||
remoteUserIdentity: 'user-1',
|
||||
draining: false
|
||||
}
|
||||
expect(daemonStatusSchema.parse(status)).toEqual(status)
|
||||
expect(() =>
|
||||
daemonStatusSchema.parse({
|
||||
...status,
|
||||
supervisor: 'systemd-user'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('types the two-phase ACP resume with stable binding identity and authoritative cursors', () => {
|
||||
expect(
|
||||
acpResumeChannelResultSchema.parse({
|
||||
bindingId: 'binding-1',
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z',
|
||||
cursors: {
|
||||
lastOutboundJournaledSequence: '3',
|
||||
lastOutboundDeliveredSequence: '2',
|
||||
lastInboundJournaledSequence: '5',
|
||||
lastMainAckSequence: '4'
|
||||
}
|
||||
})
|
||||
).toMatchObject({
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '9'
|
||||
})
|
||||
expect(() =>
|
||||
acpReplayChannelRequestSchema.parse({
|
||||
bindingId: 'binding-1',
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
acknowledgedSequence: '4',
|
||||
providerResponse: { secret: true }
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent binary frames', () => {
|
||||
it('sequences data and durable ACK frames independently', () => {
|
||||
const channel = new AgentChannelState({
|
||||
connectionId: 'connection-1',
|
||||
generation: 3,
|
||||
channelId: 'acp-1',
|
||||
channelEpoch: '7',
|
||||
inboundDirection: 'main-to-agent'
|
||||
})
|
||||
const inbound = (
|
||||
kind: AgentFrameHeader['kind'],
|
||||
sequence: string
|
||||
): AgentFrameHeader => ({
|
||||
...header,
|
||||
channelId: 'acp-1',
|
||||
direction: 'main-to-agent',
|
||||
kind,
|
||||
sequence
|
||||
})
|
||||
expect(() => channel.acceptInbound(inbound('acp', '1'))).not.toThrow()
|
||||
expect(() =>
|
||||
channel.acceptInbound(inbound('ack', '1'))
|
||||
).not.toThrow()
|
||||
expect(() => channel.acceptInbound(inbound('acp', '2'))).not.toThrow()
|
||||
|
||||
const outbound = (
|
||||
kind: AgentFrameHeader['kind']
|
||||
): AgentFrameHeader =>
|
||||
channel.reserveOutbound({
|
||||
...header,
|
||||
channelId: 'acp-1',
|
||||
direction: 'agent-to-main',
|
||||
kind
|
||||
})
|
||||
expect(outbound('acp').sequence).toBe('1')
|
||||
expect(outbound('ack').sequence).toBe('1')
|
||||
expect(outbound('acp').sequence).toBe('2')
|
||||
})
|
||||
|
||||
it('round-trips raw ACP bytes and all routing identity', () => {
|
||||
const payload = new Uint8Array([0, 10, 255, 42])
|
||||
const bytes = encodeAgentFrame({
|
||||
header: { ...header, kind: 'acp' },
|
||||
payload
|
||||
})
|
||||
const decoded = decodeAgentFrame(bytes, {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
maximumProtocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: 'connection-1',
|
||||
generation: 3
|
||||
})
|
||||
expect(decoded.header).toEqual({
|
||||
...header,
|
||||
kind: 'acp',
|
||||
payloadLength: payload.byteLength
|
||||
})
|
||||
expect(decoded.payload).toEqual(payload)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['control', AGENT_PROTOCOL_LIMITS.maximumControlFrameBytes],
|
||||
['acp', AGENT_PROTOCOL_LIMITS.maximumAcpFrameBytes],
|
||||
['blob', AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes],
|
||||
['ack', AGENT_PROTOCOL_LIMITS.maximumAckFrameBytes]
|
||||
] as const)('enforces the %s frame limit', (kind, maximum) => {
|
||||
expect(() =>
|
||||
agentFrameHeaderSchema.parse({
|
||||
...header,
|
||||
kind,
|
||||
payloadLength: maximum + 1
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('keeps the encoded frame bound aligned with maximum identifiers and payload', () => {
|
||||
const maximumIdentifier = 'a'.repeat(
|
||||
AGENT_PROTOCOL_LIMITS.maximumIdentifierBytes
|
||||
)
|
||||
const encoded = encodeAgentFrame({
|
||||
header: {
|
||||
...header,
|
||||
connectionId: maximumIdentifier,
|
||||
channelId: maximumIdentifier,
|
||||
kind: 'blob'
|
||||
},
|
||||
payload: new Uint8Array(
|
||||
AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes
|
||||
)
|
||||
})
|
||||
expect(encoded.byteLength).toBe(MAXIMUM_ENCODED_AGENT_FRAME_BYTES)
|
||||
})
|
||||
|
||||
it('rejects generation mismatch, trailing bytes, and incomplete frames', () => {
|
||||
const encoded = encodeAgentFrame({
|
||||
header,
|
||||
payload: new Uint8Array([1])
|
||||
})
|
||||
expect(() =>
|
||||
decodeAgentFrame(encoded, { generation: 4 })
|
||||
).toThrow(/generation/iu)
|
||||
const trailing = new Uint8Array(encoded.byteLength + 1)
|
||||
trailing.set(encoded)
|
||||
expect(() => decodeAgentFrame(trailing)).toThrow(/Trailing/iu)
|
||||
expect(() =>
|
||||
decodeAgentFrame(encoded.subarray(0, encoded.byteLength - 1))
|
||||
).toThrow(/Incomplete/iu)
|
||||
})
|
||||
|
||||
it('rejects unnegotiated major and minor versions', () => {
|
||||
const encoded = encodeAgentFrame({
|
||||
header: { ...header, protocolMajor: 2, protocolMinor: 4 },
|
||||
payload: new Uint8Array()
|
||||
})
|
||||
expect(() =>
|
||||
decodeAgentFrame(encoded, {
|
||||
protocolMajor: 1,
|
||||
maximumProtocolMinor: 3
|
||||
})
|
||||
).toThrow(/major/iu)
|
||||
expect(() =>
|
||||
decodeAgentFrame(encoded, {
|
||||
protocolMajor: 2,
|
||||
maximumProtocolMinor: 3
|
||||
})
|
||||
).toThrow(/minor/iu)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user