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,174 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentBundleManifestSchema,
|
||||
agentReleaseKeyRegistrySchema,
|
||||
agentRuntimeLockSchema
|
||||
} from './agent-installation-contracts'
|
||||
|
||||
const digest = 'a'.repeat(64)
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
agentVersion: '0.11.0',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
signingKeyId: 'test-key',
|
||||
entrypoint: {
|
||||
path: 'goodbuddy-agent',
|
||||
runtimePath: 'node',
|
||||
scriptPath: 'lib/agent.cjs'
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'goodbuddy-agent',
|
||||
size: 1,
|
||||
sha256: digest,
|
||||
mode: '0755'
|
||||
},
|
||||
{
|
||||
path: 'node',
|
||||
size: 1,
|
||||
sha256: digest,
|
||||
mode: '0755'
|
||||
},
|
||||
{
|
||||
path: 'lib/agent.cjs',
|
||||
size: 1,
|
||||
sha256: digest,
|
||||
mode: '0644'
|
||||
},
|
||||
{
|
||||
path: 'licenses/license.txt',
|
||||
size: 1,
|
||||
sha256: digest,
|
||||
mode: '0644'
|
||||
}
|
||||
],
|
||||
licenses: [
|
||||
{
|
||||
package: 'fixture',
|
||||
version: '1.0.0',
|
||||
spdx: 'MIT',
|
||||
path: 'licenses/license.txt'
|
||||
}
|
||||
]
|
||||
} as const
|
||||
}
|
||||
|
||||
describe('Agent installation contracts', () => {
|
||||
it('accepts a complete strict manifest', () => {
|
||||
expect(agentBundleManifestSchema.parse(manifest())).toMatchObject({
|
||||
arch: 'x64'
|
||||
})
|
||||
expect(() =>
|
||||
agentBundleManifestSchema.parse({
|
||||
...manifest(),
|
||||
agentVersion: 'release_10'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects unsafe, duplicate, undeclared, and incorrectly-modeled files', () => {
|
||||
expect(() =>
|
||||
agentBundleManifestSchema.parse({
|
||||
...manifest(),
|
||||
files: [
|
||||
...manifest().files,
|
||||
{
|
||||
path: '../outside',
|
||||
size: 1,
|
||||
sha256: digest,
|
||||
mode: '0644'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
agentBundleManifestSchema.parse({
|
||||
...manifest(),
|
||||
files: [...manifest().files, manifest().files[0]]
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
agentBundleManifestSchema.parse({
|
||||
...manifest(),
|
||||
licenses: [
|
||||
{
|
||||
package: 'fixture',
|
||||
version: '1.0.0',
|
||||
spdx: 'MIT',
|
||||
path: 'licenses/missing.txt'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
agentBundleManifestSchema.parse({
|
||||
...manifest(),
|
||||
files: manifest().files.map((file) =>
|
||||
file.path === 'goodbuddy-agent'
|
||||
? { ...file, mode: '0644' }
|
||||
: file
|
||||
)
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects duplicate keys and extra registry fields', () => {
|
||||
const key = {
|
||||
keyId: 'test-key',
|
||||
publicKeySpkiBase64: 'AAAA',
|
||||
environment: 'test'
|
||||
}
|
||||
expect(() =>
|
||||
agentReleaseKeyRegistrySchema.parse({
|
||||
formatVersion: 1,
|
||||
keys: [key, key],
|
||||
revocations: [],
|
||||
unknown: true
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('requires both locked Linux targets and the fixed Node patch', () => {
|
||||
const target = {
|
||||
archive: 'node.tar.gz',
|
||||
sha256: digest,
|
||||
binaryPath: 'node/bin/node',
|
||||
licensePath: 'node/LICENSE'
|
||||
}
|
||||
expect(
|
||||
agentRuntimeLockSchema.parse({
|
||||
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' }
|
||||
}).koffi.version
|
||||
).toBe('3.1.4')
|
||||
expect(() =>
|
||||
agentRuntimeLockSchema.parse({
|
||||
formatVersion: 1,
|
||||
agentVersion: '0.11.0',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
node: {
|
||||
version: '24.18.0',
|
||||
source: 'http://nodejs.org/',
|
||||
targets: { 'linux-x64': target }
|
||||
},
|
||||
koffi: { version: '3.1.3' }
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const versionPattern =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+((?:[0-9a-zA-Z-]+)(?:\.[0-9a-zA-Z-]+)*))?$/u
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u
|
||||
const keyIdPattern = /^[a-z0-9][a-z0-9._-]{0,63}$/u
|
||||
const canonicalBase64Pattern =
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u
|
||||
|
||||
export const agentArchitectureSchema = z.enum(['x64', 'arm64'])
|
||||
|
||||
export const componentVersionSchema = z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(versionPattern)
|
||||
|
||||
export type AgentArchitecture = z.infer<
|
||||
typeof agentArchitectureSchema
|
||||
>
|
||||
|
||||
export const agentProtocolVersionSchema = z
|
||||
.object({
|
||||
major: z.number().int().min(0).max(65_535).safe(),
|
||||
minor: z.number().int().min(0).max(65_535).safe()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const agentManifestPathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(240)
|
||||
.refine(isSafeAgentManifestPath, 'Unsafe Agent manifest path')
|
||||
|
||||
export const agentManifestFileSchema = z
|
||||
.object({
|
||||
path: agentManifestPathSchema,
|
||||
size: z.number().int().min(0).safe(),
|
||||
sha256: z.string().regex(sha256Pattern),
|
||||
mode: z.enum(['0644', '0755'])
|
||||
})
|
||||
.strict()
|
||||
.superRefine((file, context) => {
|
||||
const expectedMode = expectedAgentManifestMode(file.path)
|
||||
if (file.mode !== expectedMode) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['mode'],
|
||||
message: `Agent payload mode must be ${expectedMode}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type AgentManifestFile = z.infer<
|
||||
typeof agentManifestFileSchema
|
||||
>
|
||||
|
||||
export const agentManifestLicenseSchema = z
|
||||
.object({
|
||||
package: z.string().trim().min(1).max(200),
|
||||
version: z.string().trim().min(1).max(128),
|
||||
spdx: z.string().trim().min(1).max(128),
|
||||
path: agentManifestPathSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const agentBundleManifestSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
product: z.literal('GoodBuddy'),
|
||||
agentVersion: componentVersionSchema,
|
||||
platform: z.literal('linux'),
|
||||
arch: agentArchitectureSchema,
|
||||
protocol: agentProtocolVersionSchema,
|
||||
signingKeyId: z.string().regex(keyIdPattern),
|
||||
entrypoint: z
|
||||
.object({
|
||||
path: z.literal('goodbuddy-agent'),
|
||||
runtimePath: z.literal('node'),
|
||||
scriptPath: z.literal('lib/agent.cjs')
|
||||
})
|
||||
.strict(),
|
||||
files: z.array(agentManifestFileSchema).min(1).max(10_000),
|
||||
licenses: z.array(agentManifestLicenseSchema).min(1).max(1_000)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((manifest, context) => {
|
||||
const declaredPaths = new Set<string>()
|
||||
manifest.files.forEach((file, index) => {
|
||||
if (
|
||||
file.path === 'manifest.json' ||
|
||||
file.path === 'manifest.sig'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files', index, 'path'],
|
||||
message: 'Agent payload cannot declare signature metadata'
|
||||
})
|
||||
}
|
||||
if (declaredPaths.has(file.path)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files', index, 'path'],
|
||||
message: 'Agent manifest file paths must be unique'
|
||||
})
|
||||
}
|
||||
declaredPaths.add(file.path)
|
||||
})
|
||||
|
||||
const licensedPaths = new Set<string>()
|
||||
manifest.licenses.forEach((license, index) => {
|
||||
if (licensedPaths.has(license.path)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['licenses', index, 'path'],
|
||||
message: 'Agent manifest license paths must be unique'
|
||||
})
|
||||
}
|
||||
if (!declaredPaths.has(license.path)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['licenses', index, 'path'],
|
||||
message: 'Agent manifest license must name a declared file'
|
||||
})
|
||||
}
|
||||
licensedPaths.add(license.path)
|
||||
})
|
||||
|
||||
for (const path of [
|
||||
manifest.entrypoint.path,
|
||||
manifest.entrypoint.runtimePath,
|
||||
manifest.entrypoint.scriptPath
|
||||
]) {
|
||||
if (!declaredPaths.has(path)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['entrypoint'],
|
||||
message: `Agent entrypoint payload is missing: ${path}`
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type AgentBundleManifest = z.infer<
|
||||
typeof agentBundleManifestSchema
|
||||
>
|
||||
|
||||
export const agentReleaseKeySchema = z
|
||||
.object({
|
||||
keyId: z.string().regex(keyIdPattern),
|
||||
publicKeySpkiBase64: z
|
||||
.string()
|
||||
.min(4)
|
||||
.max(4_096)
|
||||
.regex(canonicalBase64Pattern),
|
||||
environment: z.enum(['production', 'test'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type AgentReleaseKey = z.infer<typeof agentReleaseKeySchema>
|
||||
|
||||
export const agentReleaseKeyRevocationSchema = z
|
||||
.object({
|
||||
keyId: z.string().regex(keyIdPattern)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const agentReleaseKeyRegistrySchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
keys: z.array(agentReleaseKeySchema).max(1_000),
|
||||
revocations: z.array(agentReleaseKeyRevocationSchema).max(1_000)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((registry, context) => {
|
||||
addDuplicateKeyIssues(
|
||||
registry.keys,
|
||||
'keys',
|
||||
context
|
||||
)
|
||||
addDuplicateKeyIssues(
|
||||
registry.revocations,
|
||||
'revocations',
|
||||
context
|
||||
)
|
||||
})
|
||||
|
||||
export type AgentReleaseKeyRegistry = z.infer<
|
||||
typeof agentReleaseKeyRegistrySchema
|
||||
>
|
||||
|
||||
const lockedRuntimePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.refine(isSafeAgentManifestPath, 'Unsafe locked runtime path')
|
||||
|
||||
const lockedNodeTargetSchema = z
|
||||
.object({
|
||||
archive: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(240)
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.includes('/') &&
|
||||
!value.includes('\\') &&
|
||||
value !== '.' &&
|
||||
value !== '..',
|
||||
'Locked Node archive name is invalid'
|
||||
),
|
||||
sha256: z.string().regex(sha256Pattern),
|
||||
binaryPath: lockedRuntimePathSchema,
|
||||
licensePath: lockedRuntimePathSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const agentRuntimeLockSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
agentVersion: componentVersionSchema,
|
||||
protocol: agentProtocolVersionSchema,
|
||||
node: z
|
||||
.object({
|
||||
version: z.literal('24.19.0'),
|
||||
source: z
|
||||
.url()
|
||||
.refine(
|
||||
(value) => new URL(value).protocol === 'https:',
|
||||
'Locked Node source must use HTTPS'
|
||||
),
|
||||
targets: z
|
||||
.object({
|
||||
'linux-x64': lockedNodeTargetSchema,
|
||||
'linux-arm64': lockedNodeTargetSchema
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
.strict(),
|
||||
koffi: z
|
||||
.object({
|
||||
version: z.literal('3.1.4')
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type AgentRuntimeLock = z.infer<typeof agentRuntimeLockSchema>
|
||||
|
||||
export function isSafeAgentManifestPath(filePath: string): boolean {
|
||||
return (
|
||||
filePath.length > 0 &&
|
||||
filePath.length <= 512 &&
|
||||
!filePath.includes('\\') &&
|
||||
!filePath.includes('\0') &&
|
||||
!filePath.startsWith('/') &&
|
||||
!filePath
|
||||
.split('/')
|
||||
.some(
|
||||
(part) => part.length === 0 || part === '.' || part === '..'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function expectedAgentManifestMode(
|
||||
filePath: string
|
||||
): '0644' | '0755' {
|
||||
return filePath === 'node' ||
|
||||
filePath === 'goodbuddy-agent' ||
|
||||
filePath.startsWith('helpers/')
|
||||
? '0755'
|
||||
: '0644'
|
||||
}
|
||||
|
||||
function addDuplicateKeyIssues(
|
||||
entries: readonly { keyId: string }[],
|
||||
path: 'keys' | 'revocations',
|
||||
context: z.RefinementCtx
|
||||
): void {
|
||||
const ids = new Set<string>()
|
||||
entries.forEach((entry, index) => {
|
||||
if (ids.has(entry.keyId)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: [path, index, 'keyId'],
|
||||
message: `Agent ${path} key IDs must be unique`
|
||||
})
|
||||
}
|
||||
ids.add(entry.keyId)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
conversationBranchInputSchema,
|
||||
conversationSnapshotSchema,
|
||||
isUntouchedBuiltInDefaultProject,
|
||||
persistedProjectExecutionSpaceSchema,
|
||||
projectCreateSchema,
|
||||
projectExecutionSpaceSchema,
|
||||
projectRuntimeValidationSchema,
|
||||
sshExecutionValidationSchema,
|
||||
projectUpdateSchema
|
||||
} from './assistant-contracts'
|
||||
|
||||
@@ -15,6 +19,10 @@ const untouchedProject: AssistantProject = {
|
||||
name: builtInDefaultProjectSeedName,
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
rootPath: 'C:\\Workspace',
|
||||
executionSpace: {
|
||||
kind: 'local',
|
||||
rootPath: 'C:\\Workspace'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
kind: 'user',
|
||||
builtInDefault: true,
|
||||
@@ -75,6 +83,188 @@ describe('isUntouchedBuiltInDefaultProject', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('project execution space contracts', () => {
|
||||
it('parses strict local and SSH output without normalizing paths', () => {
|
||||
expect(
|
||||
projectExecutionSpaceSchema.parse({
|
||||
kind: 'local',
|
||||
rootPath: ''
|
||||
})
|
||||
).toEqual({ kind: 'local', rootPath: '' })
|
||||
expect(
|
||||
projectExecutionSpaceSchema.parse({
|
||||
kind: 'local',
|
||||
rootPath: ' '
|
||||
})
|
||||
).toEqual({ kind: 'local', rootPath: ' ' })
|
||||
expect(
|
||||
projectExecutionSpaceSchema.parse({
|
||||
kind: 'ssh',
|
||||
hostId: '00000000-0000-4000-8000-000000000301',
|
||||
remoteRootPath: '/srv/goodbuddy'
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'ssh',
|
||||
hostId: '00000000-0000-4000-8000-000000000301',
|
||||
remoteRootPath: '/srv/goodbuddy'
|
||||
})
|
||||
|
||||
expect(
|
||||
projectExecutionSpaceSchema.safeParse({
|
||||
kind: 'local',
|
||||
rootPath: 'C:\\Workspace',
|
||||
hostId: '00000000-0000-4000-8000-000000000301'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
projectExecutionSpaceSchema.safeParse({
|
||||
kind: 'ssh',
|
||||
hostId: 'not-a-host-id',
|
||||
remoteRootPath: '/srv/goodbuddy'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
projectExecutionSpaceSchema.safeParse({
|
||||
kind: 'ssh',
|
||||
hostId: '00000000-0000-4000-8000-000000000301',
|
||||
remoteRootPath: ' '
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps SSH and Runtime validation identities separate and strict', () => {
|
||||
const validatedAt = '2026-08-21T00:00:00.000Z'
|
||||
const validation = {
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'builder',
|
||||
workspaceIdentity: 'workspace-identity',
|
||||
agentProtocolMajor: 1,
|
||||
agentInstallationIdAtValidation: 'agent-installation',
|
||||
agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`,
|
||||
agentVersionAtValidation: '0.11.0',
|
||||
agentArchitectureAtValidation: 'x64',
|
||||
validatedAt
|
||||
}
|
||||
const runtimeValidation = {
|
||||
runtimeSelectionKey: 'opencode:profile',
|
||||
runtimeBundleDigest: `sha256:${'b'.repeat(64)}`,
|
||||
runtimeAdapterDigest: `sha256:${'c'.repeat(64)}`,
|
||||
agentInstallationIdAtValidation: 'agent-installation',
|
||||
validatedAt,
|
||||
workMode: 'execute'
|
||||
}
|
||||
expect(
|
||||
sshExecutionValidationSchema.safeParse(validation).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
projectRuntimeValidationSchema.safeParse(runtimeValidation)
|
||||
.success
|
||||
).toBe(true)
|
||||
expect(
|
||||
persistedProjectExecutionSpaceSchema.parse({
|
||||
kind: 'ssh',
|
||||
hostId: '00000000-0000-4000-8000-000000000301',
|
||||
remoteRootPath: '/srv/goodbuddy',
|
||||
validation
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'ssh',
|
||||
hostId: '00000000-0000-4000-8000-000000000301',
|
||||
remoteRootPath: '/srv/goodbuddy',
|
||||
validation
|
||||
})
|
||||
expect(
|
||||
sshExecutionValidationSchema.safeParse({
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'builder',
|
||||
workspaceIdentity: 'workspace-identity',
|
||||
agentProtocolMajor: 1,
|
||||
agentInstallationIdAtValidation: 'agent-installation',
|
||||
agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`,
|
||||
agentVersionAtValidation: '0.11.0',
|
||||
agentArchitectureAtValidation: 'x64',
|
||||
validatedAt,
|
||||
runtimeBundleDigest: 'must-not-be-here'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
persistedProjectExecutionSpaceSchema.safeParse({
|
||||
kind: 'local',
|
||||
rootPath: 'C:\\Workspace',
|
||||
validation
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
persistedProjectExecutionSpaceSchema.safeParse({
|
||||
kind: 'ssh',
|
||||
hostId: '00000000-0000-4000-8000-000000000301',
|
||||
remoteRootPath: '/srv/goodbuddy',
|
||||
hostRevision: 2
|
||||
}).success
|
||||
).toBe(false)
|
||||
|
||||
for (const invalid of [
|
||||
{ ...validation, hostRevision: 0 },
|
||||
{ ...validation, trustAttestationRevision: 4 },
|
||||
{
|
||||
...validation,
|
||||
agentBinaryDigestAtValidation: `sha256:${'A'.repeat(64)}`
|
||||
},
|
||||
{ ...validation, agentVersionAtValidation: ' version ' },
|
||||
{ ...validation, agentArchitectureAtValidation: 'ia32' }
|
||||
]) {
|
||||
expect(
|
||||
sshExecutionValidationSchema.safeParse(invalid).success
|
||||
).toBe(false)
|
||||
}
|
||||
|
||||
for (const invalid of [
|
||||
{
|
||||
...runtimeValidation,
|
||||
runtimeBundleDigest: 'runtime-digest'
|
||||
},
|
||||
{ ...runtimeValidation, trustTier: 'T3' },
|
||||
{
|
||||
...runtimeValidation,
|
||||
confinementPolicyDigest: `sha256:${'d'.repeat(64)}`
|
||||
},
|
||||
{ ...runtimeValidation, approvalBridgeVersion: '1' },
|
||||
{
|
||||
...runtimeValidation,
|
||||
workMode: 'plan'
|
||||
}
|
||||
]) {
|
||||
expect(
|
||||
projectRuntimeValidationSchema.safeParse(invalid).success
|
||||
).toBe(false)
|
||||
}
|
||||
expect(
|
||||
projectRuntimeValidationSchema.safeParse({
|
||||
...runtimeValidation,
|
||||
workMode: 'ask'
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps renderer project create and update inputs local-only', () => {
|
||||
const input = {
|
||||
name: '远程草稿',
|
||||
description: '',
|
||||
rootPath: '/srv/goodbuddy',
|
||||
defaultWorkMode: 'ask',
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId: '00000000-0000-4000-8000-000000000301',
|
||||
remoteRootPath: '/srv/goodbuddy'
|
||||
}
|
||||
}
|
||||
expect(projectCreateSchema.safeParse(input).success).toBe(false)
|
||||
expect(projectUpdateSchema.safeParse(input).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation branch contracts', () => {
|
||||
it('accepts bounded branch provenance and rejects renderer extras', () => {
|
||||
const sourceConversationId =
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { agentArchitectureSchema } from './agent-installation-contracts'
|
||||
import { sha256DigestSchema } from './agent-protocol/contracts'
|
||||
import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
|
||||
import { sshHostIdSchema } from './ssh-host-contracts'
|
||||
|
||||
export const assistantIdSchema = z.string().uuid()
|
||||
export const interactiveWorkModes = ['ask', 'execute'] as const
|
||||
@@ -28,6 +31,86 @@ export type InteractiveWorkMode = (typeof interactiveWorkModes)[number]
|
||||
export type ProjectKind = z.infer<typeof projectKindSchema>
|
||||
export type ProjectChannel = z.infer<typeof projectChannelSchema>
|
||||
|
||||
const projectExecutionSpacePathSchema = z.string().max(4_096)
|
||||
const projectExecutionValidationTextSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1_000)
|
||||
.refine((value) => value.trim().length > 0)
|
||||
|
||||
export const projectExecutionSpaceSchema = z.discriminatedUnion(
|
||||
'kind',
|
||||
[
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('local'),
|
||||
rootPath: projectExecutionSpacePathSchema
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('ssh'),
|
||||
hostId: sshHostIdSchema,
|
||||
remoteRootPath: projectExecutionSpacePathSchema.refine(
|
||||
(value) => value.trim().length > 0,
|
||||
'远程项目目录不能为空'
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
]
|
||||
)
|
||||
|
||||
export const sshExecutionValidationSchema = z
|
||||
.object({
|
||||
hostRevision: z.number().int().positive(),
|
||||
hostKeyGeneration: z.number().int().positive(),
|
||||
remoteUsername: projectExecutionValidationTextSchema,
|
||||
workspaceIdentity: projectExecutionValidationTextSchema,
|
||||
agentProtocolMajor: z.number().int().positive(),
|
||||
agentInstallationIdAtValidation:
|
||||
projectExecutionValidationTextSchema,
|
||||
agentBinaryDigestAtValidation: sha256DigestSchema,
|
||||
agentVersionAtValidation: z
|
||||
.string()
|
||||
.regex(/^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$/u),
|
||||
agentArchitectureAtValidation: agentArchitectureSchema,
|
||||
validatedAt: z.string().datetime({ offset: true })
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const projectRuntimeValidationSchema = z
|
||||
.object({
|
||||
runtimeSelectionKey: z.string().trim().min(1).max(1_000),
|
||||
runtimeBundleDigest: sha256DigestSchema,
|
||||
runtimeAdapterDigest: sha256DigestSchema,
|
||||
agentInstallationIdAtValidation:
|
||||
projectExecutionValidationTextSchema,
|
||||
validatedAt: z.string().datetime({ offset: true }),
|
||||
workMode: workModeSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SshExecutionValidation = z.output<
|
||||
typeof sshExecutionValidationSchema
|
||||
>
|
||||
export type ProjectRuntimeValidation = z.output<
|
||||
typeof projectRuntimeValidationSchema
|
||||
>
|
||||
|
||||
export const persistedProjectExecutionSpaceSchema =
|
||||
z.discriminatedUnion('kind', [
|
||||
projectExecutionSpaceSchema.options[0],
|
||||
projectExecutionSpaceSchema.options[1]
|
||||
.extend({
|
||||
validation: sshExecutionValidationSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
export type ProjectExecutionSpace = z.output<
|
||||
typeof persistedProjectExecutionSpaceSchema
|
||||
>
|
||||
|
||||
export function normalizeInteractiveWorkMode(
|
||||
workMode: LegacyWorkMode | undefined
|
||||
): InteractiveWorkMode {
|
||||
@@ -429,6 +512,8 @@ export type LocalConversationSaveBatch = z.infer<
|
||||
export type AssistantProject = ProjectCreateInput & {
|
||||
id: string
|
||||
kind: ProjectKind
|
||||
executionSpace: ProjectExecutionSpace
|
||||
runtimeValidation?: ProjectRuntimeValidation
|
||||
channel?: ProjectChannel
|
||||
readonly builtInDefault?: boolean
|
||||
status: 'active' | 'archived'
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentRequestSchema,
|
||||
builtinEmbeddingConnectionId,
|
||||
legacyEmbeddingConnectionId,
|
||||
runtimeSettingsInputSchema
|
||||
} from './contracts'
|
||||
|
||||
const baseInput = {
|
||||
provider: 'model' as const,
|
||||
modelBaseUrl: 'https://bigtoken.ai',
|
||||
modelName: 'sonnet-5',
|
||||
modelProtocol: 'anthropic-messages' as const,
|
||||
modelAuthentication: 'api-key' as const,
|
||||
imageGenerationQuality: 'auto' as const,
|
||||
opencodeBaseUrl: '',
|
||||
opencodeEmbedded: true,
|
||||
opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '',
|
||||
continueBinaryPath: '',
|
||||
continueConfigPath: '',
|
||||
continueMode: 'chat' as const,
|
||||
knowledgeEmbeddingEnabled: true,
|
||||
knowledgeEmbeddingBaseUrl: 'https://vectors.example/v1/embeddings',
|
||||
knowledgeEmbeddingModel: 'embed-large',
|
||||
knowledgeRerankEnabled: false,
|
||||
knowledgeRerankEndpoint: 'https://rerank.example/v1/rerank',
|
||||
knowledgeRerankModel: 'rerank-v3.5',
|
||||
workspacePath: 'workspace',
|
||||
apiKey: { action: 'keep' as const },
|
||||
deepseekHarnessModelSource: { kind: 'platform' as const },
|
||||
toolApproval: 'always' as const
|
||||
}
|
||||
|
||||
describe('embedding connection settings input', () => {
|
||||
it('accepts additive builtin and user connection updates', () => {
|
||||
expect(
|
||||
runtimeSettingsInputSchema.safeParse({
|
||||
...baseInput,
|
||||
embeddingConnections: [
|
||||
{
|
||||
id: builtinEmbeddingConnectionId,
|
||||
name: 'GoodBuddy 内置向量模型',
|
||||
kind: 'builtin'
|
||||
},
|
||||
{
|
||||
id: legacyEmbeddingConnectionId,
|
||||
name: '自定义向量模型',
|
||||
kind: 'openai-compatible',
|
||||
baseUrl: 'https://vectors.example/v1/embeddings',
|
||||
modelName: 'embed-large',
|
||||
authentication: 'api-key',
|
||||
apiKey: { action: 'keep' }
|
||||
}
|
||||
],
|
||||
activeEmbeddingConnectionId: legacyEmbeddingConnectionId
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects missing builtin, dangling active IDs, and keys on no-auth connections', () => {
|
||||
const connection = {
|
||||
id: legacyEmbeddingConnectionId,
|
||||
name: '自定义向量模型',
|
||||
kind: 'openai-compatible' as const,
|
||||
baseUrl: 'https://vectors.example/v1/embeddings',
|
||||
modelName: 'embed-large',
|
||||
authentication: 'none' as const,
|
||||
apiKey: { action: 'replace' as const, value: 'secret' }
|
||||
}
|
||||
expect(
|
||||
runtimeSettingsInputSchema.safeParse({
|
||||
...baseInput,
|
||||
embeddingConnections: [connection],
|
||||
activeEmbeddingConnectionId: crypto.randomUUID()
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps embedding configuration and vectors outside Agent Runtime requests', () => {
|
||||
const request = {
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: 'conversation',
|
||||
prompt: 'hello',
|
||||
knowledgeLibraryIds: [],
|
||||
knowledgeRetrievalMode: 'auto' as const
|
||||
}
|
||||
expect(agentRequestSchema.safeParse(request).success).toBe(true)
|
||||
for (const forbidden of [
|
||||
{ embeddingApiKey: 'secret' },
|
||||
{ embeddingConfiguration: { endpoint: 'https://vectors.example' } },
|
||||
{ embeddingVectors: [[0.1, 0.2]] }
|
||||
]) {
|
||||
expect(
|
||||
agentRequestSchema.safeParse({ ...request, ...forbidden }).success
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -136,6 +136,10 @@ import type { KnowledgeOntologySettings } from './knowledge-ontology'
|
||||
import type {
|
||||
KnowledgeTaskItem
|
||||
} from './knowledge-task-contracts'
|
||||
import type {
|
||||
RemoteProjectSaveProgress,
|
||||
RemoteProjectSaveRequest
|
||||
} from './remote-project-candidate-contracts'
|
||||
export type {
|
||||
KnowledgeTaskError,
|
||||
KnowledgeTaskItem,
|
||||
@@ -144,8 +148,24 @@ export type {
|
||||
KnowledgeTaskStage,
|
||||
KnowledgeTaskStatus
|
||||
} from './knowledge-task-contracts'
|
||||
export type {
|
||||
RemoteProjectCreateDraft,
|
||||
RemoteProjectSavePhase,
|
||||
RemoteProjectSaveProgress,
|
||||
RemoteProjectSaveRequest,
|
||||
RemoteProjectUpdateDraft
|
||||
} from './remote-project-candidate-contracts'
|
||||
import type { WeixinBindingSnapshot } from './weixin-channel-contracts'
|
||||
import type { RemoteChannelActivity } from './remote-channel-contracts'
|
||||
import type {
|
||||
SshHostDraftInspectionRequest,
|
||||
SshHostKeyInspection,
|
||||
SshHostRemoteEnvironment,
|
||||
SshHostsSnapshot,
|
||||
SshDirectoryBrowseResult,
|
||||
SshHostValidationRequest,
|
||||
SshHostValidationResult
|
||||
} from './ssh-host-contracts'
|
||||
import {
|
||||
agentRuntimeSelectionSchema,
|
||||
type AgentRuntimeSelection
|
||||
@@ -1522,6 +1542,25 @@ export type DesktopApi = {
|
||||
selection: AgentRuntimeSelection
|
||||
) => Promise<AgentRuntimeStatus>
|
||||
}
|
||||
sshHosts?: {
|
||||
getSnapshot: () => Promise<SshHostsSnapshot>
|
||||
remove: (hostId: string) => Promise<void>
|
||||
inspectDraftHostKey: (
|
||||
input: SshHostDraftInspectionRequest
|
||||
) => Promise<SshHostKeyInspection>
|
||||
discardCandidate: (candidateId: string) => Promise<void>
|
||||
validateAndSave: (
|
||||
input: SshHostValidationRequest
|
||||
) => Promise<SshHostValidationResult>
|
||||
getRemoteEnvironment: (
|
||||
hostId: string
|
||||
) => Promise<SshHostRemoteEnvironment>
|
||||
browseDirectories: (
|
||||
hostId: string,
|
||||
path?: string
|
||||
) => Promise<SshDirectoryBrowseResult>
|
||||
cancelDirectoryBrowse: () => Promise<void>
|
||||
}
|
||||
channels?: {
|
||||
getSnapshot: () => Promise<ChannelSettingsSnapshot>
|
||||
apply: (input: ChannelSettingsApply) => Promise<ChannelSettingsSnapshot>
|
||||
@@ -1648,6 +1687,16 @@ export type DesktopApi = {
|
||||
) => Promise<AssistantProject>
|
||||
setArchived: (projectId: string, archived: boolean) => Promise<void>
|
||||
delete: (projectId: string, confirmation: string) => Promise<void>
|
||||
remote: {
|
||||
activate: (projectId: string) => Promise<AssistantProject>
|
||||
save: (
|
||||
input: RemoteProjectSaveRequest
|
||||
) => Promise<AssistantProject>
|
||||
cancelCurrent: () => Promise<void>
|
||||
onSaveProgress: (
|
||||
listener: (progress: RemoteProjectSaveProgress) => void
|
||||
) => () => void
|
||||
}
|
||||
}
|
||||
conversations: {
|
||||
list: () => Promise<ConversationSnapshot[]>
|
||||
|
||||
@@ -34,6 +34,19 @@ export const ipcChannels = {
|
||||
runtimeCustomizationGet: 'settings:runtime-customization:get',
|
||||
runtimeCustomizationUpdate: 'settings:runtime-customization:update',
|
||||
runtimeNativeSnapshot: 'settings:runtime-native:snapshot',
|
||||
sshHostsGet: 'settings:ssh-hosts:get',
|
||||
sshHostsRemove: 'settings:ssh-hosts:remove',
|
||||
sshHostsInspectDraftKey: 'settings:ssh-hosts:inspect-draft-key',
|
||||
sshHostsDiscardCandidate:
|
||||
'settings:ssh-hosts:discard-candidate',
|
||||
sshHostsValidateAndSave:
|
||||
'settings:ssh-hosts:validate-and-save',
|
||||
sshHostsRemoteEnvironment:
|
||||
'settings:ssh-hosts:remote-environment',
|
||||
sshHostsBrowseDirectories:
|
||||
'settings:ssh-hosts:directories:browse',
|
||||
sshHostsCancelDirectoryBrowse:
|
||||
'settings:ssh-hosts:directories:cancel',
|
||||
channelSettingsGet: 'settings:channels:get',
|
||||
channelSettingsApply: 'settings:channels:apply',
|
||||
channelSettingsTest: 'settings:channels:test',
|
||||
@@ -98,6 +111,10 @@ export const ipcChannels = {
|
||||
projectsUpdate: 'projects:update',
|
||||
projectsSetArchived: 'projects:set-archived',
|
||||
projectsDelete: 'projects:delete',
|
||||
remoteProjectActivate: 'projects:remote:activate',
|
||||
remoteProjectSave: 'projects:remote:save',
|
||||
remoteProjectCancelCurrent: 'projects:remote:cancel-current',
|
||||
remoteProjectSaveProgress: 'projects:remote:save-progress',
|
||||
conversationsList: 'conversations:list',
|
||||
conversationsReplace: 'conversations:replace',
|
||||
conversationsSaveLocal: 'conversations:save-local',
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MODEL_BRIDGE_LIMITS,
|
||||
MODEL_BRIDGE_PROTOCOL,
|
||||
ModelBridgeCodecError,
|
||||
createModelBridgeRequestMessage,
|
||||
decodeModelBridgeMessage,
|
||||
digestModelBridgeRequest,
|
||||
encodeModelBridgeMessage,
|
||||
modelBridgeErrorMessageSchema,
|
||||
modelBridgeDeliveryAckMessageSchema,
|
||||
modelBridgeIdentitySchema,
|
||||
modelBridgePolicySchema,
|
||||
type ModelBridgeIdentity
|
||||
} from './model-bridge-contracts'
|
||||
import { remoteModelGatewayRequestSchema } from './remote-model-gateway-contracts'
|
||||
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
const identity: ModelBridgeIdentity = {
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
requestId: 'request-1',
|
||||
roundIndex: 2,
|
||||
modelProfileDigest: digest,
|
||||
messageId: 'message-1'
|
||||
}
|
||||
const policy = {
|
||||
protocol: 'anthropic-messages',
|
||||
model: 'claude-test',
|
||||
modelProfileDigest: digest,
|
||||
supportsImageInput: true
|
||||
} as const
|
||||
const request = {
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'claude-test',
|
||||
messages: [{ role: 'user', content: '你好' }]
|
||||
})
|
||||
).toString('base64')
|
||||
} as const
|
||||
|
||||
describe('model bridge contracts and codec', () => {
|
||||
it('round trips one bounded request message with an exact digest', async () => {
|
||||
const message = await createModelBridgeRequestMessage({
|
||||
identity,
|
||||
policy,
|
||||
request
|
||||
})
|
||||
expect(message.requestDigest).toBe(
|
||||
await digestModelBridgeRequest(request)
|
||||
)
|
||||
|
||||
const payload = await encodeModelBridgeMessage(message)
|
||||
expect(payload.byteLength).toBeLessThanOrEqual(
|
||||
MODEL_BRIDGE_LIMITS.maximumMessageBytes
|
||||
)
|
||||
await expect(
|
||||
decodeModelBridgeMessage(payload, { expectedIdentity: identity })
|
||||
).resolves.toEqual(message)
|
||||
})
|
||||
|
||||
it('uses representation safety rather than a fixed round quota', () => {
|
||||
expect(
|
||||
modelBridgeIdentitySchema.parse({
|
||||
...identity,
|
||||
roundIndex: 100_001
|
||||
}).roundIndex
|
||||
).toBe(100_001)
|
||||
})
|
||||
|
||||
it('deterministically encodes a large request as one message', async () => {
|
||||
const message = await createModelBridgeRequestMessage({
|
||||
identity,
|
||||
policy,
|
||||
request: {
|
||||
...request,
|
||||
bodyBase64: Buffer.alloc(600 * 1024, 7).toString('base64')
|
||||
}
|
||||
})
|
||||
const left = await encodeModelBridgeMessage(message)
|
||||
const right = await encodeModelBridgeMessage(message)
|
||||
|
||||
expect(left).toEqual(right)
|
||||
expect(left.byteLength).toBeGreaterThan(600 * 1024)
|
||||
await expect(decodeModelBridgeMessage(left)).resolves.toEqual(message)
|
||||
})
|
||||
|
||||
it('round trips response, error, and delivery ACK messages', async () => {
|
||||
const requestDigest = await digestModelBridgeRequest(request)
|
||||
const response = {
|
||||
protocol: MODEL_BRIDGE_PROTOCOL,
|
||||
kind: 'response',
|
||||
identity,
|
||||
requestDigest,
|
||||
response: {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: Buffer.from('{"ok":true}').toString('base64')
|
||||
}
|
||||
} as const
|
||||
await expect(
|
||||
decodeModelBridgeMessage(await encodeModelBridgeMessage(response))
|
||||
).resolves.toEqual(response)
|
||||
|
||||
const error = modelBridgeErrorMessageSchema.parse({
|
||||
protocol: MODEL_BRIDGE_PROTOCOL,
|
||||
kind: 'error',
|
||||
identity,
|
||||
requestDigest,
|
||||
error: {
|
||||
code: 'dispatch-outcome-unknown',
|
||||
message: 'Provider dispatch outcome cannot be established',
|
||||
retryable: false,
|
||||
poisoned: true,
|
||||
outcomeUnknown: true
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
decodeModelBridgeMessage(await encodeModelBridgeMessage(error))
|
||||
).resolves.toEqual(error)
|
||||
|
||||
const delivery = modelBridgeDeliveryAckMessageSchema.parse({
|
||||
protocol: MODEL_BRIDGE_PROTOCOL,
|
||||
kind: 'response-delivered',
|
||||
identity,
|
||||
requestDigest
|
||||
})
|
||||
await expect(
|
||||
decodeModelBridgeMessage(await encodeModelBridgeMessage(delivery))
|
||||
).resolves.toEqual(delivery)
|
||||
})
|
||||
|
||||
it('rejects malformed, noncanonical, and oversized messages', async () => {
|
||||
await expect(
|
||||
decodeModelBridgeMessage(new Uint8Array([0xff]))
|
||||
).rejects.toBeInstanceOf(ModelBridgeCodecError)
|
||||
await expect(
|
||||
decodeModelBridgeMessage(Buffer.from('{ "kind": "request" }'))
|
||||
).rejects.toMatchObject({ code: 'invalid-message' })
|
||||
|
||||
const message = await createModelBridgeRequestMessage({
|
||||
identity,
|
||||
policy,
|
||||
request
|
||||
})
|
||||
const payload = await encodeModelBridgeMessage(message)
|
||||
await expect(
|
||||
decodeModelBridgeMessage(payload, { maximumMessageBytes: 10 })
|
||||
).rejects.toMatchObject({ code: 'oversized' })
|
||||
await expect(
|
||||
decodeModelBridgeMessage(
|
||||
Buffer.alloc(MODEL_BRIDGE_LIMITS.maximumMessageBytes + 1)
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'oversized' })
|
||||
})
|
||||
|
||||
it('rejects mismatched identities and request digests', async () => {
|
||||
const message = await createModelBridgeRequestMessage({
|
||||
identity,
|
||||
policy,
|
||||
request
|
||||
})
|
||||
const payload = await encodeModelBridgeMessage(message)
|
||||
await expect(
|
||||
decodeModelBridgeMessage(payload, {
|
||||
expectedIdentity: { ...identity, roundIndex: 3 }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'identity-mismatch' })
|
||||
await expect(
|
||||
decodeModelBridgeMessage(payload, {
|
||||
expectedRequestDigest: `sha256:${'b'.repeat(64)}`
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'digest-mismatch' })
|
||||
|
||||
await expect(
|
||||
encodeModelBridgeMessage({
|
||||
...message,
|
||||
requestDigest: `sha256:${'b'.repeat(64)}`
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'digest-mismatch' })
|
||||
expect(() =>
|
||||
modelBridgePolicySchema.parse({
|
||||
...policy,
|
||||
baseUrl: 'https://provider.example',
|
||||
apiKey: 'must-not-cross'
|
||||
})
|
||||
).toThrow()
|
||||
expect(
|
||||
modelBridgePolicySchema.parse({
|
||||
...policy,
|
||||
maximumOutputTokens: 65_537,
|
||||
maximumModelCalls: 8,
|
||||
maximumTotalOutputTokens: 32_768
|
||||
})
|
||||
).toMatchObject(policy)
|
||||
await expect(
|
||||
createModelBridgeRequestMessage({
|
||||
identity,
|
||||
policy: {
|
||||
...policy,
|
||||
modelProfileDigest: `sha256:${'b'.repeat(64)}`
|
||||
},
|
||||
request
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects noncanonical base64 and protocol path mismatches', async () => {
|
||||
expect(() =>
|
||||
remoteModelGatewayRequestSchema.parse({
|
||||
...request,
|
||||
bodyBase64: 'AB=='
|
||||
})
|
||||
).toThrow()
|
||||
await expect(
|
||||
createModelBridgeRequestMessage({
|
||||
identity,
|
||||
policy,
|
||||
request: { ...request, path: '/v1/responses' }
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,403 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
agentIdentifierSchema,
|
||||
sha256DigestSchema,
|
||||
utf8StringSchema
|
||||
} from './agent-protocol/contracts'
|
||||
import { canonicalJson } from './agent-protocol/canonical'
|
||||
import {
|
||||
REMOTE_MODEL_GATEWAY_LIMITS,
|
||||
remoteModelGatewayRequestSchema,
|
||||
remoteModelGatewayResponseSchema
|
||||
} from './remote-model-gateway-contracts'
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
const fatalTextDecoder = new TextDecoder('utf-8', { fatal: true })
|
||||
|
||||
export const MODEL_BRIDGE_LIMITS = {
|
||||
maximumMessageBytes:
|
||||
Math.ceil(
|
||||
Math.max(
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes,
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes
|
||||
) / 3
|
||||
) *
|
||||
4 +
|
||||
16 * 1024,
|
||||
maximumErrorMessageBytes: 2_048,
|
||||
maximumModelNameBytes: 256
|
||||
} as const
|
||||
|
||||
export const MODEL_BRIDGE_PROTOCOL = 'goodbuddy-model-bridge-v1' as const
|
||||
|
||||
export const modelBridgeModelProtocolSchema = z.enum([
|
||||
'anthropic-messages',
|
||||
'openai-responses',
|
||||
'openai-chat-completions'
|
||||
])
|
||||
export type ModelBridgeModelProtocol = z.infer<
|
||||
typeof modelBridgeModelProtocolSchema
|
||||
>
|
||||
|
||||
const modelNameSchema = utf8StringSchema(
|
||||
MODEL_BRIDGE_LIMITS.maximumModelNameBytes,
|
||||
{
|
||||
minimumBytes: 1,
|
||||
label: 'Model bridge model name'
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(value) => value.trim() === value,
|
||||
'Model name must not have outer whitespace'
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
Array.from(value).every((character) => {
|
||||
const codePoint = character.codePointAt(0)!
|
||||
return codePoint > 0x1f && codePoint !== 0x7f
|
||||
}),
|
||||
'Model name cannot contain control characters'
|
||||
)
|
||||
|
||||
export const modelBridgePolicySchema = z
|
||||
.object({
|
||||
protocol: modelBridgeModelProtocolSchema,
|
||||
model: modelNameSchema,
|
||||
modelProfileDigest: sha256DigestSchema,
|
||||
// Accepted only so persisted pre-removal bindings can be read and
|
||||
// retired normally. Current code neither emits nor enforces these fields.
|
||||
maximumOutputTokens: z.number().int().min(1).optional(),
|
||||
maximumModelCalls: z.number().int().min(1).optional(),
|
||||
maximumTotalOutputTokens: z.number().int().min(1).optional(),
|
||||
supportsImageInput: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
export type ModelBridgePolicy = z.infer<
|
||||
typeof modelBridgePolicySchema
|
||||
>
|
||||
|
||||
export const modelBridgeIdentitySchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
promptOperationId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema,
|
||||
roundIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER),
|
||||
modelProfileDigest: sha256DigestSchema,
|
||||
messageId: agentIdentifierSchema
|
||||
})
|
||||
.strict()
|
||||
export type ModelBridgeIdentity = z.infer<
|
||||
typeof modelBridgeIdentitySchema
|
||||
>
|
||||
|
||||
const modelBridgeRequestMessageBaseSchema = z
|
||||
.object({
|
||||
protocol: z.literal(MODEL_BRIDGE_PROTOCOL),
|
||||
kind: z.literal('request'),
|
||||
identity: modelBridgeIdentitySchema,
|
||||
policy: modelBridgePolicySchema,
|
||||
requestDigest: sha256DigestSchema,
|
||||
request: remoteModelGatewayRequestSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const modelBridgeRequestMessageSchema =
|
||||
modelBridgeRequestMessageBaseSchema.superRefine((message, context) => {
|
||||
if (
|
||||
message.identity.modelProfileDigest !==
|
||||
message.policy.modelProfileDigest
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['policy', 'modelProfileDigest'],
|
||||
message: 'Policy model profile digest does not match the message identity'
|
||||
})
|
||||
}
|
||||
if (!pathMatchesProtocol(message.policy.protocol, message.request.path)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['request', 'path'],
|
||||
message: 'Provider path does not match the model bridge policy protocol'
|
||||
})
|
||||
}
|
||||
})
|
||||
export type ModelBridgeRequestMessage = z.infer<
|
||||
typeof modelBridgeRequestMessageSchema
|
||||
>
|
||||
|
||||
export const modelBridgeResponseMessageSchema = z
|
||||
.object({
|
||||
protocol: z.literal(MODEL_BRIDGE_PROTOCOL),
|
||||
kind: z.literal('response'),
|
||||
identity: modelBridgeIdentitySchema,
|
||||
requestDigest: sha256DigestSchema,
|
||||
response: remoteModelGatewayResponseSchema
|
||||
})
|
||||
.strict()
|
||||
export type ModelBridgeResponseMessage = z.infer<
|
||||
typeof modelBridgeResponseMessageSchema
|
||||
>
|
||||
|
||||
export const modelBridgeErrorSchema = z
|
||||
.object({
|
||||
code: agentIdentifierSchema,
|
||||
message: utf8StringSchema(
|
||||
MODEL_BRIDGE_LIMITS.maximumErrorMessageBytes,
|
||||
{
|
||||
minimumBytes: 1,
|
||||
label: 'Model bridge error message'
|
||||
}
|
||||
),
|
||||
retryable: z.boolean(),
|
||||
poisoned: z.boolean(),
|
||||
outcomeUnknown: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
export type ModelBridgeError = z.infer<
|
||||
typeof modelBridgeErrorSchema
|
||||
>
|
||||
|
||||
export const modelBridgeErrorMessageSchema = z
|
||||
.object({
|
||||
protocol: z.literal(MODEL_BRIDGE_PROTOCOL),
|
||||
kind: z.literal('error'),
|
||||
identity: modelBridgeIdentitySchema,
|
||||
requestDigest: sha256DigestSchema,
|
||||
error: modelBridgeErrorSchema
|
||||
})
|
||||
.strict()
|
||||
export type ModelBridgeErrorMessage = z.infer<
|
||||
typeof modelBridgeErrorMessageSchema
|
||||
>
|
||||
|
||||
export const modelBridgeDeliveryAckMessageSchema = z
|
||||
.object({
|
||||
protocol: z.literal(MODEL_BRIDGE_PROTOCOL),
|
||||
kind: z.literal('response-delivered'),
|
||||
identity: modelBridgeIdentitySchema,
|
||||
requestDigest: sha256DigestSchema
|
||||
})
|
||||
.strict()
|
||||
export type ModelBridgeDeliveryAckMessage = z.infer<
|
||||
typeof modelBridgeDeliveryAckMessageSchema
|
||||
>
|
||||
|
||||
export const modelBridgeMessageSchema = z.discriminatedUnion('kind', [
|
||||
modelBridgeRequestMessageSchema,
|
||||
modelBridgeResponseMessageSchema,
|
||||
modelBridgeErrorMessageSchema,
|
||||
modelBridgeDeliveryAckMessageSchema
|
||||
])
|
||||
export type ModelBridgeMessage = z.infer<
|
||||
typeof modelBridgeMessageSchema
|
||||
>
|
||||
|
||||
export type ModelBridgeCodecErrorCode =
|
||||
| 'digest-mismatch'
|
||||
| 'identity-mismatch'
|
||||
| 'invalid-message'
|
||||
| 'oversized'
|
||||
|
||||
export class ModelBridgeCodecError extends Error {
|
||||
readonly code: ModelBridgeCodecErrorCode
|
||||
|
||||
constructor(code: ModelBridgeCodecErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'ModelBridgeCodecError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export async function digestModelBridgeRequest(
|
||||
request: unknown
|
||||
): Promise<string> {
|
||||
const parsed = remoteModelGatewayRequestSchema.parse(request)
|
||||
return sha256Digest(
|
||||
canonicalJson({
|
||||
protocol: MODEL_BRIDGE_PROTOCOL,
|
||||
request: parsed
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function createModelBridgeRequestMessage(input: {
|
||||
identity: unknown
|
||||
policy: unknown
|
||||
request: unknown
|
||||
}): Promise<ModelBridgeRequestMessage> {
|
||||
const identity = modelBridgeIdentitySchema.parse(input.identity)
|
||||
const policy = modelBridgePolicySchema.parse(input.policy)
|
||||
const request = remoteModelGatewayRequestSchema.parse(input.request)
|
||||
return modelBridgeRequestMessageSchema.parse({
|
||||
protocol: MODEL_BRIDGE_PROTOCOL,
|
||||
kind: 'request',
|
||||
identity,
|
||||
policy,
|
||||
requestDigest: await digestModelBridgeRequest(request),
|
||||
request
|
||||
})
|
||||
}
|
||||
|
||||
export async function assertModelBridgeRequestDigest(
|
||||
message: ModelBridgeRequestMessage
|
||||
): Promise<void> {
|
||||
const actualDigest = await digestModelBridgeRequest(message.request)
|
||||
if (actualDigest !== message.requestDigest) {
|
||||
throw new ModelBridgeCodecError(
|
||||
'digest-mismatch',
|
||||
'Model bridge request digest does not match the exact request'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function encodeModelBridgeMessage(
|
||||
input: unknown
|
||||
): Promise<Uint8Array> {
|
||||
const message = modelBridgeMessageSchema.parse(input)
|
||||
if (message.kind === 'request') {
|
||||
await assertModelBridgeRequestDigest(message)
|
||||
}
|
||||
const bytes = textEncoder.encode(canonicalJson(message))
|
||||
if (bytes.byteLength > MODEL_BRIDGE_LIMITS.maximumMessageBytes) {
|
||||
throw new ModelBridgeCodecError(
|
||||
'oversized',
|
||||
'Model bridge message exceeds its byte limit'
|
||||
)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export type ModelBridgeDecodeOptions = {
|
||||
expectedIdentity?: ModelBridgeIdentity
|
||||
expectedRequestDigest?: string
|
||||
maximumMessageBytes?: number
|
||||
}
|
||||
|
||||
export async function decodeModelBridgeMessage(
|
||||
input: Uint8Array,
|
||||
options: ModelBridgeDecodeOptions = {}
|
||||
): Promise<ModelBridgeMessage> {
|
||||
const maximumMessageBytes =
|
||||
options.maximumMessageBytes ?? MODEL_BRIDGE_LIMITS.maximumMessageBytes
|
||||
if (
|
||||
!Number.isSafeInteger(maximumMessageBytes) ||
|
||||
maximumMessageBytes < 1 ||
|
||||
maximumMessageBytes > MODEL_BRIDGE_LIMITS.maximumMessageBytes
|
||||
) {
|
||||
throw new RangeError('Invalid model bridge message limit')
|
||||
}
|
||||
if (input.byteLength < 1 || input.byteLength > maximumMessageBytes) {
|
||||
throw new ModelBridgeCodecError(
|
||||
'oversized',
|
||||
'Model bridge message exceeds its configured byte limit'
|
||||
)
|
||||
}
|
||||
let text: string
|
||||
let unparsed: unknown
|
||||
try {
|
||||
text = fatalTextDecoder.decode(input)
|
||||
unparsed = JSON.parse(text) as unknown
|
||||
} catch {
|
||||
throw new ModelBridgeCodecError(
|
||||
'invalid-message',
|
||||
'Model bridge message is not canonical UTF-8 JSON'
|
||||
)
|
||||
}
|
||||
let canonical: string
|
||||
try {
|
||||
canonical = canonicalJson(unparsed)
|
||||
} catch {
|
||||
throw new ModelBridgeCodecError(
|
||||
'invalid-message',
|
||||
'Model bridge message is not canonical JSON'
|
||||
)
|
||||
}
|
||||
if (canonical !== text) {
|
||||
throw new ModelBridgeCodecError(
|
||||
'invalid-message',
|
||||
'Model bridge message JSON is not canonical'
|
||||
)
|
||||
}
|
||||
const parsed = modelBridgeMessageSchema.safeParse(unparsed)
|
||||
if (!parsed.success) {
|
||||
throw new ModelBridgeCodecError(
|
||||
'invalid-message',
|
||||
'Model bridge message does not match the wire contract'
|
||||
)
|
||||
}
|
||||
const message = parsed.data
|
||||
const expectedIdentity =
|
||||
options.expectedIdentity === undefined
|
||||
? undefined
|
||||
: modelBridgeIdentitySchema.parse(options.expectedIdentity)
|
||||
if (
|
||||
expectedIdentity !== undefined &&
|
||||
!sameIdentity(message.identity, expectedIdentity)
|
||||
) {
|
||||
throw new ModelBridgeCodecError(
|
||||
'identity-mismatch',
|
||||
'Model bridge message identity does not match the expected request'
|
||||
)
|
||||
}
|
||||
const expectedRequestDigest =
|
||||
options.expectedRequestDigest === undefined
|
||||
? undefined
|
||||
: sha256DigestSchema.parse(options.expectedRequestDigest)
|
||||
if (
|
||||
expectedRequestDigest !== undefined &&
|
||||
message.requestDigest !== expectedRequestDigest
|
||||
) {
|
||||
throw new ModelBridgeCodecError(
|
||||
'digest-mismatch',
|
||||
'Model bridge message does not match the expected request digest'
|
||||
)
|
||||
}
|
||||
if (message.kind === 'request') {
|
||||
await assertModelBridgeRequestDigest(message)
|
||||
}
|
||||
return message
|
||||
}
|
||||
function sameIdentity(
|
||||
left: ModelBridgeIdentity,
|
||||
right: ModelBridgeIdentity
|
||||
): boolean {
|
||||
return (
|
||||
left.bindingId === right.bindingId &&
|
||||
left.promptOperationId === right.promptOperationId &&
|
||||
left.requestId === right.requestId &&
|
||||
left.roundIndex === right.roundIndex &&
|
||||
left.modelProfileDigest === right.modelProfileDigest &&
|
||||
left.messageId === right.messageId
|
||||
)
|
||||
}
|
||||
|
||||
function pathMatchesProtocol(
|
||||
protocol: ModelBridgeModelProtocol,
|
||||
path: string
|
||||
): boolean {
|
||||
switch (protocol) {
|
||||
case 'anthropic-messages':
|
||||
return path === '/v1/messages'
|
||||
case 'openai-chat-completions':
|
||||
return (
|
||||
path === '/chat/completions' ||
|
||||
path === '/v1/chat/completions'
|
||||
)
|
||||
case 'openai-responses':
|
||||
return path === '/responses' || path === '/v1/responses'
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Digest(value: string): Promise<string> {
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
textEncoder.encode(value)
|
||||
)
|
||||
return `sha256:${Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, '0')
|
||||
).join('')}`
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
completeModelCallEvidenceSchema,
|
||||
failModelCallEvidenceSchema,
|
||||
modelCallDispatchMetadataSchema,
|
||||
modelCallRecordSchema,
|
||||
prepareModelCallSchema
|
||||
} from './model-call-operation-contracts'
|
||||
|
||||
const identity = {
|
||||
callOperationId: 'call-1',
|
||||
requestId: 'request-1',
|
||||
bindingId: 'binding-1',
|
||||
promptOperationId: 'prompt-1',
|
||||
promptSequence: 0,
|
||||
roundIndex: 0,
|
||||
provider: 'provider',
|
||||
profile: 'default',
|
||||
model: 'model',
|
||||
protocol: 'responses-v1'
|
||||
}
|
||||
const policy = {
|
||||
modelProfileDigest: `sha256:${'b'.repeat(64)}`
|
||||
}
|
||||
|
||||
describe('model call operation contracts', () => {
|
||||
it('rejects secret or payload fields at every persistence boundary', () => {
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
expect(() =>
|
||||
prepareModelCallSchema.parse({
|
||||
identity,
|
||||
requestDigest: digest,
|
||||
...policy,
|
||||
prompt: 'secret prompt'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
prepareModelCallSchema.parse({
|
||||
identity: { ...identity, apiKey: 'secret-key' },
|
||||
requestDigest: digest,
|
||||
...policy
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
modelCallDispatchMetadataSchema.parse({
|
||||
providerIdempotencyKey: 'safe-id',
|
||||
requestBody: { messages: [] }
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
completeModelCallEvidenceSchema.parse({
|
||||
status: 'completed',
|
||||
providerResponseId: 'response-1',
|
||||
responseBody: 'secret response'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
failModelCallEvidenceSchema.parse({
|
||||
status: 'failed-definitive',
|
||||
error: {
|
||||
code: 'authentication',
|
||||
retryable: false,
|
||||
message: 'credential was secret-value'
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('bounds provider identifiers and safe terminal metadata', () => {
|
||||
expect(() =>
|
||||
modelCallDispatchMetadataSchema.parse({
|
||||
providerRequestId: 'x'.repeat(257)
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
completeModelCallEvidenceSchema.parse({
|
||||
status: 'completed',
|
||||
result: { finishReason: 'x'.repeat(129) }
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('tags terminal evidence and enforces record state consistency', () => {
|
||||
const base = {
|
||||
identity,
|
||||
requestDigest: `sha256:${'a'.repeat(64)}`,
|
||||
...policy,
|
||||
preparedAt: 1,
|
||||
updatedAt: 3,
|
||||
dispatchedAt: 2,
|
||||
dispatchMetadata: {}
|
||||
}
|
||||
expect(() =>
|
||||
modelCallRecordSchema.parse({
|
||||
...base,
|
||||
status: 'completed',
|
||||
terminalAt: 3,
|
||||
terminalEvidence: {
|
||||
status: 'failed-definitive',
|
||||
error: { code: 'authentication', retryable: false }
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
modelCallRecordSchema.parse({
|
||||
...base,
|
||||
status: 'prepared'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
modelCallRecordSchema.parse({
|
||||
...base,
|
||||
status: 'dispatched',
|
||||
updatedAt: 1
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('accepts legacy quota fields without requiring them', () => {
|
||||
const request = {
|
||||
identity,
|
||||
requestDigest: `sha256:${'a'.repeat(64)}`,
|
||||
...policy
|
||||
}
|
||||
expect(prepareModelCallSchema.parse(request)).toEqual(request)
|
||||
expect(
|
||||
prepareModelCallSchema.parse({
|
||||
...request,
|
||||
maximumOutputTokens: 65_537,
|
||||
maximumModelCalls: 100_000,
|
||||
maximumTotalOutputTokens: 1_000_000_000
|
||||
})
|
||||
).toMatchObject(request)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
agentIdentifierSchema,
|
||||
sha256DigestSchema,
|
||||
utf8StringSchema
|
||||
} from './agent-protocol/contracts'
|
||||
|
||||
const countSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
const positiveCountSchema = countSchema.positive()
|
||||
const timestampSchema = countSchema
|
||||
const providerLabelSchema = utf8StringSchema(256, {
|
||||
minimumBytes: 1,
|
||||
label: 'Model provider label'
|
||||
}).refine((value) => value.trim() === value, 'Label must not have outer whitespace')
|
||||
const providerIdentifierSchema = utf8StringSchema(256, {
|
||||
minimumBytes: 1,
|
||||
label: 'Provider identifier'
|
||||
}).refine(
|
||||
(value) => value.trim() === value,
|
||||
'Provider identifier must not have outer whitespace'
|
||||
)
|
||||
|
||||
export const modelCallDigestSchema = sha256DigestSchema
|
||||
|
||||
export const modelCallStatusSchema = z.enum([
|
||||
'prepared',
|
||||
'dispatched',
|
||||
'completed',
|
||||
'failed-definitive',
|
||||
'outcome-unknown'
|
||||
])
|
||||
export type ModelCallStatus = z.infer<typeof modelCallStatusSchema>
|
||||
|
||||
export const modelCallIdentitySchema = z
|
||||
.object({
|
||||
callOperationId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema,
|
||||
bindingId: agentIdentifierSchema,
|
||||
promptOperationId: agentIdentifierSchema,
|
||||
promptSequence: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0),
|
||||
roundIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER),
|
||||
provider: providerLabelSchema,
|
||||
profile: providerLabelSchema,
|
||||
model: providerLabelSchema,
|
||||
protocol: providerLabelSchema
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallIdentity = z.infer<typeof modelCallIdentitySchema>
|
||||
|
||||
export const prepareModelCallSchema = z
|
||||
.object({
|
||||
identity: modelCallIdentitySchema,
|
||||
requestDigest: modelCallDigestSchema,
|
||||
modelProfileDigest: modelCallDigestSchema,
|
||||
// Legacy ledger fields are accepted during migration but no longer
|
||||
// constrain dispatch.
|
||||
maximumOutputTokens: positiveCountSchema.optional(),
|
||||
maximumModelCalls: positiveCountSchema.optional(),
|
||||
maximumTotalOutputTokens: positiveCountSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type PrepareModelCall = z.infer<typeof prepareModelCallSchema>
|
||||
|
||||
export const modelCallDispatchMetadataSchema = z
|
||||
.object({
|
||||
providerIdempotencyKey: providerIdentifierSchema.optional(),
|
||||
providerRequestId: providerIdentifierSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallDispatchMetadata = z.infer<
|
||||
typeof modelCallDispatchMetadataSchema
|
||||
>
|
||||
|
||||
export const modelCallUsageMetadataSchema = z
|
||||
.object({
|
||||
inputTokens: countSchema.optional(),
|
||||
outputTokens: countSchema.optional(),
|
||||
cachedInputTokens: countSchema.optional(),
|
||||
reasoningTokens: countSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallUsageMetadata = z.infer<
|
||||
typeof modelCallUsageMetadataSchema
|
||||
>
|
||||
|
||||
export const modelCallResultMetadataSchema = z
|
||||
.object({
|
||||
finishReason: agentIdentifierSchema.optional(),
|
||||
outputDigest: modelCallDigestSchema.optional(),
|
||||
artifactCount: countSchema.max(1_000).optional()
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallResultMetadata = z.infer<
|
||||
typeof modelCallResultMetadataSchema
|
||||
>
|
||||
|
||||
export const modelCallErrorMetadataSchema = z
|
||||
.object({
|
||||
code: agentIdentifierSchema,
|
||||
retryable: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallErrorMetadata = z.infer<
|
||||
typeof modelCallErrorMetadataSchema
|
||||
>
|
||||
|
||||
export const completeModelCallEvidenceSchema = z
|
||||
.object({
|
||||
status: z.literal('completed'),
|
||||
providerRequestId: providerIdentifierSchema.optional(),
|
||||
providerResponseId: providerIdentifierSchema.optional(),
|
||||
result: modelCallResultMetadataSchema.optional(),
|
||||
usage: modelCallUsageMetadataSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type CompleteModelCallEvidence = z.infer<
|
||||
typeof completeModelCallEvidenceSchema
|
||||
>
|
||||
|
||||
export const failModelCallEvidenceSchema = z
|
||||
.object({
|
||||
status: z.literal('failed-definitive'),
|
||||
providerRequestId: providerIdentifierSchema.optional(),
|
||||
providerResponseId: providerIdentifierSchema.optional(),
|
||||
error: modelCallErrorMetadataSchema,
|
||||
usage: modelCallUsageMetadataSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
export type FailModelCallEvidence = z.infer<
|
||||
typeof failModelCallEvidenceSchema
|
||||
>
|
||||
|
||||
export const unknownModelCallEvidenceSchema = z
|
||||
.object({
|
||||
status: z.literal('outcome-unknown'),
|
||||
providerRequestId: providerIdentifierSchema.optional(),
|
||||
reason: modelCallErrorMetadataSchema
|
||||
})
|
||||
.strict()
|
||||
export type UnknownModelCallEvidence = z.infer<
|
||||
typeof unknownModelCallEvidenceSchema
|
||||
>
|
||||
|
||||
export const modelCallTerminalEvidenceSchema = z.discriminatedUnion('status', [
|
||||
completeModelCallEvidenceSchema,
|
||||
failModelCallEvidenceSchema,
|
||||
unknownModelCallEvidenceSchema
|
||||
])
|
||||
export type ModelCallTerminalEvidence = z.infer<
|
||||
typeof modelCallTerminalEvidenceSchema
|
||||
>
|
||||
|
||||
export const modelCallRecordSchema = z
|
||||
.object({
|
||||
identity: modelCallIdentitySchema,
|
||||
requestDigest: modelCallDigestSchema,
|
||||
modelProfileDigest: modelCallDigestSchema,
|
||||
maximumOutputTokens: positiveCountSchema.optional(),
|
||||
maximumModelCalls: positiveCountSchema.optional(),
|
||||
maximumTotalOutputTokens: positiveCountSchema.optional(),
|
||||
status: modelCallStatusSchema,
|
||||
dispatchMetadata: modelCallDispatchMetadataSchema.optional(),
|
||||
terminalEvidence: modelCallTerminalEvidenceSchema.optional(),
|
||||
preparedAt: timestampSchema,
|
||||
updatedAt: timestampSchema,
|
||||
dispatchedAt: timestampSchema.optional(),
|
||||
terminalAt: timestampSchema.optional(),
|
||||
responseDeliveredAt: timestampSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((record, context) => {
|
||||
if (record.updatedAt < record.preparedAt) {
|
||||
addRecordIssue(context, ['updatedAt'], 'updatedAt precedes preparedAt')
|
||||
}
|
||||
const hasDispatch =
|
||||
record.dispatchMetadata !== undefined && record.dispatchedAt !== undefined
|
||||
if (
|
||||
(record.dispatchMetadata === undefined) !==
|
||||
(record.dispatchedAt === undefined)
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['dispatchMetadata'],
|
||||
'dispatch metadata and timestamp must appear together'
|
||||
)
|
||||
}
|
||||
if (
|
||||
record.dispatchedAt !== undefined &&
|
||||
(record.dispatchedAt < record.preparedAt ||
|
||||
record.updatedAt < record.dispatchedAt)
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['dispatchedAt'],
|
||||
'dispatchedAt is outside the record timestamp range'
|
||||
)
|
||||
}
|
||||
if (record.status === 'prepared') {
|
||||
if (
|
||||
hasDispatch ||
|
||||
record.terminalEvidence ||
|
||||
record.terminalAt !== undefined ||
|
||||
record.responseDeliveredAt !== undefined
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['status'],
|
||||
'prepared records cannot contain dispatch or terminal state'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!hasDispatch) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['dispatchMetadata'],
|
||||
'dispatched and terminal records require dispatch state'
|
||||
)
|
||||
}
|
||||
if (record.status === 'dispatched') {
|
||||
if (
|
||||
record.terminalEvidence ||
|
||||
record.terminalAt !== undefined ||
|
||||
record.responseDeliveredAt !== undefined
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['status'],
|
||||
'dispatched records cannot contain terminal state'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (
|
||||
record.terminalEvidence?.status !== record.status ||
|
||||
record.terminalAt === undefined
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['terminalEvidence'],
|
||||
'terminal evidence and timestamp must match record status'
|
||||
)
|
||||
}
|
||||
if (
|
||||
record.terminalAt !== undefined &&
|
||||
(record.dispatchedAt === undefined ||
|
||||
record.terminalAt < record.dispatchedAt ||
|
||||
record.updatedAt < record.terminalAt)
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['terminalAt'],
|
||||
'terminalAt is outside the record timestamp range'
|
||||
)
|
||||
}
|
||||
if (
|
||||
record.responseDeliveredAt !== undefined &&
|
||||
(record.status !== 'completed' ||
|
||||
record.terminalAt === undefined ||
|
||||
record.responseDeliveredAt < record.terminalAt ||
|
||||
record.updatedAt < record.responseDeliveredAt)
|
||||
) {
|
||||
addRecordIssue(
|
||||
context,
|
||||
['responseDeliveredAt'],
|
||||
'responseDeliveredAt requires a completed provider response'
|
||||
)
|
||||
}
|
||||
})
|
||||
export type ModelCallRecord = z.infer<typeof modelCallRecordSchema>
|
||||
|
||||
export const modelCallOperationCursorSchema = z
|
||||
.object({
|
||||
updatedAt: timestampSchema,
|
||||
callOperationId: agentIdentifierSchema
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallOperationCursor = z.infer<
|
||||
typeof modelCallOperationCursorSchema
|
||||
>
|
||||
|
||||
export const modelCallDeliveryFilterSchema = z.enum([
|
||||
'any',
|
||||
'pending-response',
|
||||
'delivered'
|
||||
])
|
||||
export type ModelCallDeliveryFilter = z.infer<
|
||||
typeof modelCallDeliveryFilterSchema
|
||||
>
|
||||
|
||||
export const modelCallListOptionsSchema = z
|
||||
.object({
|
||||
statuses: z.array(modelCallStatusSchema).min(1).max(5).optional(),
|
||||
delivery: modelCallDeliveryFilterSchema.default('any'),
|
||||
cursor: modelCallOperationCursorSchema.optional(),
|
||||
limit: positiveCountSchema.max(1_000).default(100)
|
||||
})
|
||||
.strict()
|
||||
export type ModelCallListOptions = z.input<
|
||||
typeof modelCallListOptionsSchema
|
||||
>
|
||||
|
||||
export type ModelCallOperationPage = {
|
||||
records: ModelCallRecord[]
|
||||
nextCursor?: ModelCallOperationCursor
|
||||
}
|
||||
|
||||
function addRecordIssue(
|
||||
context: z.RefinementCtx,
|
||||
path: Array<string | number>,
|
||||
message: string
|
||||
): void {
|
||||
context.addIssue({ code: 'custom', path, message })
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
fchmodSync,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
type BigIntStats
|
||||
} from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
function assertSameFile(
|
||||
descriptorStats: BigIntStats,
|
||||
pathStats: BigIntStats
|
||||
): void {
|
||||
if (
|
||||
!pathStats.isFile() ||
|
||||
pathStats.isSymbolicLink() ||
|
||||
descriptorStats.dev !== pathStats.dev ||
|
||||
descriptorStats.ino !== pathStats.ino
|
||||
) {
|
||||
throw new Error('SQLite database path identity changed during open')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a local same-UID security boundary. On POSIX the database and its
|
||||
* immediate parent must be owned by the current UID, the parent must be
|
||||
* private, and the database mode is forced to 0600. This does not defend
|
||||
* against a process already running as the same UID.
|
||||
*/
|
||||
export class PreparedPrivateSqliteDatabaseFile {
|
||||
private descriptor?: number
|
||||
readonly path: string
|
||||
|
||||
constructor(databasePath: string) {
|
||||
if (
|
||||
databasePath.length === 0 ||
|
||||
databasePath === ':memory:' ||
|
||||
/^file:/iu.test(databasePath)
|
||||
) {
|
||||
throw new Error('SQLite database must be a filesystem path')
|
||||
}
|
||||
|
||||
this.path = resolve(databasePath)
|
||||
const parentStats = lstatSync(dirname(this.path), { bigint: true })
|
||||
if (parentStats.isSymbolicLink() || !parentStats.isDirectory()) {
|
||||
throw new Error('SQLite database parent must be a real directory')
|
||||
}
|
||||
this.assertPrivatePosixParent(parentStats)
|
||||
|
||||
try {
|
||||
const pathStats = lstatSync(this.path, { bigint: true })
|
||||
if (pathStats.isSymbolicLink() || !pathStats.isFile()) {
|
||||
throw new Error(
|
||||
'SQLite database must be a regular, non-symbolic-link file'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
this.descriptor = openSync(
|
||||
this.path,
|
||||
constants.O_CREAT |
|
||||
constants.O_RDWR |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600
|
||||
)
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
fchmodSync(this.descriptor, 0o600)
|
||||
}
|
||||
this.verifyPathIdentity()
|
||||
} catch (error) {
|
||||
this.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
openDatabase(timeout = 5_000): DatabaseSync {
|
||||
this.verifyPathIdentity()
|
||||
let database: DatabaseSync | undefined
|
||||
try {
|
||||
database = new DatabaseSync(this.path, { timeout })
|
||||
this.verifyPathIdentity()
|
||||
return database
|
||||
} catch (error) {
|
||||
database?.close()
|
||||
throw error
|
||||
} finally {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.descriptor !== undefined) {
|
||||
closeSync(this.descriptor)
|
||||
this.descriptor = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private verifyPathIdentity(): void {
|
||||
if (this.descriptor === undefined) {
|
||||
throw new Error('SQLite database verification descriptor is closed')
|
||||
}
|
||||
const descriptorStats = fstatSync(this.descriptor, { bigint: true })
|
||||
const pathStats = lstatSync(this.path, { bigint: true })
|
||||
assertSameFile(descriptorStats, pathStats)
|
||||
this.assertPrivatePosixFile(descriptorStats)
|
||||
}
|
||||
|
||||
private assertPrivatePosixParent(parentStats: BigIntStats): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const uid = process.getuid?.()
|
||||
if (
|
||||
uid === undefined ||
|
||||
parentStats.uid !== BigInt(uid) ||
|
||||
(parentStats.mode & 0o077n) !== 0n
|
||||
) {
|
||||
throw new Error(
|
||||
'SQLite database parent must be private and owned by the current UID'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private assertPrivatePosixFile(fileStats: BigIntStats): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const uid = process.getuid?.()
|
||||
if (
|
||||
uid === undefined ||
|
||||
fileStats.uid !== BigInt(uid) ||
|
||||
(fileStats.mode & 0o777n) !== 0o600n
|
||||
) {
|
||||
throw new Error(
|
||||
'SQLite database must be mode 0600 and owned by the current UID'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openPrivateSqliteDatabase(
|
||||
databasePath: string,
|
||||
timeout = 5_000
|
||||
): DatabaseSync {
|
||||
const prepared = new PreparedPrivateSqliteDatabaseFile(databasePath)
|
||||
try {
|
||||
return prepared.openDatabase(timeout)
|
||||
} finally {
|
||||
prepared.close()
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return (
|
||||
error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
verify
|
||||
} from 'node:crypto'
|
||||
import { createReadStream, lstatSync } from 'node:fs'
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
readFile,
|
||||
readdir
|
||||
} from 'node:fs/promises'
|
||||
import {
|
||||
basename,
|
||||
isAbsolute,
|
||||
join,
|
||||
parse,
|
||||
relative,
|
||||
resolve,
|
||||
sep
|
||||
} from 'node:path'
|
||||
import {
|
||||
agentReleaseKeyRegistrySchema,
|
||||
type AgentArchitecture,
|
||||
type AgentReleaseKey,
|
||||
type AgentReleaseKeyRegistry
|
||||
} from '../agent-installation-contracts'
|
||||
import {
|
||||
assertDetachedRemoteRuntimeBundleDigest,
|
||||
digestRemoteRuntimeBundleManifest,
|
||||
remoteRuntimeBundleManifestSchema,
|
||||
remoteRuntimeLockSchema,
|
||||
type RemoteRuntimeBundleManifest,
|
||||
type RemoteRuntimeLock
|
||||
} from '../remote-runtime-launch-contracts'
|
||||
|
||||
export type InstalledBundleVerificationEnvironment =
|
||||
| 'production'
|
||||
| 'test'
|
||||
|
||||
const SIGNATURE_DOMAIN = Buffer.from(
|
||||
'GoodBuddy Remote Runtime 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 VerifiedRuntimeBundle = {
|
||||
bundleDirectory: string
|
||||
executablePath: string
|
||||
manifest: RemoteRuntimeBundleManifest
|
||||
manifestDigest: string
|
||||
}
|
||||
|
||||
export type VerifyRuntimeBundleOptions = {
|
||||
architecture: AgentArchitecture
|
||||
releaseKeyRegistry: AgentReleaseKeyRegistry
|
||||
runtimeLock: RemoteRuntimeLock
|
||||
verificationEnvironment?: InstalledBundleVerificationEnvironment
|
||||
enforceFilesystemMode?: boolean
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
uid?: number
|
||||
}
|
||||
|
||||
export async function verifyRuntimeBundle(
|
||||
bundleDirectoryInput: string,
|
||||
options: VerifyRuntimeBundleOptions
|
||||
): Promise<VerifiedRuntimeBundle> {
|
||||
const bundleDirectory = assertAbsoluteManagedPath(
|
||||
resolve(bundleDirectoryInput)
|
||||
)
|
||||
const registry = parseReleaseKeyRegistry(
|
||||
options.releaseKeyRegistry
|
||||
)
|
||||
const runtimeLock = remoteRuntimeLockSchema.parse(options.runtimeLock)
|
||||
const verificationEnvironment =
|
||||
options.verificationEnvironment ?? 'production'
|
||||
assertRegistryAvailable(registry, verificationEnvironment)
|
||||
|
||||
const rootStat = await lstat(bundleDirectory)
|
||||
assertOwnedDirectory(rootStat, 'Runtime bundle directory', options.uid)
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(rootStat.mode) !== '0700'
|
||||
) {
|
||||
throw new Error('Runtime bundle directory permissions must be 0700')
|
||||
}
|
||||
|
||||
const manifestBytes = await readMetadataFile(
|
||||
join(bundleDirectory, MANIFEST_FILE_NAME),
|
||||
'Runtime manifest',
|
||||
options
|
||||
)
|
||||
const signatureBytes = await readSignature(
|
||||
join(bundleDirectory, SIGNATURE_FILE_NAME),
|
||||
options
|
||||
)
|
||||
const manifest = await verifyRuntimeManifestSignature(
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
registry,
|
||||
verificationEnvironment
|
||||
)
|
||||
assertRuntimeManifestMatchesLock(
|
||||
manifest,
|
||||
runtimeLock,
|
||||
options.architecture
|
||||
)
|
||||
if (
|
||||
basename(bundleDirectory) !==
|
||||
manifest.bundleDigest.slice('sha256:'.length)
|
||||
) {
|
||||
throw new Error(
|
||||
'Runtime bundle digest does not match its managed directory'
|
||||
)
|
||||
}
|
||||
|
||||
const inventory = await inventoryBundleFiles(
|
||||
bundleDirectory,
|
||||
options.uid
|
||||
)
|
||||
const declaredPaths = new Set(
|
||||
manifest.files.map((file) => file.path)
|
||||
)
|
||||
for (const file of manifest.files) {
|
||||
const filePath = join(
|
||||
bundleDirectory,
|
||||
...file.path.split('/')
|
||||
)
|
||||
const stat = inventory.get(file.path)
|
||||
if (stat === undefined) {
|
||||
throw new Error(`Runtime payload is missing: ${file.path}`)
|
||||
}
|
||||
if (stat.size !== file.size) {
|
||||
throw new Error(`Runtime payload size mismatch: ${file.path}`)
|
||||
}
|
||||
if ((await sha256File(filePath)) !== file.sha256) {
|
||||
throw new Error(`Runtime payload hash mismatch: ${file.path}`)
|
||||
}
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(stat.mode) !== file.mode
|
||||
) {
|
||||
throw new Error(`Runtime payload mode mismatch: ${file.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
const actualPaths = new Set(inventory.keys())
|
||||
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(
|
||||
'Runtime bundle contains undeclared or missing files'
|
||||
)
|
||||
}
|
||||
|
||||
const executablePath = join(
|
||||
bundleDirectory,
|
||||
...manifest.entrypoint.path.split('/')
|
||||
)
|
||||
await assertElfArchitecture(
|
||||
executablePath,
|
||||
options.architecture,
|
||||
'Runtime executable'
|
||||
)
|
||||
return {
|
||||
bundleDirectory,
|
||||
executablePath,
|
||||
manifest,
|
||||
manifestDigest:
|
||||
await digestRemoteRuntimeBundleManifest(manifest)
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalRuntimeManifestBytes(
|
||||
manifest: RemoteRuntimeBundleManifest
|
||||
): Buffer {
|
||||
return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export function runtimeManifestSignaturePayload(
|
||||
manifestBytes: Uint8Array
|
||||
): Buffer {
|
||||
return Buffer.concat([
|
||||
SIGNATURE_DOMAIN,
|
||||
Buffer.from(manifestBytes)
|
||||
])
|
||||
}
|
||||
|
||||
export async function verifyRuntimeManifestSignature(
|
||||
manifestBytes: Buffer,
|
||||
signatureBytes: Buffer,
|
||||
registryInput: AgentReleaseKeyRegistry,
|
||||
verificationEnvironment: InstalledBundleVerificationEnvironment =
|
||||
'production'
|
||||
): Promise<RemoteRuntimeBundleManifest> {
|
||||
const registry = parseReleaseKeyRegistry(registryInput)
|
||||
assertRegistryAvailable(registry, verificationEnvironment)
|
||||
let untrusted: unknown
|
||||
try {
|
||||
untrusted = JSON.parse(manifestBytes.toString('utf8'))
|
||||
} catch (error) {
|
||||
throw new Error('Runtime manifest JSON is invalid', { cause: error })
|
||||
}
|
||||
const parsed = remoteRuntimeBundleManifestSchema.safeParse(untrusted)
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Runtime manifest contract is invalid: ${parsed.error.message}`
|
||||
)
|
||||
}
|
||||
const manifest = parsed.data
|
||||
if (!canonicalRuntimeManifestBytes(manifest).equals(manifestBytes)) {
|
||||
throw new Error(
|
||||
'Runtime manifest is not in canonical deterministic form'
|
||||
)
|
||||
}
|
||||
const key = trustedKeyForManifest(
|
||||
manifest,
|
||||
registry,
|
||||
verificationEnvironment
|
||||
)
|
||||
const publicKey = importPublicKey(key)
|
||||
if (
|
||||
signatureBytes.length !== 64 ||
|
||||
!verify(
|
||||
null,
|
||||
runtimeManifestSignaturePayload(manifestBytes),
|
||||
publicKey,
|
||||
signatureBytes
|
||||
)
|
||||
) {
|
||||
throw new Error('Runtime manifest signature verification failed')
|
||||
}
|
||||
await assertDetachedRemoteRuntimeBundleDigest({
|
||||
manifest,
|
||||
detachedSignature: {
|
||||
manifestDigest:
|
||||
await digestRemoteRuntimeBundleManifest(manifest),
|
||||
algorithm: 'ed25519',
|
||||
keyId: manifest.signingKeyId,
|
||||
signatureBase64: signatureBytes.toString('base64')
|
||||
}
|
||||
})
|
||||
return manifest
|
||||
}
|
||||
|
||||
export async function readRemoteRuntimeLock(
|
||||
filePathInput: string,
|
||||
options: {
|
||||
uid?: number
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
} = {}
|
||||
): Promise<RemoteRuntimeLock> {
|
||||
const filePath = assertAbsoluteManagedPath(resolve(filePathInput))
|
||||
const contents = await readMetadataFile(
|
||||
filePath,
|
||||
'Remote Runtime lock',
|
||||
options
|
||||
)
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(contents.toString('utf8'))
|
||||
} catch (error) {
|
||||
throw new Error('Remote Runtime lock is invalid JSON', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
return remoteRuntimeLockSchema.parse(value)
|
||||
}
|
||||
|
||||
export function assertRuntimeManifestMatchesLock(
|
||||
manifest: RemoteRuntimeBundleManifest,
|
||||
lock: RemoteRuntimeLock,
|
||||
architecture: AgentArchitecture
|
||||
): void {
|
||||
const expected = lock.runtimes.opencode
|
||||
const target = expected.targets[architecture]
|
||||
if (
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.provider !== expected.provider ||
|
||||
manifest.runtimeVersion !== expected.version ||
|
||||
manifest.architecture !== architecture ||
|
||||
manifest.entrypoint.path !== expected.entrypoint ||
|
||||
manifest.entrypoint.identity !== expected.entrypointIdentity ||
|
||||
manifest.entrypoint.argvPrefix.length !==
|
||||
expected.argvPrefix.length ||
|
||||
manifest.entrypoint.argvPrefix.some(
|
||||
(argument, index) => argument !== expected.argvPrefix[index]
|
||||
) ||
|
||||
manifest.protocol.major !== expected.protocol.major ||
|
||||
manifest.protocol.minor !== expected.protocol.minor ||
|
||||
manifest.allowedEnvironmentNames.length !==
|
||||
expected.allowedEnvironmentNames.length ||
|
||||
manifest.allowedEnvironmentNames.some(
|
||||
(name, index) =>
|
||||
name !== expected.allowedEnvironmentNames[index]
|
||||
) ||
|
||||
manifest.sourcePackage.name !== target.package ||
|
||||
manifest.sourcePackage.integrity !== target.integrity
|
||||
) {
|
||||
throw new Error(
|
||||
'Runtime manifest does not match the locked OpenCode profile'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseReleaseKeyRegistry(
|
||||
value: unknown
|
||||
): AgentReleaseKeyRegistry {
|
||||
const parsed = agentReleaseKeyRegistrySchema.safeParse(value)
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Runtime trusted key registry contract is invalid: ${parsed.error.message}`
|
||||
)
|
||||
}
|
||||
for (const key of parsed.data.keys) {
|
||||
importPublicKey(key)
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
function assertRegistryAvailable(
|
||||
registry: AgentReleaseKeyRegistry,
|
||||
environment: InstalledBundleVerificationEnvironment
|
||||
): void {
|
||||
if (
|
||||
environment === 'production' &&
|
||||
!registry.keys.some((key) => key.environment === 'production')
|
||||
) {
|
||||
throw new Error(
|
||||
'Runtime production key registry is empty; verification is unavailable'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function trustedKeyForManifest(
|
||||
manifest: RemoteRuntimeBundleManifest,
|
||||
registry: AgentReleaseKeyRegistry,
|
||||
environment: InstalledBundleVerificationEnvironment
|
||||
): AgentReleaseKey {
|
||||
const key = registry.keys.find(
|
||||
(candidate) => candidate.keyId === manifest.signingKeyId
|
||||
)
|
||||
if (key === undefined) {
|
||||
throw new Error(
|
||||
`Runtime manifest uses an unknown signing key: ${manifest.signingKeyId}`
|
||||
)
|
||||
}
|
||||
if (environment === 'production' && key.environment !== 'production') {
|
||||
throw new Error(
|
||||
`Production Runtime verification rejects non-production key: ${key.keyId}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
registry.revocations.some(
|
||||
(revocation) => revocation.keyId === key.keyId
|
||||
)
|
||||
) {
|
||||
throw new Error(`Runtime signing key is revoked: ${key.keyId}`)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function importPublicKey(key: AgentReleaseKey) {
|
||||
let publicKey
|
||||
try {
|
||||
publicKey = createPublicKey({
|
||||
key: Buffer.from(key.publicKeySpkiBase64, 'base64'),
|
||||
format: 'der',
|
||||
type: 'spki'
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Runtime trusted public key is invalid: ${key.keyId}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
if (publicKey.asymmetricKeyType !== 'ed25519') {
|
||||
throw new Error(
|
||||
`Runtime trusted public key is not Ed25519: ${key.keyId}`
|
||||
)
|
||||
}
|
||||
return publicKey
|
||||
}
|
||||
|
||||
async function readMetadataFile(
|
||||
filePath: string,
|
||||
label: string,
|
||||
options: {
|
||||
enforceFilesystemMode?: boolean
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
uid?: number
|
||||
}
|
||||
): Promise<Buffer> {
|
||||
const stat = await lstat(filePath)
|
||||
assertOwnedRegularFile(stat, label, options.uid)
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(stat.mode) !== '0644' &&
|
||||
modeString(stat.mode) !== '0600'
|
||||
) {
|
||||
throw new Error(`${label} permissions must be 0600 or 0644`)
|
||||
}
|
||||
if (stat.size > MAXIMUM_METADATA_BYTES) {
|
||||
throw new Error(`${label} exceeds its size limit`)
|
||||
}
|
||||
return readFile(filePath)
|
||||
}
|
||||
|
||||
async function readSignature(
|
||||
filePath: string,
|
||||
options: VerifyRuntimeBundleOptions
|
||||
): Promise<Buffer> {
|
||||
const contents = await readMetadataFile(
|
||||
filePath,
|
||||
'Runtime detached signature',
|
||||
options
|
||||
)
|
||||
const text = contents.toString('utf8')
|
||||
if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(text)) {
|
||||
throw new Error('Runtime detached signature encoding is invalid')
|
||||
}
|
||||
return Buffer.from(text.trim(), 'base64')
|
||||
}
|
||||
|
||||
async function inventoryBundleFiles(
|
||||
bundleDirectory: string,
|
||||
uid = process.getuid?.()
|
||||
): Promise<Map<string, Awaited<ReturnType<typeof lstat>>>> {
|
||||
const files = new Map<
|
||||
string,
|
||||
Awaited<ReturnType<typeof lstat>>
|
||||
>()
|
||||
const pending = [bundleDirectory]
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop()
|
||||
if (current === undefined) {
|
||||
continue
|
||||
}
|
||||
for (const entry of await readdir(current, {
|
||||
withFileTypes: true
|
||||
})) {
|
||||
const absolutePath = join(current, entry.name)
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Runtime bundle cannot contain a symlink: ${absolutePath}`
|
||||
)
|
||||
}
|
||||
const stat = await lstat(absolutePath)
|
||||
if (entry.isDirectory()) {
|
||||
assertOwnedDirectory(
|
||||
stat,
|
||||
absolutePath,
|
||||
uid
|
||||
)
|
||||
pending.push(absolutePath)
|
||||
} else if (entry.isFile()) {
|
||||
assertOwnedRegularFile(
|
||||
stat,
|
||||
absolutePath,
|
||||
uid
|
||||
)
|
||||
files.set(
|
||||
relative(bundleDirectory, absolutePath)
|
||||
.split(sep)
|
||||
.join('/'),
|
||||
stat
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Runtime bundle contains an unsupported file: ${absolutePath}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function assertOwnedDirectory(
|
||||
stat: Awaited<ReturnType<typeof lstat>>,
|
||||
label: string,
|
||||
uid = process.getuid?.()
|
||||
): void {
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new Error(`${label} is not a real directory`)
|
||||
}
|
||||
if (uid !== undefined && stat.uid !== uid) {
|
||||
throw new Error(`${label} is not owned by the current user`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertOwnedRegularFile(
|
||||
stat: Awaited<ReturnType<typeof lstat>>,
|
||||
label: string,
|
||||
uid = process.getuid?.()
|
||||
): void {
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error(`${label} is not a regular file`)
|
||||
}
|
||||
if (uid !== undefined && stat.uid !== uid) {
|
||||
throw new Error(`${label} is not owned by the current user`)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldEnforceMode(options: {
|
||||
enforceFilesystemMode?: boolean
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
}): boolean {
|
||||
return (
|
||||
options.enforceFilesystemMode !== false &&
|
||||
(options.filesystemPlatform ?? process.platform) !== 'win32'
|
||||
)
|
||||
}
|
||||
|
||||
function modeString(mode: number | bigint): string {
|
||||
return (Number(mode) & 0o777).toString(8).padStart(4, '0')
|
||||
}
|
||||
|
||||
function assertAbsoluteManagedPath(path: string): string {
|
||||
if (
|
||||
path.includes('\0') ||
|
||||
!isAbsolute(path) ||
|
||||
resolve(path) !== path
|
||||
) {
|
||||
throw new Error('Managed paths must be normalized absolute paths')
|
||||
}
|
||||
const root = parse(path).root
|
||||
const components = relative(root, path).split(sep).filter(Boolean)
|
||||
let current = root
|
||||
for (const component of components) {
|
||||
current = resolve(current, component)
|
||||
try {
|
||||
if (lstatSync(current).isSymbolicLink()) {
|
||||
throw new Error('Managed paths cannot traverse a symlink')
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return path
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
async function assertElfArchitecture(
|
||||
filePath: string,
|
||||
expected: AgentArchitecture,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
const header = Buffer.alloc(64)
|
||||
const { bytesRead } = await handle.read(
|
||||
header,
|
||||
0,
|
||||
header.length,
|
||||
0
|
||||
)
|
||||
const actual = detectElfArchitecture(
|
||||
header.subarray(0, bytesRead)
|
||||
)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`${label} architecture mismatch: expected ${expected}, received ${actual ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function detectElfArchitecture(
|
||||
header: Buffer
|
||||
): AgentArchitecture | undefined {
|
||||
if (
|
||||
header.length < 20 ||
|
||||
header[0] !== 0x7f ||
|
||||
header.toString('ascii', 1, 4) !== 'ELF' ||
|
||||
(header[5] !== 1 && header[5] !== 2)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const machine =
|
||||
header[5] === 2
|
||||
? header.readUInt16BE(18)
|
||||
: header.readUInt16LE(18)
|
||||
return machine === 62
|
||||
? 'x64'
|
||||
: machine === 183
|
||||
? 'arm64'
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
REMOTE_WORKSPACE_LIMITS,
|
||||
acpJournalCursorSchema,
|
||||
assertRemoteWorkspaceWriteTextAtomicDigest,
|
||||
createRemoteWorkspaceWriteTextAtomicRequest,
|
||||
remoteAbsolutePathSchema,
|
||||
remoteGitDiffResultSchema,
|
||||
assertRemotePromptAcceptanceMatchesPreparation,
|
||||
remotePromptOperationAcceptanceSchema,
|
||||
remotePromptOperationPreparationSchema,
|
||||
remoteRelativePathSchema,
|
||||
remoteWorkspaceApplyChangeSetRequestSchema,
|
||||
remoteWorkspaceHandleSchema,
|
||||
remoteWorkspaceReadTextResultSchema,
|
||||
remoteWorkspaceValidateRequestSchema,
|
||||
remoteWorkspaceWriteResultSchema,
|
||||
runtimeSessionBindingSchema
|
||||
} from './remote-agent-contracts'
|
||||
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
|
||||
const binding = {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
controllerGeneration: 1,
|
||||
conversationId: 'conversation-1',
|
||||
hostId: 'host-1',
|
||||
hostRevision: 1,
|
||||
hostKeyGeneration: 1,
|
||||
workspaceIdentity: 'workspace-1',
|
||||
agentInstallationId: 'installation-1',
|
||||
daemonBootIdAtOpen: 'boot-1',
|
||||
runtimeId: 'runtime-1',
|
||||
runtimeBundleDigest: digest,
|
||||
runtimeAdapterDigest: digest,
|
||||
acpSessionId: 'session-1',
|
||||
acpCapabilitiesDigest: digest,
|
||||
state: 'ready' as const,
|
||||
promptSequence: 0,
|
||||
channelEpoch: '1',
|
||||
lastOutboundJournaledSequence: '2',
|
||||
lastOutboundDeliveredSequence: '1',
|
||||
lastInboundJournaledSequence: '3',
|
||||
lastMainAckSequence: '2'
|
||||
}
|
||||
|
||||
describe('remote Agent contracts', () => {
|
||||
it('accepts a consistent durable Runtime binding', () => {
|
||||
expect(runtimeSessionBindingSchema.parse(binding)).toEqual(binding)
|
||||
})
|
||||
|
||||
it('requires prompt operation identity while a prompt is running', () => {
|
||||
expect(() =>
|
||||
runtimeSessionBindingSchema.parse({
|
||||
...binding,
|
||||
state: 'prompt-running'
|
||||
})
|
||||
).toThrow(/operation identity/iu)
|
||||
})
|
||||
|
||||
it('retains operation identity for reconciliation and unknown outcomes', () => {
|
||||
for (const state of ['reconciling', 'outcome-unknown'] as const) {
|
||||
expect(() =>
|
||||
runtimeSessionBindingSchema.parse({
|
||||
...binding,
|
||||
state
|
||||
})
|
||||
).toThrow(/operation identity/iu)
|
||||
expect(
|
||||
runtimeSessionBindingSchema.parse({
|
||||
...binding,
|
||||
state,
|
||||
activePromptOperationId: 'operation-1'
|
||||
}).activePromptOperationId
|
||||
).toBe('operation-1')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses workMode as the complete prompt authorization contract', () => {
|
||||
const common = {
|
||||
bindingId: 'binding-1',
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
controllerId: 'controller-1',
|
||||
controllerGeneration: 1,
|
||||
connectionGeneration: 1,
|
||||
channelEpoch: '1',
|
||||
hostId: 'host-1',
|
||||
hostRevision: 1,
|
||||
hostKeyGeneration: 1,
|
||||
workspaceIdentity: 'workspace-1',
|
||||
agentInstallationId: 'installation-1',
|
||||
runtimeId: 'runtime-1',
|
||||
runtimeBundleDigest: digest,
|
||||
runtimeAdapterDigest: digest,
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z',
|
||||
budget: {
|
||||
maximumInputBytes: 10,
|
||||
maximumOutputBytes: 100
|
||||
}
|
||||
}
|
||||
const execute = remotePromptOperationPreparationSchema.parse({
|
||||
...common,
|
||||
workMode: 'execute'
|
||||
})
|
||||
expect(execute.workMode).toBe('execute')
|
||||
expect(execute).not.toHaveProperty('trustTier')
|
||||
expect(execute).not.toHaveProperty('capabilities')
|
||||
expect(execute.budget).not.toHaveProperty('maximumToolCalls')
|
||||
const acceptance = remotePromptOperationAcceptanceSchema.parse({
|
||||
bindingId: execute.bindingId,
|
||||
operationId: execute.operationId,
|
||||
requestId: execute.requestId,
|
||||
workMode: execute.workMode,
|
||||
deadlineAt: execute.deadlineAt,
|
||||
acceptedAt: '2029-01-01T00:00:00.000Z'
|
||||
})
|
||||
expect(() =>
|
||||
assertRemotePromptAcceptanceMatchesPreparation(execute, acceptance)
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertRemotePromptAcceptanceMatchesPreparation(execute, {
|
||||
...acceptance,
|
||||
workMode: 'ask'
|
||||
})
|
||||
).toThrow(/does not match/iu)
|
||||
})
|
||||
|
||||
it('rejects delivered and ACK cursors ahead of durable journals', () => {
|
||||
expect(() =>
|
||||
runtimeSessionBindingSchema.parse({
|
||||
...binding,
|
||||
lastOutboundDeliveredSequence: '3'
|
||||
})
|
||||
).toThrow(/Delivered cursor/iu)
|
||||
expect(() =>
|
||||
acpJournalCursorSchema.parse({
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '1',
|
||||
direction: 'runtime-to-main',
|
||||
journaledSequence: '1',
|
||||
deliveredSequence: '0',
|
||||
mainAckSequence: '2'
|
||||
})
|
||||
).toThrow(/ACK cursor/iu)
|
||||
})
|
||||
|
||||
it('validates UTF-8 path bytes and prevents traversal', () => {
|
||||
expect(remoteAbsolutePathSchema.parse('/srv/项目')).toBe('/srv/项目')
|
||||
expect(remoteRelativePathSchema.parse('')).toBe('')
|
||||
expect(remoteRelativePathSchema.parse('src/文件.ts')).toBe(
|
||||
'src/文件.ts'
|
||||
)
|
||||
for (const path of [
|
||||
'../secret',
|
||||
'src/../secret',
|
||||
'/etc/passwd',
|
||||
'C:/Windows',
|
||||
'src\\secret',
|
||||
'src//secret',
|
||||
'src/\0secret'
|
||||
]) {
|
||||
expect(() => remoteRelativePathSchema.parse(path)).toThrow()
|
||||
}
|
||||
expect(() =>
|
||||
remoteRelativePathSchema.parse(
|
||||
`${'界'.repeat(
|
||||
Math.floor(
|
||||
REMOTE_WORKSPACE_LIMITS.maximumPathSegmentBytes / 3
|
||||
) + 1
|
||||
)}`
|
||||
)
|
||||
).toThrow(/oversized/iu)
|
||||
})
|
||||
|
||||
it('keeps validate and handle contracts strict and capability-safe', () => {
|
||||
const request = {
|
||||
remoteRootPath: '/srv/project',
|
||||
requestedAccess: 'read-only' as const,
|
||||
requiredCapabilities: ['list', 'read-text'] as const
|
||||
}
|
||||
expect(remoteWorkspaceValidateRequestSchema.parse(request)).toEqual(
|
||||
request
|
||||
)
|
||||
expect(() =>
|
||||
remoteWorkspaceValidateRequestSchema.parse({
|
||||
...request,
|
||||
controllerId: 'controller-must-come-from-connection'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
remoteWorkspaceHandleSchema.parse({
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceIdentity: 'identity-1',
|
||||
canonicalDisplayPath: '/srv/project',
|
||||
access: 'read-only',
|
||||
git: 'available',
|
||||
capabilities: ['list', 'write-text-atomic'],
|
||||
generation: 1
|
||||
})
|
||||
).toThrow(/Read-only/iu)
|
||||
})
|
||||
|
||||
it('checks response byte counts instead of UTF-16 character counts', () => {
|
||||
expect(
|
||||
remoteWorkspaceReadTextResultSchema.parse({
|
||||
relativePath: '你好.txt',
|
||||
content: '你好',
|
||||
offsetBytes: 0,
|
||||
bytesRead: 6,
|
||||
totalBytes: 6,
|
||||
digest,
|
||||
truncated: false
|
||||
}).bytesRead
|
||||
).toBe(6)
|
||||
expect(() =>
|
||||
remoteWorkspaceReadTextResultSchema.parse({
|
||||
relativePath: '你好.txt',
|
||||
content: '你好',
|
||||
offsetBytes: 0,
|
||||
bytesRead: 2,
|
||||
totalBytes: 6,
|
||||
digest,
|
||||
truncated: false
|
||||
})
|
||||
).toThrow(/UTF-8/iu)
|
||||
expect(() =>
|
||||
remoteGitDiffResultSchema.parse({
|
||||
repositoryIdentity: 'repository-1',
|
||||
patch: '你好',
|
||||
byteLength: 2,
|
||||
truncated: false
|
||||
})
|
||||
).toThrow(/UTF-8/iu)
|
||||
})
|
||||
|
||||
it('creates a stable write digest without accepting a controller identity', async () => {
|
||||
const request = await createRemoteWorkspaceWriteTextAtomicRequest({
|
||||
operationId: 'operation-1',
|
||||
workspaceIdentity: 'identity-1',
|
||||
workspaceId: 'workspace-1',
|
||||
generation: 1,
|
||||
relativePath: 'src/a.ts',
|
||||
content: '你好',
|
||||
expectedDigest: 'absent',
|
||||
executable: false
|
||||
})
|
||||
expect(request.payloadDigest).toBe(
|
||||
'sha256:0cf6995c6aa406a5bb1b6a0d2e15955ca3b89ddec26a20f47eaacef02339bea6'
|
||||
)
|
||||
const reordered = await createRemoteWorkspaceWriteTextAtomicRequest({
|
||||
content: '你好',
|
||||
relativePath: 'src/a.ts',
|
||||
generation: 1,
|
||||
workspaceId: 'workspace-1',
|
||||
executable: false,
|
||||
expectedDigest: 'absent',
|
||||
workspaceIdentity: 'identity-1',
|
||||
operationId: 'operation-1'
|
||||
})
|
||||
expect(reordered).toEqual(request)
|
||||
expect(request).not.toHaveProperty('controllerId')
|
||||
await expect(
|
||||
assertRemoteWorkspaceWriteTextAtomicDigest(
|
||||
{ ...request, content: 'changed after digest' },
|
||||
'identity-1'
|
||||
)
|
||||
).rejects.toThrow(/digest/iu)
|
||||
})
|
||||
|
||||
it('enforces idempotent write terminal metadata and bounded unique change sets', () => {
|
||||
expect(
|
||||
remoteWorkspaceWriteResultSchema.parse({
|
||||
operationId: 'operation-1',
|
||||
payloadDigest: digest,
|
||||
status: 'completed',
|
||||
relativePath: 'src/a.ts',
|
||||
previousDigest: 'absent',
|
||||
resultDigest: digest,
|
||||
byteLength: 3
|
||||
}).status
|
||||
).toBe('completed')
|
||||
expect(() =>
|
||||
remoteWorkspaceWriteResultSchema.parse({
|
||||
operationId: 'operation-1',
|
||||
payloadDigest: digest,
|
||||
status: 'completed',
|
||||
relativePath: 'src/a.ts'
|
||||
})
|
||||
).toThrow(/metadata/iu)
|
||||
expect(() =>
|
||||
remoteWorkspaceApplyChangeSetRequestSchema.parse({
|
||||
operationId: 'operation-1',
|
||||
payloadDigest: digest,
|
||||
workspaceId: 'workspace-1',
|
||||
generation: 1,
|
||||
workspaceIdentity: 'identity-1',
|
||||
changes: [
|
||||
{
|
||||
relativePath: 'a.ts',
|
||||
action: 'delete',
|
||||
expectedDigest: digest
|
||||
},
|
||||
{
|
||||
relativePath: 'a.ts',
|
||||
action: 'delete',
|
||||
expectedDigest: digest
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/unique/iu)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
import { z } from 'zod'
|
||||
import { componentVersionSchema } from './agent-installation-contracts'
|
||||
import {
|
||||
agentIdentifierSchema,
|
||||
sha256DigestSchema
|
||||
} from './agent-protocol/contracts'
|
||||
|
||||
export const installationRegistryIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u)
|
||||
|
||||
export const installationRegistryEntrySchema = z
|
||||
.object({
|
||||
installationId: installationRegistryIdSchema,
|
||||
agentVersion: componentVersionSchema,
|
||||
manifestSha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
arch: z.enum(['x64', 'arm64'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type InstallationRegistryEntry = z.infer<
|
||||
typeof installationRegistryEntrySchema
|
||||
>
|
||||
|
||||
export const installationRegistryStateSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
current: installationRegistryEntrySchema.optional(),
|
||||
candidate: installationRegistryEntrySchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((state, context) => {
|
||||
const entries = [
|
||||
state.current,
|
||||
state.candidate
|
||||
].filter((entry): entry is InstallationRegistryEntry =>
|
||||
entry !== undefined
|
||||
)
|
||||
const ids = new Map<string, InstallationRegistryEntry>()
|
||||
for (const entry of entries) {
|
||||
const existing = ids.get(entry.installationId)
|
||||
if (
|
||||
existing !== undefined &&
|
||||
!installationEntryEquals(existing, entry)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['candidate'],
|
||||
message: 'Agent installation identity is inconsistent across roles'
|
||||
})
|
||||
}
|
||||
ids.set(entry.installationId, entry)
|
||||
}
|
||||
if (
|
||||
state.current !== undefined &&
|
||||
state.candidate !== undefined &&
|
||||
state.current.installationId === state.candidate.installationId
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['candidate'],
|
||||
message: 'Current Agent installation cannot also be the candidate'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type InstallationRegistryState = z.infer<
|
||||
typeof installationRegistryStateSchema
|
||||
>
|
||||
|
||||
const compatibleInstallationRegistryStateSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
current: installationRegistryEntrySchema
|
||||
.passthrough()
|
||||
.optional(),
|
||||
candidate: installationRegistryEntrySchema
|
||||
.passthrough()
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((state, context) => {
|
||||
const legacyEntry = [state.current, state.candidate].some(
|
||||
(entry) =>
|
||||
entry !== undefined &&
|
||||
(
|
||||
'releaseSequence' in entry ||
|
||||
'productVersion' in entry ||
|
||||
'binaryDigest' in entry
|
||||
)
|
||||
)
|
||||
if (
|
||||
!legacyEntry &&
|
||||
!('minimumTrustedReleaseSequence' in state) &&
|
||||
!('draining' in state) &&
|
||||
!('rollback' in state)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Agent registry is not a recognized legacy format'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export function parseInstallationRegistryState(
|
||||
value: unknown
|
||||
): InstallationRegistryState {
|
||||
const current = installationRegistryStateSchema.safeParse(value)
|
||||
if (current.success) {
|
||||
return current.data
|
||||
}
|
||||
const compatible =
|
||||
compatibleInstallationRegistryStateSchema.parse(value)
|
||||
return installationRegistryStateSchema.parse({
|
||||
formatVersion: 1,
|
||||
...(compatible.current === undefined
|
||||
? {}
|
||||
: {
|
||||
current: canonicalInstallationEntry(compatible.current)
|
||||
}),
|
||||
...(compatible.candidate === undefined
|
||||
? {}
|
||||
: {
|
||||
candidate: canonicalInstallationEntry(
|
||||
compatible.candidate
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const runtimeRegistryEntrySchema = z
|
||||
.object({
|
||||
runtimeId: agentIdentifierSchema,
|
||||
runtimeVersion: componentVersionSchema,
|
||||
architecture: z.enum(['x64', 'arm64']),
|
||||
bundleDigest: sha256DigestSchema,
|
||||
manifestDigest: sha256DigestSchema,
|
||||
acpCapabilitiesDigest: sha256DigestSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RuntimeRegistryEntry = z.infer<
|
||||
typeof runtimeRegistryEntrySchema
|
||||
>
|
||||
|
||||
export const runtimeRegistryStateSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
current: z.array(runtimeRegistryEntrySchema).max(16)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((state, context) => {
|
||||
const identities = new Set<string>()
|
||||
state.current.forEach((entry, index) => {
|
||||
const identity = `${entry.runtimeId}:${entry.architecture}`
|
||||
if (identities.has(identity)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['current', index],
|
||||
message: 'Runtime registry identities must be unique'
|
||||
})
|
||||
}
|
||||
identities.add(identity)
|
||||
})
|
||||
})
|
||||
|
||||
export type RuntimeRegistryState = z.infer<
|
||||
typeof runtimeRegistryStateSchema
|
||||
>
|
||||
|
||||
const compatibleRuntimeRegistryStateSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
current: z
|
||||
.array(runtimeRegistryEntrySchema.passthrough())
|
||||
.max(16)
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((state, context) => {
|
||||
const legacyEntry = state.current.some(
|
||||
(entry) =>
|
||||
'releaseSequence' in entry ||
|
||||
'signingKeyId' in entry ||
|
||||
'provider' in entry
|
||||
)
|
||||
if (
|
||||
!legacyEntry &&
|
||||
!('minimumTrustedReleaseSequence' in state)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Runtime registry is not a recognized legacy format'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export function parseRuntimeRegistryState(
|
||||
value: unknown
|
||||
): RuntimeRegistryState {
|
||||
const current = runtimeRegistryStateSchema.safeParse(value)
|
||||
if (current.success) {
|
||||
return current.data
|
||||
}
|
||||
const compatible =
|
||||
compatibleRuntimeRegistryStateSchema.parse(value)
|
||||
return runtimeRegistryStateSchema.parse({
|
||||
formatVersion: 1,
|
||||
current: compatible.current.map(canonicalRuntimeEntry)
|
||||
})
|
||||
}
|
||||
|
||||
function canonicalInstallationEntry(
|
||||
entry: z.infer<typeof installationRegistryEntrySchema> &
|
||||
Record<string, unknown>
|
||||
): InstallationRegistryEntry {
|
||||
return installationRegistryEntrySchema.parse({
|
||||
installationId: entry.installationId,
|
||||
agentVersion: entry.agentVersion,
|
||||
manifestSha256: entry.manifestSha256,
|
||||
arch: entry.arch
|
||||
})
|
||||
}
|
||||
|
||||
function canonicalRuntimeEntry(
|
||||
entry: z.infer<typeof runtimeRegistryEntrySchema> &
|
||||
Record<string, unknown>
|
||||
): RuntimeRegistryEntry {
|
||||
return runtimeRegistryEntrySchema.parse({
|
||||
runtimeId: entry.runtimeId,
|
||||
runtimeVersion: entry.runtimeVersion,
|
||||
architecture: entry.architecture,
|
||||
bundleDigest: entry.bundleDigest,
|
||||
manifestDigest: entry.manifestDigest,
|
||||
acpCapabilitiesDigest: entry.acpCapabilitiesDigest
|
||||
})
|
||||
}
|
||||
|
||||
function installationEntryEquals(
|
||||
left: InstallationRegistryEntry,
|
||||
right: InstallationRegistryEntry
|
||||
): boolean {
|
||||
return (
|
||||
left.installationId === right.installationId &&
|
||||
left.agentVersion === right.agentVersion &&
|
||||
left.manifestSha256 === right.manifestSha256 &&
|
||||
left.arch === right.arch
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
REMOTE_MODEL_GATEWAY_LIMITS,
|
||||
remoteModelGatewayRequestSchema,
|
||||
remoteModelGatewayResponseSchema
|
||||
} from './remote-model-gateway-contracts'
|
||||
|
||||
const validRequest = {
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
bodyBase64: Buffer.from('{"messages":[]}').toString('base64')
|
||||
} as const
|
||||
|
||||
describe('remote model gateway contracts', () => {
|
||||
it('accepts only POST requests to fixed provider API paths', () => {
|
||||
expect(remoteModelGatewayRequestSchema.parse(validRequest)).toEqual(
|
||||
validRequest
|
||||
)
|
||||
for (const path of [
|
||||
'https://api.example/v1/messages',
|
||||
'//api.example/v1/messages',
|
||||
'/v1/messages?key=secret',
|
||||
'/v1/messages#fragment',
|
||||
'/v1/../messages',
|
||||
'/images/generations'
|
||||
]) {
|
||||
expect(() =>
|
||||
remoteModelGatewayRequestSchema.parse({ ...validRequest, path })
|
||||
).toThrow()
|
||||
}
|
||||
expect(() =>
|
||||
remoteModelGatewayRequestSchema.parse({
|
||||
...validRequest,
|
||||
method: 'GET'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects credentials and arbitrary headers', () => {
|
||||
for (const headers of [
|
||||
{ authorization: 'Bearer remote-secret' },
|
||||
{ 'x-api-key': 'remote-secret' },
|
||||
{ cookie: 'private=value' },
|
||||
{ Accept: 'application/json' },
|
||||
{ accept: 'ok\r\nx-injected: true' }
|
||||
]) {
|
||||
expect(() =>
|
||||
remoteModelGatewayRequestSchema.parse({ ...validRequest, headers })
|
||||
).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces decoded byte limits and canonical base64', () => {
|
||||
expect(() =>
|
||||
remoteModelGatewayRequestSchema.parse({
|
||||
...validRequest,
|
||||
bodyBase64: Buffer.alloc(
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes + 1
|
||||
).toString('base64')
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
remoteModelGatewayRequestSchema.parse({
|
||||
...validRequest,
|
||||
bodyBase64: 'not base64'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
remoteModelGatewayResponseSchema.parse({
|
||||
status: 200,
|
||||
headers: { authorization: 'Bearer secret' },
|
||||
bodyBase64: ''
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { z } from 'zod'
|
||||
import { utf8StringSchema } from './agent-protocol/contracts'
|
||||
|
||||
export const REMOTE_MODEL_GATEWAY_LIMITS = {
|
||||
maximumRequestBodyBytes: 768 * 1024,
|
||||
maximumResponseBodyBytes: 768 * 1024,
|
||||
maximumHeaderValueBytes: 1_024
|
||||
} as const
|
||||
|
||||
const requestHeaderNameSchema = z.enum([
|
||||
'accept',
|
||||
'content-type'
|
||||
])
|
||||
|
||||
const requestHeaderValueSchema = utf8StringSchema(
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes,
|
||||
{
|
||||
minimumBytes: 1,
|
||||
label: 'Remote model request header value'
|
||||
}
|
||||
).refine(
|
||||
(value) => !/[\r\n]/u.test(value),
|
||||
'Header values cannot contain line breaks'
|
||||
)
|
||||
|
||||
const responseHeaderNameSchema = z.enum([
|
||||
'content-type',
|
||||
'openai-request-id',
|
||||
'request-id',
|
||||
'x-request-id'
|
||||
])
|
||||
|
||||
function boundedCanonicalBase64Schema(maximumBytes: number) {
|
||||
return z
|
||||
.string()
|
||||
.max(Math.ceil(maximumBytes / 3) * 4)
|
||||
.regex(
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u,
|
||||
'Body must use canonical base64'
|
||||
)
|
||||
.refine(
|
||||
(value) => Buffer.byteLength(value, 'base64') <= maximumBytes,
|
||||
`Decoded body exceeds ${maximumBytes} bytes`
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
Buffer.from(value, 'base64').toString('base64') === value,
|
||||
'Body must use canonical base64'
|
||||
)
|
||||
}
|
||||
|
||||
export const remoteModelApiPathSchema = z.enum([
|
||||
'/v1/messages',
|
||||
'/chat/completions',
|
||||
'/v1/chat/completions',
|
||||
'/responses',
|
||||
'/v1/responses'
|
||||
])
|
||||
export type RemoteModelApiPath = z.infer<typeof remoteModelApiPathSchema>
|
||||
|
||||
export const remoteModelRequestHeadersSchema = z
|
||||
.partialRecord(requestHeaderNameSchema, requestHeaderValueSchema)
|
||||
.superRefine((headers, context) => {
|
||||
for (const name of Object.keys(headers)) {
|
||||
if (name !== name.toLowerCase()) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: [name],
|
||||
message: 'Header names must be lowercase'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const remoteModelGatewayRequestSchema = z
|
||||
.object({
|
||||
method: z.literal('POST'),
|
||||
path: remoteModelApiPathSchema,
|
||||
headers: remoteModelRequestHeadersSchema,
|
||||
bodyBase64: boundedCanonicalBase64Schema(
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
export type RemoteModelGatewayRequest = z.infer<
|
||||
typeof remoteModelGatewayRequestSchema
|
||||
>
|
||||
|
||||
export const remoteModelResponseHeadersSchema = z.partialRecord(
|
||||
responseHeaderNameSchema,
|
||||
requestHeaderValueSchema
|
||||
)
|
||||
|
||||
export const remoteModelGatewayResponseSchema = z
|
||||
.object({
|
||||
status: z.number().int().min(100).max(599),
|
||||
headers: remoteModelResponseHeadersSchema,
|
||||
bodyBase64: boundedCanonicalBase64Schema(
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
export type RemoteModelGatewayResponse = z.infer<
|
||||
typeof remoteModelGatewayResponseSchema
|
||||
>
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
REMOTE_PROJECT_SAVE_LIMITS,
|
||||
remoteProjectCreateDraftSchema,
|
||||
remoteProjectSaveProgressSchema,
|
||||
remoteProjectSaveRequestSchema,
|
||||
remoteProjectUpdateDraftSchema
|
||||
} from './remote-project-candidate-contracts'
|
||||
|
||||
const hostId = '00000000-0000-4000-8000-000000000010'
|
||||
const projectId = '00000000-0000-4000-8000-000000000020'
|
||||
const profileId = '00000000-0000-4000-8000-000000000040'
|
||||
const draft = {
|
||||
name: '远程项目',
|
||||
description: 'Awaited save',
|
||||
defaultWorkMode: 'ask' as const,
|
||||
runtimeSelection: { provider: 'model' as const, profileId },
|
||||
hostId,
|
||||
remoteRootPath: '/srv/projects/goodbuddy'
|
||||
}
|
||||
|
||||
describe('remote project save contracts', () => {
|
||||
it('accepts closed create and update save requests', () => {
|
||||
expect(
|
||||
remoteProjectSaveRequestSchema.parse({
|
||||
intent: 'create',
|
||||
draft
|
||||
})
|
||||
).toEqual({ intent: 'create', draft })
|
||||
expect(
|
||||
remoteProjectSaveRequestSchema.parse({
|
||||
intent: 'update',
|
||||
draft: { projectId, ...draft }
|
||||
})
|
||||
).toEqual({ intent: 'update', draft: { projectId, ...draft } })
|
||||
expect(() =>
|
||||
remoteProjectCreateDraftSchema.parse({
|
||||
...draft,
|
||||
candidateId: projectId
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
remoteProjectUpdateDraftSchema.parse({
|
||||
projectId,
|
||||
...draft,
|
||||
commitToken: 'token'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('accepts Execute without consent or trust fields', () => {
|
||||
expect(
|
||||
remoteProjectCreateDraftSchema.parse({
|
||||
...draft,
|
||||
defaultWorkMode: 'execute'
|
||||
}).defaultWorkMode
|
||||
).toBe('execute')
|
||||
expect(() =>
|
||||
remoteProjectCreateDraftSchema.parse({
|
||||
...draft,
|
||||
trust: {},
|
||||
defaultWorkMode: 'execute'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('requires a Runtime and a bounded absolute remote root', () => {
|
||||
const withoutRuntime: Partial<typeof draft> = { ...draft }
|
||||
delete withoutRuntime.runtimeSelection
|
||||
expect(() =>
|
||||
remoteProjectCreateDraftSchema.parse(withoutRuntime)
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
remoteProjectCreateDraftSchema.parse({
|
||||
...draft,
|
||||
remoteRootPath: 'srv/project'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
remoteProjectCreateDraftSchema.parse({
|
||||
...draft,
|
||||
remoteRootPath: '/srv/\nprivate'
|
||||
})
|
||||
).toThrow(/control characters/iu)
|
||||
for (const remoteRootPath of [
|
||||
'/srv//project',
|
||||
'/srv/./project',
|
||||
'/srv/../project'
|
||||
]) {
|
||||
expect(() =>
|
||||
remoteProjectCreateDraftSchema.parse({
|
||||
...draft,
|
||||
remoteRootPath
|
||||
})
|
||||
).toThrow(/empty, dot, or parent segments/iu)
|
||||
}
|
||||
expect(() =>
|
||||
remoteProjectCreateDraftSchema.parse({
|
||||
...draft,
|
||||
remoteRootPath: `/${'界'.repeat(
|
||||
Math.floor(
|
||||
REMOTE_PROJECT_SAVE_LIMITS.maximumAbsolutePathBytes / 3
|
||||
) + 1
|
||||
)}`
|
||||
})
|
||||
).toThrow(/4096 UTF-8 bytes/iu)
|
||||
})
|
||||
|
||||
it('exposes only an ephemeral progress phase', () => {
|
||||
expect(remoteProjectSaveProgressSchema.parse({ phase: 'agent' })).toEqual({
|
||||
phase: 'agent'
|
||||
})
|
||||
expect(() =>
|
||||
remoteProjectSaveProgressSchema.parse({
|
||||
phase: 'agent',
|
||||
candidateId: projectId
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { z } from 'zod'
|
||||
import { assistantIdSchema, projectCreateSchema } from './assistant-contracts'
|
||||
import {
|
||||
REMOTE_WORKSPACE_LIMITS,
|
||||
remoteAbsolutePathSchema
|
||||
} from './remote-agent-contracts'
|
||||
import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
|
||||
import { sshHostIdSchema } from './ssh-host-contracts'
|
||||
|
||||
export const REMOTE_PROJECT_SAVE_LIMITS = {
|
||||
maximumAbsolutePathBytes:
|
||||
REMOTE_WORKSPACE_LIMITS.maximumAbsolutePathBytes
|
||||
} as const
|
||||
|
||||
export const remoteProjectRootPathSchema = remoteAbsolutePathSchema
|
||||
.refine(
|
||||
(value) => !/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(value),
|
||||
'Remote project root cannot contain control characters'
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.includes('//') &&
|
||||
!value
|
||||
.split('/')
|
||||
.some((segment) => segment === '.' || segment === '..'),
|
||||
'Remote project root cannot contain empty, dot, or parent segments'
|
||||
)
|
||||
|
||||
const commonDraftFields = {
|
||||
name: projectCreateSchema.shape.name,
|
||||
description: projectCreateSchema.shape.description,
|
||||
defaultWorkMode: projectCreateSchema.shape.defaultWorkMode,
|
||||
runtimeSelection: agentRuntimeSelectionSchema,
|
||||
hostId: sshHostIdSchema,
|
||||
remoteRootPath: remoteProjectRootPathSchema
|
||||
} as const
|
||||
|
||||
export const remoteProjectCreateDraftSchema =
|
||||
z.object(commonDraftFields).strict()
|
||||
|
||||
export const remoteProjectUpdateDraftSchema = z
|
||||
.object({
|
||||
projectId: assistantIdSchema,
|
||||
...commonDraftFields
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const remoteProjectSaveRequestSchema = z.discriminatedUnion(
|
||||
'intent',
|
||||
[
|
||||
z
|
||||
.object({
|
||||
intent: z.literal('create'),
|
||||
draft: remoteProjectCreateDraftSchema
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
intent: z.literal('update'),
|
||||
draft: remoteProjectUpdateDraftSchema
|
||||
})
|
||||
.strict()
|
||||
]
|
||||
)
|
||||
|
||||
export const remoteProjectSavePhaseSchema = z.enum([
|
||||
'host',
|
||||
'agent',
|
||||
'workspace',
|
||||
'runtime',
|
||||
'saving'
|
||||
])
|
||||
|
||||
export const remoteProjectSaveProgressSchema = z
|
||||
.object({
|
||||
phase: remoteProjectSavePhaseSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RemoteProjectCreateDraft = z.infer<
|
||||
typeof remoteProjectCreateDraftSchema
|
||||
>
|
||||
export type RemoteProjectUpdateDraft = z.infer<
|
||||
typeof remoteProjectUpdateDraftSchema
|
||||
>
|
||||
export type RemoteProjectSaveRequest = z.infer<
|
||||
typeof remoteProjectSaveRequestSchema
|
||||
>
|
||||
export type RemoteProjectSavePhase = z.infer<
|
||||
typeof remoteProjectSavePhaseSchema
|
||||
>
|
||||
export type RemoteProjectSaveProgress = z.infer<
|
||||
typeof remoteProjectSaveProgressSchema
|
||||
>
|
||||
@@ -0,0 +1,548 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createOperationIdentity } from './agent-protocol/canonical'
|
||||
import {
|
||||
REMOTE_RUNTIME_LAUNCH_LIMITS,
|
||||
assertDetachedRemoteRuntimeBundleDigest,
|
||||
assertRemoteRuntimeOperationDigest,
|
||||
createRemoteRuntimeQueryRequest,
|
||||
createRemoteRuntimeStartRequest,
|
||||
createRemoteRuntimeStopRequest,
|
||||
detachedSignedRemoteRuntimeBundleSchema,
|
||||
digestRemoteRuntimeBundleIdentity,
|
||||
digestRemoteRuntimeBundleManifest,
|
||||
remoteRuntimeQueryRequestSchema,
|
||||
remoteRuntimeQueryResultSchema,
|
||||
remoteRuntimeStartRequestSchema,
|
||||
remoteRuntimeStartResultSchema,
|
||||
remoteRuntimeStopRequestSchema,
|
||||
remoteRuntimeStopResultSchema
|
||||
} from './remote-runtime-launch-contracts'
|
||||
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
const rawDigest = 'a'.repeat(64)
|
||||
const deadlineAt = '2030-01-01T00:00:00.000Z'
|
||||
|
||||
const manifest = {
|
||||
formatVersion: 2 as const,
|
||||
product: 'GoodBuddy' as const,
|
||||
runtimeId: 'runtime-1',
|
||||
runtimeVersion: '1.0.0',
|
||||
provider: 'opencode' as const,
|
||||
platform: 'linux' as const,
|
||||
architecture: 'x64' as const,
|
||||
signingKeyId: 'release-1',
|
||||
bundleDigest: digest,
|
||||
adapterDigest: digest,
|
||||
sourcePackage: {
|
||||
name: 'opencode-linux-x64-baseline',
|
||||
integrity:
|
||||
'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ=='
|
||||
},
|
||||
entrypoint: {
|
||||
identity: 'opencode-acp',
|
||||
path: 'bin/opencode-acp',
|
||||
sha256: rawDigest,
|
||||
argvPrefix: ['acp']
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'bin/opencode-acp',
|
||||
size: 100,
|
||||
sha256: rawDigest,
|
||||
mode: '0755' as const
|
||||
},
|
||||
{
|
||||
path: 'licenses/runtime.txt',
|
||||
size: 10,
|
||||
sha256: rawDigest,
|
||||
mode: '0644' as const
|
||||
}
|
||||
],
|
||||
licenses: [
|
||||
{
|
||||
package: 'runtime',
|
||||
version: '1.0.0',
|
||||
spdx: 'MIT',
|
||||
path: 'licenses/runtime.txt'
|
||||
}
|
||||
],
|
||||
allowedEnvironmentNames: ['HOME', 'LANG'] as const,
|
||||
protocol: { major: 1, minor: 0 },
|
||||
acpCapabilitiesDigest: digest,
|
||||
limits: {
|
||||
maximumPromptRuntimeMilliseconds: 60_000,
|
||||
maximumPromptInputBytes: 4096,
|
||||
maximumPromptOutputBytes: 1024
|
||||
}
|
||||
}
|
||||
|
||||
const signedBundle = {
|
||||
manifest,
|
||||
detachedSignature: {
|
||||
manifestDigest: digest,
|
||||
algorithm: 'ed25519' as const,
|
||||
keyId: 'release-1',
|
||||
signatureBase64: `${'A'.repeat(86)}==`
|
||||
}
|
||||
}
|
||||
|
||||
const commonPreparation = {
|
||||
bindingId: 'binding-1',
|
||||
operationId: 'operation-1',
|
||||
requestId: 'request-1',
|
||||
workMode: 'ask' as const,
|
||||
controllerId: 'controller-1',
|
||||
controllerGeneration: 1,
|
||||
connectionGeneration: 1,
|
||||
channelEpoch: '1',
|
||||
hostId: 'host-1',
|
||||
hostRevision: 1,
|
||||
hostKeyGeneration: 1,
|
||||
workspaceIdentity: 'workspace-1',
|
||||
agentInstallationId: 'installation-1',
|
||||
runtimeId: 'runtime-1',
|
||||
runtimeBundleDigest: digest,
|
||||
runtimeAdapterDigest: digest,
|
||||
deadlineAt,
|
||||
budget: {
|
||||
maximumInputBytes: 10,
|
||||
maximumOutputBytes: 100
|
||||
}
|
||||
}
|
||||
|
||||
const startPayload = {
|
||||
bindingId: 'binding-1',
|
||||
requestId: 'request-1',
|
||||
workMode: 'ask' as const,
|
||||
runtimeId: 'runtime-1',
|
||||
adapterParameters: [
|
||||
{ name: 'modelProfileId' as const, value: 'model-1' }
|
||||
],
|
||||
deadlineAt,
|
||||
budget: commonPreparation.budget
|
||||
}
|
||||
|
||||
async function operationRequest<T>(
|
||||
method: string,
|
||||
operationId: string,
|
||||
requestId: string,
|
||||
payload: T
|
||||
) {
|
||||
return {
|
||||
identity: await createOperationIdentity({
|
||||
controllerId: 'controller-1',
|
||||
operationId,
|
||||
scope: {
|
||||
kind: 'run',
|
||||
sessionId: 'binding-1',
|
||||
requestId
|
||||
},
|
||||
method,
|
||||
payload
|
||||
}),
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
const forbiddenLaunchFields = [
|
||||
{ executable: '/bin/sh' },
|
||||
{ cwd: '/tmp' },
|
||||
{ env: { TOKEN: 'secret' } },
|
||||
{ unit: 'runtime.service' },
|
||||
{ systemdProperties: ['Delegate=yes'] },
|
||||
{ network: 'host' },
|
||||
{ mount: '/etc:/host' },
|
||||
{ mounts: ['/etc:/host'] },
|
||||
{ shell: true },
|
||||
{ pid: 123 },
|
||||
{ signal: 'SIGKILL' }
|
||||
]
|
||||
|
||||
describe('remote Runtime launch contracts', () => {
|
||||
it.each(['deepseek-harness', 'opencode', 'continue'] as const)(
|
||||
'accepts a detached-signed %s bundle without signature metadata in signed bytes',
|
||||
(provider) => {
|
||||
const parsed = detachedSignedRemoteRuntimeBundleSchema.parse({
|
||||
...signedBundle,
|
||||
manifest: { ...manifest, provider }
|
||||
})
|
||||
expect(parsed.manifest.provider).toBe(provider)
|
||||
expect(parsed.manifest).not.toHaveProperty('manifestDigest')
|
||||
expect(parsed.manifest).not.toHaveProperty('signature')
|
||||
}
|
||||
)
|
||||
|
||||
it('binds detached keys, entrypoint files, licenses, and unique environment names', () => {
|
||||
expect(() =>
|
||||
detachedSignedRemoteRuntimeBundleSchema.parse({
|
||||
...signedBundle,
|
||||
detachedSignature: {
|
||||
...signedBundle.detachedSignature,
|
||||
keyId: 'other-key'
|
||||
}
|
||||
})
|
||||
).toThrow(/key/iu)
|
||||
expect(() =>
|
||||
detachedSignedRemoteRuntimeBundleSchema.parse({
|
||||
...signedBundle,
|
||||
manifest: {
|
||||
...manifest,
|
||||
entrypoint: { ...manifest.entrypoint, sha256: 'b'.repeat(64) }
|
||||
}
|
||||
})
|
||||
).toThrow(/entrypoint/iu)
|
||||
expect(() =>
|
||||
detachedSignedRemoteRuntimeBundleSchema.parse({
|
||||
...signedBundle,
|
||||
manifest: {
|
||||
...manifest,
|
||||
licenses: [
|
||||
{ ...manifest.licenses[0], path: 'licenses/missing.txt' }
|
||||
]
|
||||
}
|
||||
})
|
||||
).toThrow(/licenses/iu)
|
||||
expect(() =>
|
||||
detachedSignedRemoteRuntimeBundleSchema.parse({
|
||||
...signedBundle,
|
||||
manifest: {
|
||||
...manifest,
|
||||
allowedEnvironmentNames: ['HOME', 'HOME']
|
||||
}
|
||||
})
|
||||
).toThrow(/unique/iu)
|
||||
})
|
||||
|
||||
it('bounds prompt limits and fixed argv', () => {
|
||||
expect(
|
||||
detachedSignedRemoteRuntimeBundleSchema.parse(signedBundle)
|
||||
).toEqual(signedBundle)
|
||||
expect(() =>
|
||||
detachedSignedRemoteRuntimeBundleSchema.parse({
|
||||
...signedBundle,
|
||||
manifest: {
|
||||
...manifest,
|
||||
limits: {
|
||||
...manifest.limits,
|
||||
maximumPromptInputBytes:
|
||||
REMOTE_RUNTIME_LAUNCH_LIMITS.maximumPromptInputBytes + 1
|
||||
}
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
detachedSignedRemoteRuntimeBundleSchema.parse({
|
||||
...signedBundle,
|
||||
manifest: {
|
||||
...manifest,
|
||||
entrypoint: {
|
||||
...manifest.entrypoint,
|
||||
argvPrefix: Array(
|
||||
REMOTE_RUNTIME_LAUNCH_LIMITS.maximumFixedArgumentCount + 1
|
||||
).fill('x')
|
||||
}
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('keeps one current prompt-limits manifest contract', () => {
|
||||
const parsed = detachedSignedRemoteRuntimeBundleSchema.parse({
|
||||
...signedBundle,
|
||||
manifest
|
||||
})
|
||||
expect(parsed.manifest.formatVersion).toBe(2)
|
||||
expect(parsed.manifest).toHaveProperty('limits')
|
||||
expect(Object.keys(parsed.manifest)).not.toContain('quotas')
|
||||
})
|
||||
|
||||
it('verifies the detached digest over manifest bytes only', async () => {
|
||||
const bundleDigest =
|
||||
await digestRemoteRuntimeBundleIdentity(manifest)
|
||||
const digestBoundManifest = { ...manifest, bundleDigest }
|
||||
const manifestDigest =
|
||||
await digestRemoteRuntimeBundleManifest(digestBoundManifest)
|
||||
const validBundle = {
|
||||
...signedBundle,
|
||||
manifest: digestBoundManifest,
|
||||
detachedSignature: {
|
||||
...signedBundle.detachedSignature,
|
||||
manifestDigest
|
||||
}
|
||||
}
|
||||
await expect(
|
||||
assertDetachedRemoteRuntimeBundleDigest(validBundle)
|
||||
).resolves.toEqual(validBundle)
|
||||
await expect(
|
||||
assertDetachedRemoteRuntimeBundleDigest(signedBundle)
|
||||
).rejects.toThrow(/digest does not match/iu)
|
||||
})
|
||||
|
||||
it('constructs the exact start operation identity and canonical digest', async () => {
|
||||
const request = await createRemoteRuntimeStartRequest({
|
||||
controllerId: 'controller-1',
|
||||
operationId: 'start-operation-1',
|
||||
prompt: commonPreparation,
|
||||
adapterParameters: startPayload.adapterParameters,
|
||||
deadlineAt
|
||||
})
|
||||
expect(request.identity.method).toBe('runtime/startPrompt')
|
||||
expect(request.identity.scope).toEqual({
|
||||
kind: 'run',
|
||||
sessionId: 'binding-1',
|
||||
requestId: 'request-1'
|
||||
})
|
||||
const expected = await createOperationIdentity({
|
||||
controllerId: 'controller-1',
|
||||
operationId: 'start-operation-1',
|
||||
scope: request.identity.scope,
|
||||
method: 'runtime/startPrompt',
|
||||
payload: request.payload
|
||||
})
|
||||
expect(request.identity.payloadDigest).toBe(expected.payloadDigest)
|
||||
expect(request.payload.workMode).toBe('ask')
|
||||
await expect(
|
||||
createRemoteRuntimeStartRequest({
|
||||
controllerId: 'controller-1',
|
||||
operationId: commonPreparation.operationId,
|
||||
prompt: commonPreparation,
|
||||
deadlineAt: '2031-01-01T00:00:00.000Z'
|
||||
})
|
||||
).rejects.toThrow(/prompt deadline/iu)
|
||||
})
|
||||
|
||||
it('rejects caller digest mismatches', async () => {
|
||||
const valid = await operationRequest(
|
||||
'runtime/startPrompt',
|
||||
'start-operation-1',
|
||||
'request-1',
|
||||
startPayload
|
||||
)
|
||||
const changedPayload = {
|
||||
...startPayload,
|
||||
runtimeId: 'runtime-2'
|
||||
}
|
||||
await expect(
|
||||
assertRemoteRuntimeOperationDigest({
|
||||
...valid,
|
||||
payload: changedPayload
|
||||
})
|
||||
).rejects.toThrow(/digest does not match/iu)
|
||||
await expect(
|
||||
assertRemoteRuntimeOperationDigest(valid)
|
||||
).resolves.toBeUndefined()
|
||||
await expect(
|
||||
assertRemoteRuntimeOperationDigest({
|
||||
...valid,
|
||||
identity: { ...valid.identity, payloadDigest: digest }
|
||||
})
|
||||
).rejects.toThrow(/digest does not match/iu)
|
||||
})
|
||||
|
||||
it('rejects adapter flag and path tunneling plus all process-control fields', async () => {
|
||||
for (const value of [
|
||||
'--flag',
|
||||
'/tmp/model',
|
||||
'models/model',
|
||||
'models\\model',
|
||||
'..'
|
||||
]) {
|
||||
await expect(
|
||||
createRemoteRuntimeStartRequest({
|
||||
controllerId: 'controller-1',
|
||||
operationId: 'start-operation-1',
|
||||
prompt: commonPreparation,
|
||||
adapterParameters: [
|
||||
{ name: 'modelProfileId', value }
|
||||
],
|
||||
deadlineAt
|
||||
})
|
||||
).rejects.toThrow()
|
||||
}
|
||||
const valid = await operationRequest(
|
||||
'runtime/startPrompt',
|
||||
'start-operation-1',
|
||||
'request-1',
|
||||
startPayload
|
||||
)
|
||||
for (const forbidden of forbiddenLaunchFields) {
|
||||
expect(() =>
|
||||
remoteRuntimeStartRequestSchema.parse({
|
||||
...valid,
|
||||
payload: { ...startPayload, ...forbidden }
|
||||
})
|
||||
).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('operation-binds start results and canonical stop/query requests', async () => {
|
||||
const startResult = {
|
||||
launchId: 'launch-1',
|
||||
processId: 'process-opaque-1',
|
||||
startOperationId: 'start-operation-1',
|
||||
bindingId: 'binding-1',
|
||||
runtimeId: 'runtime-1',
|
||||
supervisorIdentityDigest: digest,
|
||||
state: 'running' as const,
|
||||
acceptedAt: deadlineAt
|
||||
}
|
||||
expect(remoteRuntimeStartResultSchema.parse(startResult)).toEqual(
|
||||
startResult
|
||||
)
|
||||
const stopPayload = {
|
||||
launchId: 'launch-1',
|
||||
bindingId: 'binding-1',
|
||||
runtimeId: 'runtime-1',
|
||||
startOperationId: 'start-operation-1',
|
||||
requestId: 'stop-request-1',
|
||||
reason: 'user-cancelled' as const,
|
||||
deadlineAt
|
||||
}
|
||||
const stop = await createRemoteRuntimeStopRequest({
|
||||
controllerId: 'controller-1',
|
||||
operationId: 'stop-1',
|
||||
payload: stopPayload
|
||||
})
|
||||
expect(remoteRuntimeStopRequestSchema.parse(stop)).toEqual(stop)
|
||||
const query = await createRemoteRuntimeQueryRequest({
|
||||
controllerId: 'controller-1',
|
||||
operationId: 'query-1',
|
||||
payload: {
|
||||
launchId: 'launch-1',
|
||||
bindingId: 'binding-1',
|
||||
runtimeId: 'runtime-1',
|
||||
startOperationId: 'start-operation-1',
|
||||
requestId: 'query-request-1'
|
||||
}
|
||||
})
|
||||
expect(remoteRuntimeQueryRequestSchema.parse(query)).toEqual(query)
|
||||
expect(() =>
|
||||
remoteRuntimeStopRequestSchema.parse({
|
||||
...stop,
|
||||
identity: { ...stop.identity, method: 'runtime/startPrompt' }
|
||||
})
|
||||
).toThrow(/method/iu)
|
||||
expect(() =>
|
||||
remoteRuntimeQueryRequestSchema.parse({
|
||||
...query,
|
||||
payload: { ...query.payload, bindingId: 'other-binding' }
|
||||
})
|
||||
).toThrow(/scope/iu)
|
||||
for (const forbidden of forbiddenLaunchFields) {
|
||||
expect(() =>
|
||||
remoteRuntimeStopRequestSchema.parse({
|
||||
...stop,
|
||||
payload: { ...stop.payload, ...forbidden }
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
remoteRuntimeQueryRequestSchema.parse({
|
||||
...query,
|
||||
payload: { ...query.payload, ...forbidden }
|
||||
})
|
||||
).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('requires ownership identity in stop results and finite reconciliation state', () => {
|
||||
const stopResult = {
|
||||
launchId: 'launch-1',
|
||||
operationId: 'stop-1',
|
||||
startOperationId: 'start-operation-1',
|
||||
bindingId: 'binding-1',
|
||||
runtimeId: 'runtime-1',
|
||||
supervisorIdentityDigest: digest,
|
||||
state: 'stopped' as const,
|
||||
acceptedAt: deadlineAt
|
||||
}
|
||||
expect(remoteRuntimeStopResultSchema.parse(stopResult)).toEqual(
|
||||
stopResult
|
||||
)
|
||||
const baseQueryResult = {
|
||||
launchId: 'launch-1',
|
||||
queryOperationId: 'query-1',
|
||||
startOperationId: 'start-operation-1',
|
||||
bindingId: 'binding-1',
|
||||
runtimeId: 'runtime-1',
|
||||
supervisorIdentityDigest: digest,
|
||||
output: {
|
||||
effectiveCapturedOutputLimitBytes: 1024,
|
||||
stdoutBytes: 10,
|
||||
stderrBytes: 2,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
expect(
|
||||
remoteRuntimeQueryResultSchema.parse({
|
||||
...baseQueryResult,
|
||||
state: 'interrupted',
|
||||
processTreeState: 'orphaned',
|
||||
errorCode: 'process-tree-orphaned'
|
||||
}).state
|
||||
).toBe('interrupted')
|
||||
expect(
|
||||
remoteRuntimeQueryResultSchema.parse({
|
||||
...baseQueryResult,
|
||||
state: 'outcome-unknown',
|
||||
processTreeState: 'outcome-unknown',
|
||||
errorCode: 'supervisor-unavailable'
|
||||
}).state
|
||||
).toBe('outcome-unknown')
|
||||
expect(() =>
|
||||
remoteRuntimeQueryResultSchema.parse({
|
||||
...baseQueryResult,
|
||||
state: 'identity-conflict',
|
||||
processTreeState: 'reconciled',
|
||||
errorCode: 'identity-mismatch'
|
||||
})
|
||||
).toThrow(/inconsistent/iu)
|
||||
for (const invalid of [
|
||||
{ state: 'running', processTreeState: 'terminated' },
|
||||
{ state: 'stopped', processTreeState: 'pending' },
|
||||
{ state: 'stopped', processTreeState: 'orphaned' }
|
||||
] as const) {
|
||||
expect(() =>
|
||||
remoteRuntimeQueryResultSchema.parse({
|
||||
...baseQueryResult,
|
||||
...invalid
|
||||
})
|
||||
).toThrow(/inconsistent/iu)
|
||||
}
|
||||
expect(() =>
|
||||
remoteRuntimeQueryResultSchema.parse({
|
||||
...baseQueryResult,
|
||||
state: 'running',
|
||||
processTreeState: 'reconciled',
|
||||
output: {
|
||||
effectiveCapturedOutputLimitBytes: 10,
|
||||
stdoutBytes: 8,
|
||||
stderrBytes: 3,
|
||||
truncated: true
|
||||
}
|
||||
})
|
||||
).toThrow(/aggregate limit/iu)
|
||||
expect(() =>
|
||||
remoteRuntimeQueryResultSchema.parse({
|
||||
...baseQueryResult,
|
||||
state: 'running',
|
||||
processTreeState: 'reconciled',
|
||||
output: {
|
||||
effectiveCapturedOutputLimitBytes:
|
||||
REMOTE_RUNTIME_LAUNCH_LIMITS.maximumCapturedOutputBytes,
|
||||
stdoutBytes:
|
||||
REMOTE_RUNTIME_LAUNCH_LIMITS.maximumCapturedOutputBytes,
|
||||
stderrBytes: 1,
|
||||
truncated: true
|
||||
}
|
||||
})
|
||||
).toThrow(/aggregate limit/iu)
|
||||
expect(() =>
|
||||
remoteRuntimeQueryResultSchema.parse({
|
||||
...baseQueryResult,
|
||||
state: 'running',
|
||||
processTreeState: 'reconciled',
|
||||
diagnostic: 'arbitrary process text'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,793 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
agentArchitectureSchema,
|
||||
agentBundleManifestSchema,
|
||||
agentManifestLicenseSchema,
|
||||
agentManifestPathSchema,
|
||||
agentProtocolVersionSchema,
|
||||
agentReleaseKeySchema
|
||||
} from './agent-installation-contracts'
|
||||
import {
|
||||
agentIdentifierSchema,
|
||||
operationIdentitySchema,
|
||||
sha256DigestSchema,
|
||||
utf8StringSchema,
|
||||
type OperationIdentity
|
||||
} from './agent-protocol/contracts'
|
||||
import {
|
||||
createOperationIdentity,
|
||||
digestCanonicalOperation
|
||||
} from './agent-protocol/canonical'
|
||||
import {
|
||||
remotePromptOperationPreparationSchema,
|
||||
type RemotePromptOperationPreparation
|
||||
} from './remote-agent-contracts'
|
||||
|
||||
export const REMOTE_RUNTIME_LAUNCH_LIMITS = {
|
||||
maximumFiles: 10_000,
|
||||
maximumLicenses: 1_000,
|
||||
maximumFixedArgumentCount: 32,
|
||||
maximumFixedArgumentBytes: 1024,
|
||||
maximumAdapterParameters: 8,
|
||||
maximumEnvironmentNames: 16,
|
||||
maximumRuntimeMilliseconds: 24 * 60 * 60 * 1000,
|
||||
maximumPromptInputBytes: 16 * 1024 * 1024,
|
||||
maximumCapturedOutputBytes: 16 * 1024 * 1024
|
||||
} as const
|
||||
|
||||
export const remoteRuntimeProviderKindSchema = z.enum([
|
||||
'deepseek-harness',
|
||||
'opencode',
|
||||
'continue'
|
||||
])
|
||||
|
||||
export const remoteRuntimeArchitectureSchema = agentArchitectureSchema
|
||||
|
||||
export const remoteRuntimeAllowedEnvironmentNameSchema = z.enum([
|
||||
'HOME',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'XDG_STATE_HOME'
|
||||
])
|
||||
|
||||
export const OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES = [
|
||||
'HOME',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'XDG_STATE_HOME'
|
||||
] as const
|
||||
|
||||
const fixedArgumentSchema = utf8StringSchema(
|
||||
REMOTE_RUNTIME_LAUNCH_LIMITS.maximumFixedArgumentBytes,
|
||||
{ label: 'Fixed Runtime argument' }
|
||||
).refine((value) => !value.includes('\0'), 'Runtime argument contains NUL')
|
||||
|
||||
const rawSha256Schema = z.string().regex(/^[a-f0-9]{64}$/u)
|
||||
const npmPackageIntegritySchema = z
|
||||
.string()
|
||||
.regex(/^sha512-[A-Za-z0-9+/]{86}==$/u)
|
||||
|
||||
export const remoteRuntimeSourcePackageSchema = z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,127}$/u),
|
||||
integrity: npmPackageIntegritySchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const remoteRuntimeBundleFileSchema = z
|
||||
.object({
|
||||
path: agentManifestPathSchema,
|
||||
size: z.number().int().min(0).safe(),
|
||||
sha256: rawSha256Schema,
|
||||
mode: z.enum(['0644', '0755'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const remoteRuntimeLimitsSchema = z
|
||||
.object({
|
||||
maximumPromptRuntimeMilliseconds: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumRuntimeMilliseconds),
|
||||
maximumPromptInputBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumPromptInputBytes),
|
||||
maximumPromptOutputBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumCapturedOutputBytes)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const remoteRuntimeManifestFields = {
|
||||
product: z.literal('GoodBuddy'),
|
||||
runtimeId: agentIdentifierSchema,
|
||||
runtimeVersion: agentBundleManifestSchema.shape.agentVersion,
|
||||
provider: remoteRuntimeProviderKindSchema,
|
||||
platform: z.literal('linux'),
|
||||
architecture: agentArchitectureSchema,
|
||||
signingKeyId: agentReleaseKeySchema.shape.keyId,
|
||||
bundleDigest: sha256DigestSchema,
|
||||
adapterDigest: sha256DigestSchema,
|
||||
sourcePackage: remoteRuntimeSourcePackageSchema,
|
||||
entrypoint: z
|
||||
.object({
|
||||
identity: agentIdentifierSchema,
|
||||
path: agentManifestPathSchema,
|
||||
sha256: rawSha256Schema,
|
||||
argvPrefix: z
|
||||
.array(fixedArgumentSchema)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumFixedArgumentCount)
|
||||
})
|
||||
.strict(),
|
||||
files: z
|
||||
.array(remoteRuntimeBundleFileSchema)
|
||||
.min(1)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumFiles),
|
||||
licenses: z
|
||||
.array(agentManifestLicenseSchema)
|
||||
.min(1)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumLicenses),
|
||||
allowedEnvironmentNames: z
|
||||
.array(remoteRuntimeAllowedEnvironmentNameSchema)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumEnvironmentNames),
|
||||
protocol: agentProtocolVersionSchema,
|
||||
acpCapabilitiesDigest: sha256DigestSchema
|
||||
} as const
|
||||
|
||||
export const remoteRuntimeBundleManifestSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(2),
|
||||
...remoteRuntimeManifestFields,
|
||||
limits: remoteRuntimeLimitsSchema
|
||||
})
|
||||
.strict()
|
||||
.superRefine((manifest, context) => {
|
||||
const files = new Map<string, string>()
|
||||
manifest.files.forEach((file, index) => {
|
||||
if (
|
||||
file.path === 'manifest.json' ||
|
||||
file.path === 'manifest.sig'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files', index, 'path'],
|
||||
message: 'Runtime payload cannot declare signature metadata'
|
||||
})
|
||||
}
|
||||
if (files.has(file.path)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['files', index, 'path'],
|
||||
message: 'Runtime bundle file paths must be unique'
|
||||
})
|
||||
}
|
||||
files.set(file.path, file.sha256)
|
||||
})
|
||||
if (files.get(manifest.entrypoint.path) !== manifest.entrypoint.sha256) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['entrypoint'],
|
||||
message: 'Runtime entrypoint must match a declared bundle file'
|
||||
})
|
||||
}
|
||||
const licensePaths = new Set<string>()
|
||||
manifest.licenses.forEach((license, index) => {
|
||||
if (licensePaths.has(license.path) || !files.has(license.path)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['licenses', index, 'path'],
|
||||
message: 'Runtime licenses must uniquely name declared files'
|
||||
})
|
||||
}
|
||||
licensePaths.add(license.path)
|
||||
})
|
||||
if (
|
||||
new Set(manifest.allowedEnvironmentNames).size !==
|
||||
manifest.allowedEnvironmentNames.length
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['allowedEnvironmentNames'],
|
||||
message: 'Allowed environment names must be unique'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type RemoteRuntimeBundleManifest = z.infer<
|
||||
typeof remoteRuntimeBundleManifestSchema
|
||||
>
|
||||
|
||||
const remoteRuntimeLockedTargetSchema = z
|
||||
.object({
|
||||
package: remoteRuntimeSourcePackageSchema.shape.name,
|
||||
integrity: npmPackageIntegritySchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const remoteRuntimeLockSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
runtimes: z
|
||||
.object({
|
||||
opencode: z
|
||||
.object({
|
||||
version: z.literal('1.18.9'),
|
||||
provider: z.literal('opencode'),
|
||||
entrypoint: z.literal('bin/opencode'),
|
||||
entrypointIdentity: z.literal('opencode-acp'),
|
||||
argvPrefix: z.tuple([z.literal('acp')]),
|
||||
allowedEnvironmentNames: z.tuple([
|
||||
z.literal('HOME'),
|
||||
z.literal('LANG'),
|
||||
z.literal('LC_ALL'),
|
||||
z.literal('PATH'),
|
||||
z.literal('TMPDIR'),
|
||||
z.literal('XDG_CACHE_HOME'),
|
||||
z.literal('XDG_CONFIG_HOME'),
|
||||
z.literal('XDG_DATA_HOME'),
|
||||
z.literal('XDG_STATE_HOME')
|
||||
]),
|
||||
protocol: agentProtocolVersionSchema,
|
||||
targets: z
|
||||
.object({
|
||||
x64: remoteRuntimeLockedTargetSchema.extend({
|
||||
package: z.literal(
|
||||
'opencode-linux-x64-baseline'
|
||||
)
|
||||
}),
|
||||
arm64: remoteRuntimeLockedTargetSchema.extend({
|
||||
package: z.literal('opencode-linux-arm64')
|
||||
})
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RemoteRuntimeLock = z.infer<typeof remoteRuntimeLockSchema>
|
||||
|
||||
export const remoteRuntimeDetachedSignatureSchema = z
|
||||
.object({
|
||||
manifestDigest: sha256DigestSchema,
|
||||
algorithm: z.literal('ed25519'),
|
||||
keyId: agentReleaseKeySchema.shape.keyId,
|
||||
signatureBase64: z
|
||||
.string()
|
||||
.regex(/^(?:[A-Za-z0-9+/]{4}){21}[A-Za-z0-9+/]{2}==$/u)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const detachedSignedRemoteRuntimeBundleSchema = z
|
||||
.object({
|
||||
manifest: remoteRuntimeBundleManifestSchema,
|
||||
detachedSignature: remoteRuntimeDetachedSignatureSchema
|
||||
})
|
||||
.strict()
|
||||
.superRefine((bundle, context) => {
|
||||
if (
|
||||
bundle.manifest.signingKeyId !== bundle.detachedSignature.keyId
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['detachedSignature', 'keyId'],
|
||||
message: 'Detached signature key does not match the manifest'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const signedRemoteRuntimeManifestSchema =
|
||||
detachedSignedRemoteRuntimeBundleSchema
|
||||
export type SignedRemoteRuntimeManifest = z.infer<
|
||||
typeof detachedSignedRemoteRuntimeBundleSchema
|
||||
>
|
||||
|
||||
export const remoteRuntimeDeadlineSchema = z
|
||||
.string()
|
||||
.datetime({ offset: true })
|
||||
.refine((value) => Number.isFinite(Date.parse(value)), 'Invalid deadline')
|
||||
|
||||
export const remoteRuntimeAdapterParameterSchema = z
|
||||
.object({
|
||||
name: z.enum([
|
||||
'modelProfileId',
|
||||
'configurationId',
|
||||
'conversationAdapterId'
|
||||
]),
|
||||
value: agentIdentifierSchema.refine(
|
||||
(value) =>
|
||||
!value.startsWith('-') &&
|
||||
!value.includes('/') &&
|
||||
!value.includes('\\') &&
|
||||
!value.includes('\0') &&
|
||||
value !== '.' &&
|
||||
value !== '..',
|
||||
'Adapter parameter must be an opaque identifier'
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const remoteRuntimeAdapterParametersSchema = z
|
||||
.array(remoteRuntimeAdapterParameterSchema)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumAdapterParameters)
|
||||
.superRefine((parameters, context) => {
|
||||
const names = new Set<string>()
|
||||
parameters.forEach((parameter, index) => {
|
||||
if (names.has(parameter.name)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: [index, 'name'],
|
||||
message: 'Adapter parameter names must be unique'
|
||||
})
|
||||
}
|
||||
names.add(parameter.name)
|
||||
})
|
||||
})
|
||||
|
||||
export const remoteRuntimeStartPayloadSchema = z
|
||||
.object({
|
||||
bindingId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema,
|
||||
workMode: z.enum(['ask', 'execute']),
|
||||
runtimeId: agentIdentifierSchema,
|
||||
adapterParameters: remoteRuntimeAdapterParametersSchema,
|
||||
deadlineAt: remoteRuntimeDeadlineSchema,
|
||||
budget: remotePromptOperationPreparationSchema.shape.budget
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RemoteRuntimeStartPayload = z.infer<
|
||||
typeof remoteRuntimeStartPayloadSchema
|
||||
>
|
||||
|
||||
export const remoteRuntimeStartRequestSchema = z
|
||||
.object({
|
||||
identity: operationIdentitySchema,
|
||||
payload: remoteRuntimeStartPayloadSchema
|
||||
})
|
||||
.strict()
|
||||
.superRefine((request, context) => {
|
||||
addOperationBindingIssues(
|
||||
request.identity,
|
||||
'runtime/startPrompt',
|
||||
request.payload.bindingId,
|
||||
request.payload.requestId,
|
||||
context
|
||||
)
|
||||
})
|
||||
|
||||
export type RemoteRuntimeStartRequest = z.infer<
|
||||
typeof remoteRuntimeStartRequestSchema
|
||||
>
|
||||
|
||||
export const remoteRuntimeStartResultSchema = z
|
||||
.object({
|
||||
launchId: agentIdentifierSchema,
|
||||
processId: agentIdentifierSchema,
|
||||
startOperationId: agentIdentifierSchema,
|
||||
bindingId: agentIdentifierSchema,
|
||||
runtimeId: agentIdentifierSchema,
|
||||
supervisorIdentityDigest: sha256DigestSchema,
|
||||
state: z.enum(['starting', 'running']),
|
||||
acceptedAt: z.string().datetime({ offset: true })
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RemoteRuntimeStartResult = z.infer<
|
||||
typeof remoteRuntimeStartResultSchema
|
||||
>
|
||||
|
||||
export const remoteRuntimeStopReasonSchema = z.enum([
|
||||
'user-cancelled',
|
||||
'deadline-exceeded',
|
||||
'binding-closed',
|
||||
'shutdown',
|
||||
'identity-conflict'
|
||||
])
|
||||
|
||||
export const remoteRuntimeStopPayloadSchema = z
|
||||
.object({
|
||||
launchId: agentIdentifierSchema,
|
||||
bindingId: agentIdentifierSchema,
|
||||
runtimeId: agentIdentifierSchema,
|
||||
startOperationId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema,
|
||||
reason: remoteRuntimeStopReasonSchema,
|
||||
deadlineAt: remoteRuntimeDeadlineSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const remoteRuntimeStopRequestSchema = z
|
||||
.object({
|
||||
identity: operationIdentitySchema,
|
||||
payload: remoteRuntimeStopPayloadSchema
|
||||
})
|
||||
.strict()
|
||||
.superRefine((request, context) => {
|
||||
addOperationBindingIssues(
|
||||
request.identity,
|
||||
'runtime/stopPrompt',
|
||||
request.payload.bindingId,
|
||||
request.payload.requestId,
|
||||
context
|
||||
)
|
||||
})
|
||||
|
||||
export const remoteRuntimeStopResultSchema = z
|
||||
.object({
|
||||
launchId: agentIdentifierSchema,
|
||||
operationId: agentIdentifierSchema,
|
||||
startOperationId: agentIdentifierSchema,
|
||||
bindingId: agentIdentifierSchema,
|
||||
runtimeId: agentIdentifierSchema,
|
||||
supervisorIdentityDigest: sha256DigestSchema,
|
||||
state: z.enum(['stop-requested', 'stopped']),
|
||||
acceptedAt: z.string().datetime({ offset: true })
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const remoteRuntimeQueryPayloadSchema = z
|
||||
.object({
|
||||
launchId: agentIdentifierSchema,
|
||||
bindingId: agentIdentifierSchema,
|
||||
runtimeId: agentIdentifierSchema,
|
||||
startOperationId: agentIdentifierSchema,
|
||||
requestId: agentIdentifierSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const remoteRuntimeQueryRequestSchema = z
|
||||
.object({
|
||||
identity: operationIdentitySchema,
|
||||
payload: remoteRuntimeQueryPayloadSchema
|
||||
})
|
||||
.strict()
|
||||
.superRefine((request, context) => {
|
||||
addOperationBindingIssues(
|
||||
request.identity,
|
||||
'runtime/queryPrompt',
|
||||
request.payload.bindingId,
|
||||
request.payload.requestId,
|
||||
context
|
||||
)
|
||||
})
|
||||
|
||||
export const remoteRuntimeLaunchStateSchema = z.enum([
|
||||
'starting',
|
||||
'running',
|
||||
'stop-requested',
|
||||
'stopped',
|
||||
'failed',
|
||||
'expired',
|
||||
'interrupted',
|
||||
'outcome-unknown',
|
||||
'identity-conflict'
|
||||
])
|
||||
|
||||
export const remoteRuntimeProcessTreeStateSchema = z.enum([
|
||||
'pending',
|
||||
'reconciled',
|
||||
'terminating',
|
||||
'terminated',
|
||||
'orphaned',
|
||||
'outcome-unknown',
|
||||
'identity-conflict'
|
||||
])
|
||||
|
||||
export const REMOTE_RUNTIME_STATE_PROCESS_TREE_MATRIX = {
|
||||
starting: ['pending', 'reconciled'],
|
||||
running: ['reconciled'],
|
||||
'stop-requested': ['reconciled', 'terminating'],
|
||||
stopped: ['terminated'],
|
||||
failed: ['pending', 'terminated', 'orphaned'],
|
||||
expired: ['terminating', 'terminated', 'orphaned'],
|
||||
interrupted: [
|
||||
'reconciled',
|
||||
'terminating',
|
||||
'terminated',
|
||||
'orphaned',
|
||||
'outcome-unknown'
|
||||
],
|
||||
'outcome-unknown': ['outcome-unknown'],
|
||||
'identity-conflict': ['identity-conflict']
|
||||
} as const satisfies Record<
|
||||
z.infer<typeof remoteRuntimeLaunchStateSchema>,
|
||||
readonly z.infer<typeof remoteRuntimeProcessTreeStateSchema>[]
|
||||
>
|
||||
|
||||
export const remoteRuntimeErrorCodeSchema = z.enum([
|
||||
'manifest-untrusted',
|
||||
'identity-mismatch',
|
||||
'deadline-exceeded',
|
||||
'output-limit-exceeded',
|
||||
'launch-failed',
|
||||
'runtime-failed',
|
||||
'supervisor-unavailable',
|
||||
'process-tree-orphaned',
|
||||
'not-found'
|
||||
])
|
||||
|
||||
export const remoteRuntimeOutputMetadataSchema = z
|
||||
.object({
|
||||
effectiveCapturedOutputLimitBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumCapturedOutputBytes),
|
||||
stdoutBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumCapturedOutputBytes),
|
||||
stderrBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(REMOTE_RUNTIME_LAUNCH_LIMITS.maximumCapturedOutputBytes),
|
||||
truncated: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((output, context) => {
|
||||
const aggregate = output.stdoutBytes + output.stderrBytes
|
||||
if (
|
||||
aggregate > output.effectiveCapturedOutputLimitBytes ||
|
||||
aggregate >
|
||||
REMOTE_RUNTIME_LAUNCH_LIMITS.maximumCapturedOutputBytes
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['effectiveCapturedOutputLimitBytes'],
|
||||
message: 'Captured Runtime output exceeds its aggregate limit'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const remoteRuntimeQueryResultSchema = z
|
||||
.object({
|
||||
launchId: agentIdentifierSchema,
|
||||
queryOperationId: agentIdentifierSchema,
|
||||
startOperationId: agentIdentifierSchema,
|
||||
bindingId: agentIdentifierSchema,
|
||||
runtimeId: agentIdentifierSchema,
|
||||
supervisorIdentityDigest: sha256DigestSchema,
|
||||
state: remoteRuntimeLaunchStateSchema,
|
||||
processTreeState: remoteRuntimeProcessTreeStateSchema,
|
||||
output: remoteRuntimeOutputMetadataSchema,
|
||||
errorCode: remoteRuntimeErrorCodeSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((result, context) => {
|
||||
const allowedProcessTreeStates =
|
||||
REMOTE_RUNTIME_STATE_PROCESS_TREE_MATRIX[result.state]
|
||||
if (
|
||||
!(allowedProcessTreeStates as readonly string[]).includes(
|
||||
result.processTreeState
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['processTreeState'],
|
||||
message:
|
||||
'Runtime launch and process-tree states are inconsistent'
|
||||
})
|
||||
}
|
||||
const needsError =
|
||||
result.state === 'failed' ||
|
||||
result.state === 'interrupted' ||
|
||||
result.state === 'outcome-unknown' ||
|
||||
result.state === 'identity-conflict'
|
||||
if (needsError !== (result.errorCode !== undefined)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['errorCode'],
|
||||
message: 'Runtime error code does not match the durable state'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type RemoteRuntimeStopRequest = z.infer<
|
||||
typeof remoteRuntimeStopRequestSchema
|
||||
>
|
||||
export type RemoteRuntimeStopResult = z.infer<
|
||||
typeof remoteRuntimeStopResultSchema
|
||||
>
|
||||
export type RemoteRuntimeQueryRequest = z.infer<
|
||||
typeof remoteRuntimeQueryRequestSchema
|
||||
>
|
||||
export type RemoteRuntimeQueryResult = z.infer<
|
||||
typeof remoteRuntimeQueryResultSchema
|
||||
>
|
||||
|
||||
export async function digestRemoteRuntimePreparation(
|
||||
preparation: RemotePromptOperationPreparation
|
||||
): Promise<string> {
|
||||
const parsed = remotePromptOperationPreparationSchema.parse(preparation)
|
||||
return digestCanonicalOperation({
|
||||
method: 'runtime/preparePrompt',
|
||||
scope: {
|
||||
kind: 'run',
|
||||
sessionId: parsed.bindingId,
|
||||
requestId: parsed.requestId
|
||||
},
|
||||
payload: parsed
|
||||
})
|
||||
}
|
||||
|
||||
export async function createRemoteRuntimeStartRequest(input: {
|
||||
controllerId: string
|
||||
operationId: string
|
||||
prompt: unknown
|
||||
adapterParameters?: unknown
|
||||
deadlineAt?: string
|
||||
}): Promise<RemoteRuntimeStartRequest> {
|
||||
const prompt = remotePromptOperationPreparationSchema.parse(input.prompt)
|
||||
const deadlineAt = input.deadlineAt ?? prompt.deadlineAt
|
||||
if (Date.parse(deadlineAt) > Date.parse(prompt.deadlineAt)) {
|
||||
throw new Error('Runtime start deadline exceeds the prompt deadline')
|
||||
}
|
||||
const payload = remoteRuntimeStartPayloadSchema.parse({
|
||||
bindingId: prompt.bindingId,
|
||||
requestId: prompt.requestId,
|
||||
workMode: prompt.workMode,
|
||||
runtimeId: prompt.runtimeId,
|
||||
adapterParameters: input.adapterParameters ?? [],
|
||||
deadlineAt,
|
||||
budget: prompt.budget
|
||||
})
|
||||
const identity = await createOperationIdentity({
|
||||
controllerId: agentIdentifierSchema.parse(input.controllerId),
|
||||
operationId: agentIdentifierSchema.parse(input.operationId),
|
||||
scope: {
|
||||
kind: 'run',
|
||||
sessionId: prompt.bindingId,
|
||||
requestId: prompt.requestId
|
||||
},
|
||||
method: 'runtime/startPrompt',
|
||||
payload
|
||||
})
|
||||
return remoteRuntimeStartRequestSchema.parse({ identity, payload })
|
||||
}
|
||||
|
||||
export async function createRemoteRuntimeStopRequest(input: {
|
||||
controllerId: string
|
||||
operationId: string
|
||||
payload: unknown
|
||||
}): Promise<RemoteRuntimeStopRequest> {
|
||||
const payload = remoteRuntimeStopPayloadSchema.parse(input.payload)
|
||||
const identity = await createOperationIdentity({
|
||||
controllerId: input.controllerId,
|
||||
operationId: input.operationId,
|
||||
scope: {
|
||||
kind: 'run',
|
||||
sessionId: payload.bindingId,
|
||||
requestId: payload.requestId
|
||||
},
|
||||
method: 'runtime/stopPrompt',
|
||||
payload
|
||||
})
|
||||
return remoteRuntimeStopRequestSchema.parse({ identity, payload })
|
||||
}
|
||||
|
||||
export async function createRemoteRuntimeQueryRequest(input: {
|
||||
controllerId: string
|
||||
operationId: string
|
||||
payload: unknown
|
||||
}): Promise<RemoteRuntimeQueryRequest> {
|
||||
const payload = remoteRuntimeQueryPayloadSchema.parse(input.payload)
|
||||
const identity = await createOperationIdentity({
|
||||
controllerId: input.controllerId,
|
||||
operationId: input.operationId,
|
||||
scope: {
|
||||
kind: 'run',
|
||||
sessionId: payload.bindingId,
|
||||
requestId: payload.requestId
|
||||
},
|
||||
method: 'runtime/queryPrompt',
|
||||
payload
|
||||
})
|
||||
return remoteRuntimeQueryRequestSchema.parse({ identity, payload })
|
||||
}
|
||||
|
||||
export async function digestRemoteRuntimeBundleManifest(
|
||||
manifest: unknown
|
||||
): Promise<string> {
|
||||
const parsed = remoteRuntimeBundleManifestSchema.parse(manifest)
|
||||
return digestCanonicalOperation({
|
||||
method: 'runtime/bundleManifest',
|
||||
scope: {
|
||||
kind: 'installation',
|
||||
installationId: parsed.runtimeId
|
||||
},
|
||||
payload: parsed
|
||||
})
|
||||
}
|
||||
|
||||
export async function digestRemoteRuntimeBundleIdentity(
|
||||
manifest: unknown
|
||||
): Promise<string> {
|
||||
const parsed = remoteRuntimeBundleManifestSchema.parse(manifest)
|
||||
const { bundleDigest: _bundleDigest, ...identity } = parsed
|
||||
void _bundleDigest
|
||||
return digestCanonicalOperation({
|
||||
method: 'runtime/bundleIdentity',
|
||||
scope: {
|
||||
kind: 'installation',
|
||||
installationId: parsed.runtimeId
|
||||
},
|
||||
payload: identity
|
||||
})
|
||||
}
|
||||
|
||||
export async function assertDetachedRemoteRuntimeBundleDigest(
|
||||
bundle: unknown
|
||||
): Promise<SignedRemoteRuntimeManifest> {
|
||||
const parsed = detachedSignedRemoteRuntimeBundleSchema.parse(bundle)
|
||||
const bundleDigest = await digestRemoteRuntimeBundleIdentity(
|
||||
parsed.manifest
|
||||
)
|
||||
if (bundleDigest !== parsed.manifest.bundleDigest) {
|
||||
throw new Error('Runtime bundle identity digest does not match')
|
||||
}
|
||||
const digest = await digestRemoteRuntimeBundleManifest(parsed.manifest)
|
||||
if (digest !== parsed.detachedSignature.manifestDigest) {
|
||||
throw new Error('Detached Runtime manifest digest does not match')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function assertRemoteRuntimeOperationDigest(
|
||||
request: RemoteRuntimeStartRequest | RemoteRuntimeStopRequest | RemoteRuntimeQueryRequest
|
||||
): Promise<void> {
|
||||
const expectedDigest = await digestCanonicalOperation({
|
||||
method: request.identity.method,
|
||||
scope: request.identity.scope,
|
||||
payload: request.payload
|
||||
})
|
||||
if (request.identity.payloadDigest !== expectedDigest) {
|
||||
throw new Error('Runtime operation payload digest does not match')
|
||||
}
|
||||
}
|
||||
|
||||
function addOperationBindingIssues(
|
||||
identity: OperationIdentity,
|
||||
method: string,
|
||||
bindingId: string,
|
||||
requestId: string,
|
||||
context: z.RefinementCtx
|
||||
): void {
|
||||
if (identity.method !== method) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['identity', 'method'],
|
||||
message: `Operation method must be ${method}`
|
||||
})
|
||||
}
|
||||
if (
|
||||
identity.scope.kind !== 'run' ||
|
||||
identity.scope.sessionId !== bindingId ||
|
||||
identity.scope.requestId !== requestId
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['identity', 'scope'],
|
||||
message: 'Operation scope does not match Runtime ownership'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SSH_DIRECTORY_BROWSE_LIMITS,
|
||||
agentBootstrapProbeResultSchema,
|
||||
sshDirectoryBrowseRequestSchema,
|
||||
sshDirectoryBrowseResultSchema,
|
||||
sshHostCreateInputSchema,
|
||||
sshHostDraftInspectionRequestSchema,
|
||||
sshHostRemoteEnvironmentSchema,
|
||||
sshHostValidationRequestSchema
|
||||
} from './ssh-host-contracts'
|
||||
|
||||
describe('SSH host contracts', () => {
|
||||
it('normalizes bounded display fields and preserves password bytes', () => {
|
||||
expect(
|
||||
sshHostCreateInputSchema.parse({
|
||||
name: ' Build host ',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'password',
|
||||
password: {
|
||||
action: 'replace',
|
||||
value: ' exact password '
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'password',
|
||||
password: {
|
||||
action: 'replace',
|
||||
value: ' exact password '
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('requires a password for new password hosts and rejects one for system-agent auth', () => {
|
||||
expect(() =>
|
||||
sshHostCreateInputSchema.parse({
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'password',
|
||||
password: { action: 'keep' }
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
sshHostCreateInputSchema.parse({
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'system-agent',
|
||||
password: { action: 'replace', value: 'not-allowed' }
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects whitespace, control characters, unknown fields, and invalid ports', () => {
|
||||
for (const input of [
|
||||
{
|
||||
name: 'Build host',
|
||||
hostname: 'build host',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'system-agent',
|
||||
password: { action: 'clear' }
|
||||
},
|
||||
{
|
||||
name: 'Build\u0000host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'system-agent',
|
||||
password: { action: 'clear' }
|
||||
},
|
||||
{
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 65_536,
|
||||
username: 'builder',
|
||||
authentication: 'system-agent',
|
||||
password: { action: 'clear' }
|
||||
},
|
||||
{
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'build user',
|
||||
authentication: 'system-agent',
|
||||
password: { action: 'clear' },
|
||||
command: 'rm -rf /'
|
||||
}
|
||||
]) {
|
||||
expect(() => sshHostCreateInputSchema.parse(input)).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('strictly validates inspection and validation envelopes', () => {
|
||||
const hostId = '00000000-0000-4000-8000-000000000101'
|
||||
const candidateId = '00000000-0000-4000-8000-000000000102'
|
||||
const fingerprint = `SHA256:${'A'.repeat(43)}`
|
||||
expect(
|
||||
sshHostDraftInspectionRequestSchema.parse({
|
||||
hostId,
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder'
|
||||
})
|
||||
).toEqual({
|
||||
hostId,
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder'
|
||||
})
|
||||
expect(() =>
|
||||
sshHostValidationRequestSchema.parse({
|
||||
candidateId,
|
||||
fingerprintSha256: 'SHA256:short',
|
||||
input: {
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'system-agent',
|
||||
password: { action: 'clear' }
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
sshHostValidationRequestSchema.parse({
|
||||
candidateId,
|
||||
fingerprintSha256: fingerprint,
|
||||
input: {
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'system-agent',
|
||||
password: { action: 'clear' },
|
||||
privateKey: 'not-allowed'
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('strictly validates Agent bootstrap readiness results', () => {
|
||||
expect(
|
||||
agentBootstrapProbeResultSchema.parse({
|
||||
ready: true,
|
||||
platform: 'linux',
|
||||
architecture: 'arm64',
|
||||
canonicalHomeDirectory: '/home/builder',
|
||||
uid: 1_001,
|
||||
shell: '/bin/bash',
|
||||
procfs: 'ready'
|
||||
})
|
||||
).toMatchObject({
|
||||
ready: true,
|
||||
architecture: 'arm64'
|
||||
})
|
||||
expect(
|
||||
agentBootstrapProbeResultSchema.parse({
|
||||
ready: false,
|
||||
reason: 'procfs-unavailable'
|
||||
})
|
||||
).toEqual({
|
||||
ready: false,
|
||||
reason: 'procfs-unavailable'
|
||||
})
|
||||
|
||||
for (const result of [
|
||||
{
|
||||
ready: true,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
canonicalHomeDirectory: '/home/builder/../root',
|
||||
uid: 1_001,
|
||||
shell: '/bin/bash',
|
||||
procfs: 'ready'
|
||||
},
|
||||
{
|
||||
ready: true,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
canonicalHomeDirectory: '/home/builder\u0000',
|
||||
uid: 1_001,
|
||||
shell: '/bin/bash',
|
||||
procfs: 'ready'
|
||||
},
|
||||
{
|
||||
ready: false,
|
||||
reason: 'procfs-unavailable',
|
||||
diagnostic: 'environment dump'
|
||||
}
|
||||
]) {
|
||||
expect(() =>
|
||||
agentBootstrapProbeResultSchema.parse(result)
|
||||
).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes only bounded remote environment version metadata', () => {
|
||||
const status = {
|
||||
hostId: '00000000-0000-4000-8000-000000000101',
|
||||
checkedAt: '2030-01-01T00:00:00.000Z',
|
||||
architecture: 'x64',
|
||||
agent: {
|
||||
state: 'update-available',
|
||||
expected: {
|
||||
version: '0.11.1',
|
||||
architecture: 'x64'
|
||||
},
|
||||
installed: {
|
||||
version: '0.10.4',
|
||||
architecture: 'x64'
|
||||
}
|
||||
},
|
||||
runtimes: [{
|
||||
runtimeId: 'opencode',
|
||||
provider: 'opencode',
|
||||
state: 'current',
|
||||
expected: {
|
||||
version: '1.18.9',
|
||||
architecture: 'x64'
|
||||
},
|
||||
installed: {
|
||||
version: '1.18.9',
|
||||
architecture: 'x64'
|
||||
}
|
||||
}]
|
||||
}
|
||||
expect(sshHostRemoteEnvironmentSchema.parse(status)).toEqual(status)
|
||||
expect(() =>
|
||||
sshHostRemoteEnvironmentSchema.parse({
|
||||
...status,
|
||||
credential: 'secret'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('validates strict directory browse requests and treats an omitted path as valid', () => {
|
||||
const hostId = '00000000-0000-4000-8000-000000000101'
|
||||
expect(
|
||||
sshDirectoryBrowseRequestSchema.parse({ hostId })
|
||||
).toEqual({ hostId })
|
||||
expect(
|
||||
sshDirectoryBrowseRequestSchema.parse({
|
||||
hostId,
|
||||
path: '/home/builder'
|
||||
})
|
||||
).toEqual({
|
||||
hostId,
|
||||
path: '/home/builder'
|
||||
})
|
||||
|
||||
for (const input of [
|
||||
{ hostId, path: '/home/builder/' },
|
||||
{ hostId, path: '/home//builder' },
|
||||
{ hostId, path: '/home/./builder' },
|
||||
{ hostId, path: '/home/../builder' },
|
||||
{ hostId, path: '/home/\u0000builder' },
|
||||
{ hostId, path: `/${'界'.repeat(1_366)}` },
|
||||
{ hostId, path: '/', command: 'pwd' }
|
||||
]) {
|
||||
expect(() =>
|
||||
sshDirectoryBrowseRequestSchema.parse(input)
|
||||
).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('validates deterministic bounded directory browse results', () => {
|
||||
const result = {
|
||||
path: '/home/builder',
|
||||
homeDirectory: '/home/builder',
|
||||
parentPath: '/home',
|
||||
entries: [
|
||||
{
|
||||
name: 'alpha',
|
||||
path: '/home/builder/alpha'
|
||||
},
|
||||
{
|
||||
name: '资料',
|
||||
path: '/home/builder/资料'
|
||||
}
|
||||
],
|
||||
truncated: false
|
||||
}
|
||||
expect(
|
||||
sshDirectoryBrowseResultSchema.parse(result)
|
||||
).toEqual(result)
|
||||
expect(
|
||||
sshDirectoryBrowseResultSchema.parse({
|
||||
...result,
|
||||
path: '/',
|
||||
parentPath: null,
|
||||
entries: []
|
||||
})
|
||||
).toMatchObject({
|
||||
path: '/',
|
||||
parentPath: null
|
||||
})
|
||||
|
||||
for (const invalid of [
|
||||
{
|
||||
...result,
|
||||
parentPath: '/'
|
||||
},
|
||||
{
|
||||
...result,
|
||||
entries: [...result.entries].reverse()
|
||||
},
|
||||
{
|
||||
...result,
|
||||
entries: [result.entries[0], result.entries[0]]
|
||||
},
|
||||
{
|
||||
...result,
|
||||
entries: [
|
||||
{
|
||||
name: 'elsewhere',
|
||||
path: '/tmp/elsewhere'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
...result,
|
||||
entries: [
|
||||
{
|
||||
name: '..',
|
||||
path: '/home'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
...result,
|
||||
entries: [],
|
||||
credential: 'secret'
|
||||
}
|
||||
]) {
|
||||
expect(() =>
|
||||
sshDirectoryBrowseResultSchema.parse(invalid)
|
||||
).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('caps directory browse output at 500 entries', () => {
|
||||
const entries = Array.from(
|
||||
{ length: SSH_DIRECTORY_BROWSE_LIMITS.maximumEntries + 1 },
|
||||
(_, index) => {
|
||||
const name = index.toString().padStart(3, '0')
|
||||
return {
|
||||
name,
|
||||
path: `/home/builder/${name}`
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(() =>
|
||||
sshDirectoryBrowseResultSchema.parse({
|
||||
path: '/home/builder',
|
||||
homeDirectory: '/home/builder',
|
||||
parentPath: '/home',
|
||||
entries,
|
||||
truncated: true
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,464 @@
|
||||
import { z } from 'zod'
|
||||
import { componentVersionSchema } from './agent-installation-contracts'
|
||||
import { utf8ByteLength } from './agent-protocol/contracts'
|
||||
|
||||
export const SSH_HOST_LIMITS = {
|
||||
maximumHosts: 100,
|
||||
maximumNameLength: 120,
|
||||
maximumHostnameLength: 253,
|
||||
maximumUsernameLength: 128,
|
||||
maximumPort: 65_535,
|
||||
maximumPasswordLength: 4_096,
|
||||
maximumHostKeyLength: 32 * 1_024,
|
||||
maximumDiagnosticLength: 1_000
|
||||
} as const
|
||||
|
||||
const boundedTextSchema = (
|
||||
maximum: number,
|
||||
minimum = 0
|
||||
): z.ZodString =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(minimum)
|
||||
.max(maximum)
|
||||
.refine(
|
||||
(value) =>
|
||||
Array.from(value).every((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0
|
||||
return codePoint > 31 && codePoint !== 127
|
||||
}),
|
||||
'内容不能包含控制字符'
|
||||
)
|
||||
|
||||
export const sshHostIdSchema = z.string().uuid()
|
||||
export const sshAuthenticationKindSchema = z.enum([
|
||||
'password',
|
||||
'system-agent'
|
||||
])
|
||||
|
||||
export type SshAuthenticationKind = z.infer<
|
||||
typeof sshAuthenticationKindSchema
|
||||
>
|
||||
|
||||
export const sshPasswordUpdateSchema = z.discriminatedUnion(
|
||||
'action',
|
||||
[
|
||||
z.object({ action: z.literal('keep') }).strict(),
|
||||
z.object({ action: z.literal('clear') }).strict(),
|
||||
z
|
||||
.object({
|
||||
action: z.literal('replace'),
|
||||
value: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(SSH_HOST_LIMITS.maximumPasswordLength)
|
||||
})
|
||||
.strict()
|
||||
]
|
||||
)
|
||||
|
||||
export type SshPasswordUpdate = z.infer<
|
||||
typeof sshPasswordUpdateSchema
|
||||
>
|
||||
|
||||
const sshHostEditableFields = {
|
||||
name: boundedTextSchema(SSH_HOST_LIMITS.maximumNameLength, 1),
|
||||
hostname: boundedTextSchema(
|
||||
SSH_HOST_LIMITS.maximumHostnameLength,
|
||||
1
|
||||
).refine((value) => !/\s/u.test(value), '主机地址不能包含空白字符'),
|
||||
port: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(SSH_HOST_LIMITS.maximumPort),
|
||||
username: boundedTextSchema(
|
||||
SSH_HOST_LIMITS.maximumUsernameLength,
|
||||
1
|
||||
).refine((value) => !/\s/u.test(value), '用户名不能包含空白字符'),
|
||||
authentication: sshAuthenticationKindSchema,
|
||||
password: sshPasswordUpdateSchema
|
||||
} as const
|
||||
|
||||
export const sshHostCreateInputSchema = z
|
||||
.object(sshHostEditableFields)
|
||||
.strict()
|
||||
.superRefine((input, context) => {
|
||||
if (
|
||||
input.authentication === 'password' &&
|
||||
input.password.action !== 'replace'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['password'],
|
||||
message: '新建密码认证主机时必须提供密码'
|
||||
})
|
||||
}
|
||||
if (
|
||||
input.authentication === 'system-agent' &&
|
||||
input.password.action === 'replace'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['password'],
|
||||
message: '系统 SSH Agent 认证不接受密码'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const sshHostUpdateInputSchema = z
|
||||
.object(sshHostEditableFields)
|
||||
.strict()
|
||||
.superRefine((input, context) => {
|
||||
if (
|
||||
input.authentication === 'system-agent' &&
|
||||
input.password.action === 'replace'
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['password'],
|
||||
message: '系统 SSH Agent 认证不接受密码'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type SshHostCreateInput = z.infer<
|
||||
typeof sshHostCreateInputSchema
|
||||
>
|
||||
export type SshHostUpdateInput = z.infer<
|
||||
typeof sshHostUpdateInputSchema
|
||||
>
|
||||
|
||||
export const sshHostRequestSchema = z
|
||||
.object({
|
||||
hostId: sshHostIdSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const sshHostDraftInspectionRequestSchema = z
|
||||
.object({
|
||||
hostId: sshHostIdSchema.optional(),
|
||||
hostname: sshHostCreateInputSchema.shape.hostname,
|
||||
port: sshHostCreateInputSchema.shape.port,
|
||||
username: sshHostCreateInputSchema.shape.username
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SshHostDraftInspectionRequest = z.infer<
|
||||
typeof sshHostDraftInspectionRequestSchema
|
||||
>
|
||||
|
||||
export const sshHostCandidateRequestSchema = z
|
||||
.object({
|
||||
candidateId: z.string().uuid()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const sshHostValidationRequestSchema = z
|
||||
.object({
|
||||
candidateId: z.string().uuid(),
|
||||
fingerprintSha256: z
|
||||
.string()
|
||||
.regex(/^SHA256:[A-Za-z0-9+/]{43}$/u),
|
||||
input: sshHostUpdateInputSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SshHostValidationRequest = z.infer<
|
||||
typeof sshHostValidationRequestSchema
|
||||
>
|
||||
|
||||
export const sshHostKeyStateSchema = z.enum([
|
||||
'unverified',
|
||||
'verified',
|
||||
'changed'
|
||||
])
|
||||
|
||||
export type SshHostKeyState = z.infer<
|
||||
typeof sshHostKeyStateSchema
|
||||
>
|
||||
|
||||
export type SshHost = {
|
||||
id: string
|
||||
name: string
|
||||
hostname: string
|
||||
port: number
|
||||
username: string
|
||||
authentication: SshAuthenticationKind
|
||||
credentialConfigured: boolean
|
||||
credentialSource:
|
||||
| 'none'
|
||||
| 'encrypted'
|
||||
| 'system-agent'
|
||||
| 'unreadable'
|
||||
hostKey: {
|
||||
state: Exclude<SshHostKeyState, 'changed'>
|
||||
algorithm?: string
|
||||
fingerprintSha256?: string
|
||||
generation: number
|
||||
}
|
||||
lastValidatedAt?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type SshHostsSnapshot = {
|
||||
hosts: SshHost[]
|
||||
secureStorageAvailable: boolean
|
||||
}
|
||||
|
||||
export type SshHostKeyInspection = {
|
||||
candidateId: string
|
||||
hostId?: string
|
||||
state: SshHostKeyState
|
||||
algorithm: string
|
||||
fingerprintSha256: string
|
||||
previousHostKey?: {
|
||||
algorithm: string
|
||||
fingerprintSha256: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SshHostConnectionTestResult = {
|
||||
hostId: string
|
||||
connected: true
|
||||
latencyMs: number
|
||||
platform: 'linux' | 'darwin' | 'win32' | 'unknown'
|
||||
architecture: 'x64' | 'arm64' | 'unknown'
|
||||
shell: string
|
||||
homeDirectory: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type SshHostValidationResult = {
|
||||
host: SshHost
|
||||
connection: SshHostConnectionTestResult
|
||||
}
|
||||
|
||||
export const sshHostRemoteEnvironmentVersionSchema = z
|
||||
.object({
|
||||
version: componentVersionSchema,
|
||||
architecture: z.enum(['x64', 'arm64'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const sshHostRemoteEnvironmentStateSchema = z.enum([
|
||||
'current',
|
||||
'update-available',
|
||||
'not-installed'
|
||||
])
|
||||
|
||||
export const sshHostRemoteEnvironmentSchema = z
|
||||
.object({
|
||||
hostId: sshHostIdSchema,
|
||||
checkedAt: z.string().datetime({ offset: true }),
|
||||
architecture: z.enum(['x64', 'arm64']),
|
||||
agent: z
|
||||
.object({
|
||||
state: sshHostRemoteEnvironmentStateSchema,
|
||||
expected: sshHostRemoteEnvironmentVersionSchema,
|
||||
installed: sshHostRemoteEnvironmentVersionSchema.nullable()
|
||||
})
|
||||
.strict(),
|
||||
runtimes: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
runtimeId: z.literal('opencode'),
|
||||
provider: z.literal('opencode'),
|
||||
state: sshHostRemoteEnvironmentStateSchema,
|
||||
expected: sshHostRemoteEnvironmentVersionSchema,
|
||||
installed:
|
||||
sshHostRemoteEnvironmentVersionSchema.nullable()
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(16)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SshHostRemoteEnvironment = z.infer<
|
||||
typeof sshHostRemoteEnvironmentSchema
|
||||
>
|
||||
|
||||
export const agentBootstrapIncompatibleReasonSchema = z.enum([
|
||||
'non-linux',
|
||||
'unsupported-architecture',
|
||||
'home-directory-unavailable',
|
||||
'uid-unavailable',
|
||||
'shell-unavailable',
|
||||
'procfs-unavailable'
|
||||
])
|
||||
|
||||
export type AgentBootstrapIncompatibleReason = z.infer<
|
||||
typeof agentBootstrapIncompatibleReasonSchema
|
||||
>
|
||||
|
||||
export const canonicalRemotePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4_096)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.startsWith('/') &&
|
||||
value !== '/.' &&
|
||||
value !== '/..' &&
|
||||
!value.includes('//') &&
|
||||
!value.split('/').some((part) => part === '.' || part === '..') &&
|
||||
!/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(value),
|
||||
'远端路径必须是无控制字符的规范绝对路径'
|
||||
)
|
||||
|
||||
export const SSH_DIRECTORY_BROWSE_LIMITS = {
|
||||
maximumPathBytes: 4_096,
|
||||
maximumNameBytes: 255,
|
||||
maximumEntries: 500
|
||||
} as const
|
||||
|
||||
export const sshDirectoryBrowsePathSchema =
|
||||
canonicalRemotePathSchema.refine(
|
||||
(value) =>
|
||||
(value === '/' || !value.endsWith('/')) &&
|
||||
utf8ByteLength(value) <=
|
||||
SSH_DIRECTORY_BROWSE_LIMITS.maximumPathBytes,
|
||||
'远端目录路径必须是规范绝对路径,且不能超过 4096 UTF-8 字节'
|
||||
)
|
||||
|
||||
export const sshDirectoryBrowseRequestSchema = z
|
||||
.object({
|
||||
hostId: sshHostIdSchema,
|
||||
path: sshDirectoryBrowsePathSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SshDirectoryBrowseRequest = z.infer<
|
||||
typeof sshDirectoryBrowseRequestSchema
|
||||
>
|
||||
|
||||
export const sshDirectoryBrowseEntrySchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine(
|
||||
(value) =>
|
||||
value !== '.' &&
|
||||
value !== '..' &&
|
||||
!value.includes('/') &&
|
||||
!/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(value) &&
|
||||
utf8ByteLength(value) <=
|
||||
SSH_DIRECTORY_BROWSE_LIMITS.maximumNameBytes,
|
||||
'远端目录名称无效或超过 255 UTF-8 字节'
|
||||
),
|
||||
path: sshDirectoryBrowsePathSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SshDirectoryBrowseEntry = z.infer<
|
||||
typeof sshDirectoryBrowseEntrySchema
|
||||
>
|
||||
|
||||
function parentRemotePath(path: string): string | null {
|
||||
if (path === '/') {
|
||||
return null
|
||||
}
|
||||
const separator = path.lastIndexOf('/')
|
||||
return separator === 0 ? '/' : path.slice(0, separator)
|
||||
}
|
||||
|
||||
export const sshDirectoryBrowseResultSchema = z
|
||||
.object({
|
||||
path: sshDirectoryBrowsePathSchema,
|
||||
homeDirectory: sshDirectoryBrowsePathSchema,
|
||||
parentPath: sshDirectoryBrowsePathSchema.nullable(),
|
||||
entries: z
|
||||
.array(sshDirectoryBrowseEntrySchema)
|
||||
.max(SSH_DIRECTORY_BROWSE_LIMITS.maximumEntries),
|
||||
truncated: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((result, context) => {
|
||||
const expectedParentPath = parentRemotePath(result.path)
|
||||
if (result.parentPath !== expectedParentPath) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['parentPath'],
|
||||
message: '父目录路径与当前目录不匹配'
|
||||
})
|
||||
}
|
||||
|
||||
const names = new Set<string>()
|
||||
const paths = new Set<string>()
|
||||
let previousName: string | undefined
|
||||
for (const [index, entry] of result.entries.entries()) {
|
||||
const expectedPath =
|
||||
result.path === '/'
|
||||
? `/${entry.name}`
|
||||
: `${result.path}/${entry.name}`
|
||||
if (entry.path !== expectedPath) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['entries', index, 'path'],
|
||||
message: '目录项路径与当前目录或名称不匹配'
|
||||
})
|
||||
}
|
||||
if (names.has(entry.name) || paths.has(entry.path)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['entries', index],
|
||||
message: '目录项不能重复'
|
||||
})
|
||||
}
|
||||
if (previousName !== undefined && previousName >= entry.name) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['entries', index, 'name'],
|
||||
message: '目录项必须按名称严格升序排列'
|
||||
})
|
||||
}
|
||||
names.add(entry.name)
|
||||
paths.add(entry.path)
|
||||
previousName = entry.name
|
||||
}
|
||||
})
|
||||
|
||||
export type SshDirectoryBrowseResult = z.infer<
|
||||
typeof sshDirectoryBrowseResultSchema
|
||||
>
|
||||
|
||||
// Keep the schema names aligned with the other host-scoped contracts.
|
||||
export const sshHostDirectoryBrowseRequestSchema =
|
||||
sshDirectoryBrowseRequestSchema
|
||||
export const sshHostDirectoryBrowseResultSchema =
|
||||
sshDirectoryBrowseResultSchema
|
||||
export type SshHostDirectoryBrowseRequest = SshDirectoryBrowseRequest
|
||||
export type SshHostDirectoryBrowseResult = SshDirectoryBrowseResult
|
||||
|
||||
export const agentBootstrapProbeResultSchema = z.discriminatedUnion(
|
||||
'ready',
|
||||
[
|
||||
z
|
||||
.object({
|
||||
ready: z.literal(true),
|
||||
platform: z.literal('linux'),
|
||||
architecture: z.enum(['x64', 'arm64']),
|
||||
canonicalHomeDirectory: canonicalRemotePathSchema,
|
||||
uid: z.number().int().min(0).max(4_294_967_294),
|
||||
shell: canonicalRemotePathSchema,
|
||||
procfs: z.literal('ready')
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
ready: z.literal(false),
|
||||
reason: agentBootstrapIncompatibleReasonSchema
|
||||
})
|
||||
.strict()
|
||||
]
|
||||
)
|
||||
|
||||
export type AgentBootstrapProbeResult = z.infer<
|
||||
typeof agentBootstrapProbeResultSchema
|
||||
>
|
||||
Reference in New Issue
Block a user