feat: add managed SSH execution spaces

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

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

Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
This commit is contained in:
mesalogo
2026-08-24 23:02:18 +08:00
parent 3829a871f5
commit 607bb33249
258 changed files with 99449 additions and 2427 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+55 -7
View File
@@ -2,29 +2,56 @@ export type BoundedResponseTextOptions = {
maxBytes: number
missingBodyMessage?: string
tooLargeMessage: string
truncatedMessage?: string
}
export async function readBoundedResponseText(
export class BoundedResponseTooLargeError extends Error {
constructor(message: string) {
super(message)
this.name = 'BoundedResponseTooLargeError'
}
}
export class BoundedResponseTruncatedError extends Error {
constructor(message: string) {
super(message)
this.name = 'BoundedResponseTruncatedError'
}
}
export async function readBoundedResponseBytes(
response: Response,
options: BoundedResponseTextOptions
): Promise<string> {
): Promise<Uint8Array> {
const declaredLength = response.headers.get('content-length')
let parsedLength: number | undefined
if (declaredLength !== null) {
const parsedLength = Number(declaredLength)
parsedLength = Number(declaredLength)
if (
!Number.isSafeInteger(parsedLength) ||
parsedLength < 0 ||
parsedLength > options.maxBytes
) {
await response.body?.cancel().catch(() => undefined)
throw new Error(options.tooLargeMessage)
throw new BoundedResponseTooLargeError(
options.tooLargeMessage
)
}
}
if (!response.body) {
if (options.missingBodyMessage) {
throw new Error(options.missingBodyMessage)
}
return ''
if (
options.truncatedMessage !== undefined &&
parsedLength !== undefined &&
parsedLength !== 0
) {
throw new BoundedResponseTruncatedError(
options.truncatedMessage
)
}
return new Uint8Array()
}
const reader = response.body.getReader()
@@ -40,7 +67,9 @@ export async function readBoundedResponseText(
}
total += value.byteLength
if (total > options.maxBytes) {
throw new Error(options.tooLargeMessage)
throw new BoundedResponseTooLargeError(
options.tooLargeMessage
)
}
chunks.push(value)
}
@@ -50,5 +79,24 @@ export async function readBoundedResponseText(
}
reader.releaseLock()
}
return Buffer.concat(chunks, total).toString('utf8')
if (
options.truncatedMessage !== undefined &&
parsedLength !== undefined &&
response.headers.get('content-encoding') === null &&
total !== parsedLength
) {
throw new BoundedResponseTruncatedError(
options.truncatedMessage
)
}
return Buffer.concat(chunks, total)
}
export async function readBoundedResponseText(
response: Response,
options: BoundedResponseTextOptions
): Promise<string> {
return Buffer.from(
await readBoundedResponseBytes(response, options)
).toString('utf8')
}
+29
View File
@@ -7,6 +7,7 @@ import {
createModelProfileRuntime
} from './create-runtime'
import { AgentRuntimeController } from './runtime-controller'
import type { WorkspaceAccess } from '../workspace'
function createBrowserService(): BrowserToolService & {
dispose: ReturnType<typeof vi.fn>
@@ -421,4 +422,32 @@ describe('createAgentRuntime model compatibility', () => {
})
await runtime.dispose()
})
it('rejects remote process runtimes before invoking any local launcher', () => {
const launch = vi.fn()
const workspaceAccess = {
dispose: vi.fn(async () => undefined)
} as unknown as WorkspaceAccess
expect(() =>
createAgentRuntime(
'C:\\SafeLocalDefault',
settings({
provider: 'deepseek-harness'
}),
{
deepseekHarnessLauncher: launch,
executionSpace: {
kind: 'ssh',
hostId: 'host-one',
remoteRootPath: '/srv/project',
cacheIdentity: 'remote-one',
routeIdentity: 'remote-one',
workspaceAccess
}
}
)
).toThrow('远程执行空间尚不可用')
expect(launch).not.toHaveBeenCalled()
})
})
+33 -3
View File
@@ -30,6 +30,14 @@ import type { ModelToolProviderLike } from './model-tool-provider'
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { ModelToolProvider } from './model-tool-provider'
import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader'
import {
LocalWorkspaceAccess,
type WorkspaceAccess
} from '../workspace'
import {
REMOTE_EXECUTION_SPACE_UNAVAILABLE,
type ExecutionSpaceDescriptor
} from '../execution-space'
const noSubagentTools: ModelToolProviderLike = {
listTools: async () => [],
@@ -55,6 +63,8 @@ export type AgentCapabilityContext = {
browserService?: BrowserToolService
knowledgeGateway?: KnowledgeMcpGateway
webSearchEnabled?: boolean
executionSpace?: ExecutionSpaceDescriptor
workspaceAccess?: WorkspaceAccess
}
function resolveContextCompression(
@@ -145,15 +155,34 @@ export function createAgentRuntime(
settings?: ResolvedRuntimeSettings,
capabilities: AgentCapabilityContext = {}
): AgentRuntime {
const executionSpace = capabilities.executionSpace
const remote = executionSpace?.kind === 'ssh'
const baseUrl = (
settings?.opencodeBaseUrl ||
process.env.GOODBUDDY_OPENCODE_URL ||
''
).trim()
const embedded = !baseUrl
const workspace = settings?.workspacePath || defaultWorkspace
const workspace =
executionSpace?.kind === 'local'
? executionSpace.rootPath
: settings?.workspacePath || defaultWorkspace
const getWorkspaceAccess = (): WorkspaceAccess =>
capabilities.workspaceAccess ??
executionSpace?.workspaceAccess ??
new LocalWorkspaceAccess(workspace)
const provider = settings?.provider ?? defaultRuntimeSettings.provider
if (
remote &&
(provider === 'continue' ||
provider === 'opencode' ||
provider === 'deepseek-harness' ||
provider === 'auto')
) {
throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE)
}
if (provider === 'deepseek-harness') {
const profile = settings?.deepseekHarnessModelProfile
if (!profile || !isDeepSeekHarnessModelProfile(profile)) {
@@ -179,7 +208,7 @@ export function createAgentRuntime(
skillPackages: capabilities.skillPackages,
extensionPackages: capabilities.deepseekHarnessExtensions,
toolProvider: new ModelToolProvider(
workspace,
getWorkspaceAccess(),
capabilities.mcpServers,
undefined,
capabilities.knowledgeGateway,
@@ -311,7 +340,8 @@ export function createAgentRuntime(
webSearchEnabled: capabilities.webSearchEnabled,
contextCompression: settings
? resolveContextCompression(settings, defaultModelProfile)
: undefined
: undefined,
workspaceAccess: getWorkspaceAccess()
})
}
@@ -72,7 +72,7 @@ const MAX_NATIVE_TOOLS = 200
const NATIVE_SNAPSHOT_TIMEOUT_MS = 2_000
const MAIN_WEB_TOOL_NAMES = new Set(['web_search', 'web_fetch'])
const GOODBUDDY_EXECUTION_GUIDANCE = [
'GoodBuddy controlled execution rules:',
'GoodBuddy work mode rules:',
'- In Execute mode, act through the available tools instead of writing a long implementation plan.',
'- Inspect only what is needed, then create or update the requested workspace files promptly.',
'- Work in small verifiable steps and use tool results as the source of truth.',
@@ -0,0 +1,649 @@
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import type {
ModelCallIdentity,
PrepareModelCall
} from '../../shared/model-call-operation-contracts'
import {
ModelCallCapacityError,
ModelCallConflictError,
ModelCallOperationStore,
ModelCallStateError
} from './model-call-operation-store'
const directories: string[] = []
const digestA = `sha256:${'a'.repeat(64)}`
const digestB = `sha256:${'b'.repeat(64)}`
const profileDigest = `sha256:${'c'.repeat(64)}`
const policy = {
modelProfileDigest: profileDigest
}
const identity: ModelCallIdentity = {
callOperationId: 'call-1',
requestId: 'request-1',
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0,
roundIndex: 0,
provider: 'openai',
profile: 'work',
model: 'gpt-model',
protocol: 'responses-v1'
}
const prepared: PrepareModelCall = {
identity,
requestDigest: digestA,
...policy
}
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function createPath(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-model-calls-'))
directories.push(directory)
return join(directory, 'model-calls.sqlite')
}
function operation(callOperationId: string, roundIndex: number): PrepareModelCall {
return {
identity: {
...identity,
callOperationId,
promptOperationId: `prompt-${callOperationId}`,
roundIndex: 0
},
requestDigest: roundIndex % 2 === 0 ? digestA : digestB,
...policy
}
}
function completeCall(
store: ModelCallOperationStore,
callOperationId: string
): void {
store.beginDispatch(callOperationId)
store.complete(callOperationId, { status: 'completed' })
store.markResponseDelivered(callOperationId)
}
describe('ModelCallOperationStore', () => {
it('durably preserves prepared and dispatched crash states in WAL mode', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.prepare(prepared)
store.prepare({
identity: {
...identity,
callOperationId: 'call-2',
promptOperationId: 'prompt-2',
roundIndex: 0
},
requestDigest: digestB,
...policy
})
expect(store.beginDispatch('call-2').permitted).toBe(true)
store.close()
const check = new DatabaseSync(path)
expect(
(check.prepare('PRAGMA journal_mode').get() as { journal_mode: string })
.journal_mode
).toBe('wal')
check.close()
const reopened = new ModelCallOperationStore(path)
expect(reopened.get('call-1')?.status).toBe('prepared')
expect(reopened.get('call-1')?.dispatchMetadata).toBeUndefined()
expect(reopened.get('call-2')?.status).toBe('dispatched')
expect(reopened.beginDispatch('call-2').permitted).toBe(false)
expect(reopened.listUnresolved().map((record) => record.status)).toEqual([
'prepared',
'dispatched'
])
reopened.close()
})
it('idempotently prepares the same digest and conflicts on changed identity or digest', async () => {
const store = new ModelCallOperationStore(await createPath())
expect(store.prepare(prepared).created).toBe(true)
expect(store.prepare(prepared).created).toBe(false)
expect(() =>
store.prepare({ ...prepared, requestDigest: digestB })
).toThrow(ModelCallConflictError)
expect(() =>
store.prepare({
...prepared,
identity: { ...identity, model: 'different-model' }
})
).toThrow(ModelCallConflictError)
store.close()
})
it('grants only one dispatch permit across concurrent store instances', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path)
const second = new ModelCallOperationStore(path)
first.prepare(prepared)
const claims = await Promise.all([
Promise.resolve().then(() => first.beginDispatch('call-1')),
Promise.resolve().then(() => second.beginDispatch('call-1'))
])
expect(claims.filter((claim) => claim.permitted)).toHaveLength(1)
expect(claims.every((claim) => claim.record.status === 'dispatched')).toBe(
true
)
first.close()
second.close()
})
it('blocks replay after an outcome becomes unknown, including after reopen', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.prepare(prepared)
store.beginDispatch('call-1', {
providerIdempotencyKey: 'provider-key-1'
})
store.markOutcomeUnknown('call-1', {
status: 'outcome-unknown',
providerRequestId: 'provider-request-1',
reason: {
code: 'connection-lost',
retryable: false
}
})
expect(store.beginDispatch('call-1').permitted).toBe(false)
store.close()
const reopened = new ModelCallOperationStore(path)
expect(reopened.beginDispatch('call-1').permitted).toBe(false)
expect(reopened.listUnresolved()).toHaveLength(1)
expect(reopened.listUnresolved()[0]?.status).toBe('outcome-unknown')
reopened.close()
})
it('makes identical completion idempotent and rejects changed evidence', async () => {
const store = new ModelCallOperationStore(await createPath(), {
now: () => 42
})
store.prepare(prepared)
store.beginDispatch('call-1')
const evidence = {
status: 'completed' as const,
providerRequestId: 'request-provider-1',
providerResponseId: 'response-provider-1',
result: { finishReason: 'stop', outputDigest: digestB },
usage: { inputTokens: 10, outputTokens: 5 }
}
const completed = store.complete('call-1', evidence)
expect(store.complete('call-1', evidence)).toEqual(completed)
expect(() =>
store.complete('call-1', {
...evidence,
providerResponseId: 'different-response'
})
).toThrow(ModelCallConflictError)
expect(store.listUnresolved()).toEqual([])
store.close()
})
it('strictly rejects invalid transitions and different terminal outcomes', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(prepared)
expect(() =>
store.complete('call-1', { status: 'completed' })
).toThrow(ModelCallStateError)
store.beginDispatch('call-1')
store.failDefinitive('call-1', {
status: 'failed-definitive',
error: {
code: 'authentication',
retryable: false
}
})
expect(() =>
store.complete('call-1', { status: 'completed' })
).toThrow(ModelCallStateError)
expect(() =>
store.markOutcomeUnknown('call-1', {
status: 'outcome-unknown',
reason: {
code: 'uncertain',
retryable: false
}
})
).toThrow(ModelCallStateError)
store.close()
})
it('guards closed stores and makes close and dispose idempotent', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(prepared)
store.close()
expect(() => store.get('call-1')).toThrow(ModelCallStateError)
expect(() => store.prepare(prepared)).toThrow(ModelCallStateError)
expect(() => store.beginDispatch('call-1')).toThrow(ModelCallStateError)
expect(() => store.listUnresolved()).toThrow(ModelCallStateError)
expect(() => store.close()).not.toThrow()
expect(() => store.dispose()).not.toThrow()
})
it('fails safely when persisted JSON is corrupt', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.prepare(prepared)
store.close()
const corrupt = new DatabaseSync(path)
corrupt.exec(`
PRAGMA ignore_check_constraints = ON;
UPDATE model_call_operations SET identity_json = '{broken';
`)
corrupt.close()
const reopened = new ModelCallOperationStore(path)
expect(() => reopened.get('call-1')).toThrow(ModelCallStateError)
reopened.close()
})
it('enforces the hard record cap without dispatching prepared calls', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumRecords: 2
})
store.prepare(operation('call-1', 0))
store.prepare(operation('call-2', 1))
expect(() => store.prepare(operation('call-3', 2))).toThrow(
ModelCallCapacityError
)
expect(store.get('call-1')?.status).toBe('prepared')
expect(store.get('call-2')?.status).toBe('prepared')
expect(store.get('call-3')).toBeUndefined()
store.close()
})
it('automatically prunes only definitive terminal rows for capacity', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumRecords: 3
})
store.prepare(operation('call-1', 0))
completeCall(store, 'call-1')
store.prepare(operation('call-2', 1))
store.beginDispatch('call-2')
store.failDefinitive('call-2', {
status: 'failed-definitive',
error: { code: 'provider-rejected', retryable: false }
})
store.prepare(operation('call-3', 2))
store.beginDispatch('call-3')
store.prepare(operation('call-4', 3))
expect(store.get('call-1')).toBeUndefined()
expect(store.get('call-2')?.status).toBe('failed-definitive')
expect(store.get('call-3')?.status).toBe('dispatched')
expect(store.get('call-4')?.status).toBe('prepared')
store.prepare(operation('call-5', 4))
expect(store.get('call-2')).toBeUndefined()
expect(store.get('call-3')?.status).toBe('dispatched')
expect(store.get('call-4')?.status).toBe('prepared')
expect(store.get('call-5')?.status).toBe('prepared')
store.close()
})
it('never prunes dispatched or outcome-unknown rows for capacity', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumRecords: 2
})
store.prepare(operation('call-1', 0))
store.beginDispatch('call-1')
store.prepare(operation('call-2', 1))
store.beginDispatch('call-2')
store.markOutcomeUnknown('call-2', {
status: 'outcome-unknown',
reason: { code: 'transport-uncertain', retryable: false }
})
expect(() => store.prepare(operation('call-3', 2))).toThrow(
ModelCallCapacityError
)
expect(store.get('call-1')?.status).toBe('dispatched')
expect(store.get('call-2')?.status).toBe('outcome-unknown')
store.close()
})
it('reapplies capacity and terminal pruning after reopen', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path, { maximumRecords: 3 })
for (let index = 1; index <= 3; index += 1) {
first.prepare(operation(`call-${index}`, index))
}
completeCall(first, 'call-1')
completeCall(first, 'call-2')
first.close()
const reopened = new ModelCallOperationStore(path, { maximumRecords: 2 })
reopened.prepare(operation('call-4', 4))
expect(reopened.get('call-1')).toBeUndefined()
expect(reopened.get('call-2')).toBeUndefined()
expect(reopened.get('call-3')?.status).toBe('prepared')
expect(reopened.get('call-4')?.status).toBe('prepared')
reopened.close()
})
it('prunes terminal records deterministically with bounded options', async () => {
let now = 0
const store = new ModelCallOperationStore(await createPath(), {
now: () => ++now
})
store.prepare(operation('call-1', 0))
completeCall(store, 'call-1')
store.prepare(operation('call-2', 1))
completeCall(store, 'call-2')
store.prepare(operation('call-3', 2))
store.beginDispatch('call-3')
store.markOutcomeUnknown('call-3', {
status: 'outcome-unknown',
reason: { code: 'transport-uncertain', retryable: false }
})
expect(store.pruneTerminal(1, 6)).toBe(1)
expect(store.get('call-1')).toBeUndefined()
expect(store.get('call-2')?.status).toBe('completed')
expect(store.get('call-3')?.status).toBe('outcome-unknown')
expect(() => store.pruneTerminal(1_001)).toThrow()
store.close()
})
it('atomically grants one strictly monotonic next round across stores', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path)
const second = new ModelCallOperationStore(path)
first.prepare(prepared)
completeCall(first, 'call-1')
const next = {
identity: {
...identity,
callOperationId: 'call-round-1-a',
roundIndex: 1
},
requestDigest: digestB,
...policy
}
const competing = {
...next,
identity: {
...next.identity,
callOperationId: 'call-round-1-b'
}
}
const results = await Promise.allSettled([
Promise.resolve().then(() => first.prepare(next)),
Promise.resolve().then(() => second.prepare(competing))
])
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
first.close()
second.close()
})
it('retains replay authority after delivered records are pruned and reopened', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path)
first.prepare(prepared)
completeCall(first, 'call-1')
first.finalizePrompt({
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0
})
expect(first.pruneTerminal(1)).toBe(1)
expect(first.get('call-1')).toBeUndefined()
first.close()
const reopened = new ModelCallOperationStore(path)
expect(() => reopened.prepare(prepared)).toThrow(ModelCallConflictError)
expect(
reopened.prepare({
...prepared,
identity: {
...prepared.identity,
callOperationId: 'call-next-prompt',
requestId: 'request-2',
promptOperationId: 'prompt-2',
promptSequence: 1,
roundIndex: 0
},
requestDigest: digestB
}).created
).toBe(true)
expect(() =>
reopened.prepare({
...prepared,
identity: {
...prepared.identity,
callOperationId: 'call-skipped-prompt',
requestId: 'request-3',
promptOperationId: 'prompt-3',
promptSequence: 3,
roundIndex: 0
}
})
).toThrow(ModelCallConflictError)
reopened.close()
})
it('compacts finalized prompt authorities below the hard authority cap', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumAuthorities: 1
})
for (let promptSequence = 0; promptSequence < 3; promptSequence += 1) {
const callOperationId = `call-prompt-${promptSequence}`
const promptOperationId = `prompt-${promptSequence}`
store.prepare({
...prepared,
identity: {
...identity,
callOperationId,
requestId: `request-${promptSequence}`,
promptOperationId,
promptSequence,
roundIndex: 0
}
})
completeCall(store, callOperationId)
store.finalizePrompt({
bindingId: identity.bindingId,
promptOperationId,
promptSequence
})
}
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'replayed-finalized-prompt',
promptOperationId: 'prompt-1',
promptSequence: 1,
roundIndex: 0
}
})
).toThrow(ModelCallConflictError)
store.close()
})
it('rejects skipped, lower, and profile-swapped rounds', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(prepared)
completeCall(store, 'call-1')
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'call-skipped',
roundIndex: 2
}
})
).toThrow(ModelCallStateError)
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'call-profile-swap',
roundIndex: 1,
profile: 'other'
},
modelProfileDigest: digestB
})
).toThrow(ModelCallConflictError)
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'call-lower',
roundIndex: 0
}
})
).toThrow(ModelCallConflictError)
store.close()
})
it('permits sequential rounds without a call-count or cumulative-token quota', async () => {
const store = new ModelCallOperationStore(await createPath())
for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) {
const callOperationId = `call-unbounded-${roundIndex}`
store.prepare({
...prepared,
identity: {
...identity,
callOperationId,
requestId: `request-${roundIndex}`,
roundIndex
}
})
completeCall(store, callOperationId)
}
expect(store.get('call-unbounded-11')).toMatchObject({
status: 'completed',
responseDeliveredAt: expect.any(Number)
})
store.close()
})
it('requires explicit response delivery before the next round', async () => {
const store = new ModelCallOperationStore(await createPath(), {
now: () => 42
})
store.prepare(prepared)
store.beginDispatch('call-1')
store.complete('call-1', { status: 'completed' })
const next = {
...prepared,
identity: {
...identity,
callOperationId: 'call-2',
roundIndex: 1
},
requestDigest: digestB
}
expect(() => store.prepare(next)).toThrow(ModelCallStateError)
expect(store.pruneTerminal(1)).toBe(0)
const pending = store.listStartupRecords().records
expect(pending).toMatchObject([{ status: 'completed' }])
expect(pending[0]).not.toHaveProperty('responseDeliveredAt')
const delivered = store.markResponseDelivered('call-1')
expect(delivered.responseDeliveredAt).toBe(42)
expect(store.markResponseDelivered('call-1')).toEqual(delivered)
expect(store.prepare(next).created).toBe(true)
store.close()
})
it('paginates startup and status-filtered records without starvation', async () => {
let now = 0
const store = new ModelCallOperationStore(await createPath(), {
now: () => ++now
})
for (const id of ['call-a', 'call-b', 'call-c']) {
store.prepare(operation(id, 0))
store.beginDispatch(id)
}
store.complete('call-b', { status: 'completed' })
completeCall(store, 'call-c')
const first = store.listStartupRecords({ limit: 1 })
expect(first.records).toHaveLength(1)
expect(first.nextCursor).toBeDefined()
const second = store.listStartupRecords({
limit: 1,
cursor: first.nextCursor
})
expect([
...first.records,
...second.records
].map((record) => record.identity.callOperationId)).toEqual([
'call-a',
'call-b'
])
expect(
store.list({
statuses: ['completed'],
delivery: 'pending-response',
limit: 10
}).records.map((record) => record.identity.callOperationId)
).toEqual(['call-b'])
store.close()
})
it('includes prepared records in startup reconciliation', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(operation('call-prepared', 0))
expect(store.listStartupRecords().records).toMatchObject([
{
identity: { callOperationId: 'call-prepared' },
status: 'prepared'
}
])
store.close()
})
it('uses the verified private SQLite path opener before initialization', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.close()
const databaseStats = await stat(path)
expect(databaseStats.isFile()).toBe(true)
if (process.platform !== 'win32') {
expect(databaseStats.mode & 0o777).toBe(0o600)
}
expect(() => new ModelCallOperationStore(':memory:')).toThrow(
'SQLite database must be a filesystem path'
)
const invalidPath = join(
join(path, '..'),
'invalid-capacity-must-not-exist.sqlite'
)
expect(
() =>
new ModelCallOperationStore(invalidPath, {
maximumRecords: 0
})
).toThrow()
await expect(stat(invalidPath)).rejects.toMatchObject({ code: 'ENOENT' })
})
})
File diff suppressed because it is too large Load Diff
+153 -8
View File
@@ -621,7 +621,7 @@ describe('ModelAgentRuntime', () => {
const summaryBody = JSON.parse(
fetcher.mock.calls[0]![1]!.body as string
) as { max_tokens: number; system: string; messages: unknown[] }
expect(summaryBody.max_tokens).toBe(8_192)
expect(summaryBody.max_tokens).toBe(32_000)
expect(summaryBody.system).toContain('Summarize earlier history.')
expect(JSON.stringify(summaryBody.messages)).toContain('old-user-')
expect(JSON.stringify(summaryBody.messages)).not.toContain('new-user-')
@@ -1847,13 +1847,15 @@ describe('ModelAgentRuntime', () => {
})
expect(JSON.parse(init?.body as string)).toMatchObject({
model: 'gpt-5',
max_output_tokens: 4096,
stream: true,
instructions: expect.stringContaining('GoodBuddy'),
input: [
expect.objectContaining({ role: 'user', content: '你好' })
]
})
expect(JSON.parse(init?.body as string)).not.toHaveProperty(
'max_output_tokens'
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'reasoning',
@@ -4418,8 +4420,9 @@ describe('ModelAgentRuntime', () => {
expect(fetcher).toHaveBeenCalledOnce()
})
it('terminates repeated identical tool rounds without exhausting hard limits', async () => {
it('allows repeated tool rounds until the run is cancelled', async () => {
let callId = 0
const controller = new AbortController()
const fetcher = vi.fn<typeof fetch>(async () => {
callId += 1
return Response.json({
@@ -4443,7 +4446,16 @@ describe('ModelAgentRuntime', () => {
]
})
})
const toolProvider = createToolProvider()
let completedCalls = 0
const toolProvider = createToolProvider({
callTool: vi.fn(async () => {
completedCalls += 1
if (completedCalls === 5) {
controller.abort()
}
return createTextToolResult('same result')
})
})
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
@@ -4460,16 +4472,149 @@ describe('ModelAgentRuntime', () => {
prompt: 'repeat',
workMode: 'execute'
},
new AbortController().signal,
controller.signal,
async () => 'once'
)) {
void _event
}
}
await expect(consume()).rejects.toThrow('没有取得进展')
expect(fetcher).toHaveBeenCalledTimes(3)
expect(toolProvider.callTool).toHaveBeenCalledTimes(2)
await expect(consume()).rejects.toThrow()
expect(fetcher).toHaveBeenCalledTimes(5)
expect(toolProvider.callTool).toHaveBeenCalledTimes(5)
})
it('allows more than the legacy 40 tool calls in one model round', async () => {
const toolCalls = Array.from({ length: 45 }, (_, index) => ({
id: `call-bulk-${index}`,
type: 'function',
function: {
name: 'workspace_read_text',
arguments: JSON.stringify({ path: `file-${index}.txt` })
}
}))
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: toolCalls
}
}
]
})
)
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: 'all tools completed'
}
}
]
})
)
const toolProvider = createToolProvider()
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider
})
const events = []
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: 'conversation-bulk-tools',
prompt: 'run all tools',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
events.push(event)
}
expect(toolProvider.callTool).toHaveBeenCalledTimes(45)
expect(fetcher).toHaveBeenCalledTimes(2)
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('continues beyond the legacy 24 model tool rounds', async () => {
let modelRound = 0
const fetcher = vi.fn<typeof fetch>(async () => {
modelRound += 1
if (modelRound <= 25) {
return Response.json({
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: `call-round-${modelRound}`,
type: 'function',
function: {
name: 'workspace_read_text',
arguments: JSON.stringify({
path: `round-${modelRound}.txt`
})
}
}
]
}
}
]
})
}
return Response.json({
choices: [
{
message: {
role: 'assistant',
content: 'completed after 25 rounds'
}
}
]
})
})
const toolProvider = createToolProvider()
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider
})
const events = []
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: 'conversation-many-rounds',
prompt: 'continue until done',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
events.push(event)
}
expect(toolProvider.callTool).toHaveBeenCalledTimes(25)
expect(fetcher).toHaveBeenCalledTimes(26)
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('releases provider state for only the requested conversation', async () => {
+22 -90
View File
@@ -10,6 +10,7 @@ import type {
} from '../../shared/contracts'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools'
import type { WorkspaceAccess } from '../workspace'
import {
scopedReadToolNames,
type KnowledgeMcpGateway
@@ -173,13 +174,7 @@ const maxChatResponseBytes = 2 * 1024 * 1024
const maxStreamBlockBytes = 1024 * 1024
const maxToolArgumentBytes = 128 * 1024
const maxToolContextBytes = 1024 * 1024
const maxToolCallsPerRun = 40
const maxToolRounds = 24
const maxRepeatedIdenticalCalls = 3
const maxIdenticalRoundsWithoutProgress = 2
const defaultModelRequestTimeoutMs = 10 * 60_000
const defaultModelOutputTokens = 4_096
const summaryModelOutputTokens = contextSummaryTokenBudget
const noModelTools: ModelToolProviderLike = {
listTools: async () => [],
@@ -220,6 +215,7 @@ export type ModelRuntimeOptions = {
imageGenerationQuality?: ImageGenerationQuality
skillInstructions?: string
defaultWorkspace?: string
workspaceAccess?: WorkspaceAccess
mcpServers?: ResolvedMcpServer[]
browserService?: BrowserToolService
knowledgeGateway?: KnowledgeMcpGateway
@@ -227,7 +223,6 @@ export type ModelRuntimeOptions = {
toolProvider?: ModelToolProviderLike
fetcher?: typeof fetch
requestTimeoutMs?: number
maxOutputTokens?: number
contextCompression?: {
settings: ContextCompressionSettings
contextWindowTokens?: number
@@ -665,26 +660,6 @@ function parseToolArguments(value: unknown): Record<string, unknown> {
return parsed as Record<string, unknown>
}
function canonicalizeToolArguments(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalizeToolArguments)
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalizeToolArguments(item)])
)
}
return value
}
function getToolCallFingerprint(call: ModelToolCall): string {
return `${call.name}:${JSON.stringify(
canonicalizeToolArguments(call.arguments)
)}`
}
function validateToolResult(result: ModelToolResult): number {
if (
!Array.isArray(result.parts) ||
@@ -1344,7 +1319,6 @@ async function* readAnthropicToolStream(
let answer = ''
let reasoning = ''
let stopReason: unknown
let toolCallCount = 0
for await (const block of readBoundedSseBlocks(response)) {
const parsed = parseSseData(block)
@@ -1421,10 +1395,6 @@ async function* readAnthropicToolStream(
}
}
} else if (content.type === 'tool_use') {
toolCallCount += 1
if (toolCallCount > maxToolCallsPerRun) {
throw new Error('模型单轮工具调用超过安全限制')
}
const identity = parseToolCallIdentity(content.id, content.name)
next.kind = 'tool_use'
next.initialInput = content.input
@@ -1555,31 +1525,23 @@ export class ModelAgentRuntime implements AgentRuntime {
private readonly fetcher: typeof fetch
private readonly toolProvider: ModelToolProviderLike
private readonly requestTimeoutMs: number
private readonly maxOutputTokens: number
constructor(private readonly options: ModelRuntimeOptions) {
this.fetcher = options.fetcher ?? fetch
this.requestTimeoutMs =
options.requestTimeoutMs ?? defaultModelRequestTimeoutMs
this.maxOutputTokens =
options.maxOutputTokens ?? defaultModelOutputTokens
if (
!Number.isSafeInteger(this.requestTimeoutMs) ||
this.requestTimeoutMs < 1
) {
throw new Error('模型接口请求超时设置无效')
}
if (
!Number.isSafeInteger(this.maxOutputTokens) ||
this.maxOutputTokens < 1 ||
this.maxOutputTokens > summaryModelOutputTokens
) {
throw new Error('模型最大输出设置无效')
}
this.toolProvider =
options.toolProvider ??
new ModelToolProvider(
options.defaultWorkspace ?? process.cwd(),
options.workspaceAccess ??
options.defaultWorkspace ??
process.cwd(),
options.mcpServers,
options.browserService,
options.knowledgeGateway,
@@ -1831,6 +1793,14 @@ export class ModelAgentRuntime implements AgentRuntime {
)
}
private get anthropicProtocolMaximumTokens(): number {
return Math.max(
this.options.contextCompression?.contextWindowTokens ??
minimumModelContextWindowTokens,
minimumModelContextWindowTokens
)
}
private getHardSafetyTriggerTokens(): number | undefined {
const contextWindowTokens =
this.options.contextCompression?.contextWindowTokens
@@ -1842,8 +1812,7 @@ export class ModelAgentRuntime implements AgentRuntime {
contextWindowTokens,
minimumModelContextWindowTokens
) -
this.maxOutputTokens -
2_048
12_000
)
}
@@ -1915,8 +1884,7 @@ export class ModelAgentRuntime implements AgentRuntime {
supportsImageInput: false,
toolProvider: noModelTools,
fetcher: this.fetcher,
requestTimeoutMs: this.requestTimeoutMs,
maxOutputTokens: summaryModelOutputTokens
requestTimeoutMs: this.requestTimeoutMs
})
const summaryRequest: AgentExecutionRequest = {
requestId: request.requestId,
@@ -2543,7 +2511,6 @@ export class ModelAgentRuntime implements AgentRuntime {
responses
? {
model: this.options.model,
max_output_tokens: this.maxOutputTokens,
stream: true,
instructions: system,
input: messages,
@@ -2554,7 +2521,7 @@ export class ModelAgentRuntime implements AgentRuntime {
: anthropic
? {
model: this.options.model,
max_tokens: this.maxOutputTokens,
max_tokens: this.anthropicProtocolMaximumTokens,
stream: true,
system,
messages,
@@ -2564,7 +2531,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
: {
model: this.options.model,
max_tokens: this.maxOutputTokens,
stream: true,
stream_options: {
include_usage: true
@@ -2690,8 +2656,7 @@ export class ModelAgentRuntime implements AgentRuntime {
const index = toolDelta?.index
if (
!Number.isSafeInteger(index) ||
(index as number) < 0 ||
(index as number) >= maxToolCallsPerRun
(index as number) < 0
) {
throw new Error(
'OpenAI 模型接口返回了无效流式工具调用序号'
@@ -3011,14 +2976,9 @@ export class ModelAgentRuntime implements AgentRuntime {
rounds: [],
compressionCount: 0
}
const seenCallIds = new Set<string>()
let totalToolCalls = 0
let answer = ''
const identicalCallCounts = new Map<string, number>()
let previousRoundSignature: string | undefined
let identicalRoundsWithoutProgress = 0
for (let round = 0; round < maxToolRounds; round += 1) {
for (let round = 0; ; round += 1) {
signal.throwIfAborted()
if (round > 0) {
toolSnapshot = await loadToolSnapshot()
@@ -3156,25 +3116,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
return
}
const roundSignature = response.toolCalls
.map(getToolCallFingerprint)
.join('\n')
if (roundSignature === previousRoundSignature) {
identicalRoundsWithoutProgress += 1
if (
identicalRoundsWithoutProgress >=
maxIdenticalRoundsWithoutProgress
) {
throw new Error('直连模型重复了相同工具调用且没有取得进展')
}
} else {
previousRoundSignature = roundSignature
identicalRoundsWithoutProgress = 0
}
totalToolCalls += response.toolCalls.length
if (totalToolCalls > maxToolCallsPerRun) {
throw new Error('直连模型单次运行的工具调用超过 40 个')
}
if (responses) {
if (!response.responsesOutput) {
throw new Error('OpenAI Responses 工具调用缺少 output')
@@ -3205,19 +3146,13 @@ export class ModelAgentRuntime implements AgentRuntime {
const responsesResults: Array<Record<string, unknown>> = []
const chatImageCarrierContent: Array<Record<string, unknown>> = []
let roundContextBytes = 0
const roundCallIds = new Set<string>()
for (const call of response.toolCalls) {
signal.throwIfAborted()
const callFingerprint = getToolCallFingerprint(call)
const identicalCallCount =
(identicalCallCounts.get(callFingerprint) ?? 0) + 1
identicalCallCounts.set(callFingerprint, identicalCallCount)
if (identicalCallCount > maxRepeatedIdenticalCalls) {
throw new Error('直连模型重复请求了完全相同的工具调用')
}
if (seenCallIds.has(call.id)) {
if (roundCallIds.has(call.id)) {
throw new Error('模型重复使用了工具调用 ID')
}
seenCallIds.add(call.id)
roundCallIds.add(call.id)
const tool = toolSnapshot.toolsByName.get(call.name)
const displayName = tool?.displayName ?? call.name.slice(0, 128)
const input = boundedToolDetail(call.arguments, 4_000)
@@ -3421,7 +3356,6 @@ export class ModelAgentRuntime implements AgentRuntime {
})
signal.throwIfAborted()
}
throw new Error('直连模型工具调用轮次超过 24 轮')
}
async *run(
@@ -3537,7 +3471,6 @@ export class ModelAgentRuntime implements AgentRuntime {
responses
? {
model: this.options.model,
max_output_tokens: this.maxOutputTokens,
stream: true,
instructions: system,
input: messages
@@ -3545,14 +3478,13 @@ export class ModelAgentRuntime implements AgentRuntime {
: anthropic
? {
model: this.options.model,
max_tokens: this.maxOutputTokens,
max_tokens: this.anthropicProtocolMaximumTokens,
stream: true,
system,
messages
}
: {
model: this.options.model,
max_tokens: this.maxOutputTokens,
stream: true,
stream_options: {
include_usage: true
+211
View File
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
import type { WorkspaceAccess } from '../workspace'
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
const mocks = vi.hoisted(() => {
@@ -201,6 +202,216 @@ describe('ModelToolProvider', () => {
).resolves.toBe('saved')
})
it('delegates workspace tools and disposal to WorkspaceAccess', async () => {
const readText = vi.fn(async () => ({
path: 'remote.txt',
name: 'remote.txt',
content: 'remote content',
size: 14
}))
const listDirectory = vi.fn(async () => ({
path: '',
entries: [
{ name: 'remote.txt', path: 'remote.txt', type: 'file' as const }
],
truncated: false
}))
const writeTextAtomic = vi.fn(async () => ({
path: 'output.txt',
bytesWritten: 5
}))
const dispose = vi.fn(async () => undefined)
const access = {
getIdentity: vi.fn(),
listDirectory,
stat: vi.fn(),
readText,
writeTextAtomic,
search: vi.fn(),
getChanges: vi.fn(),
dispose
} as unknown as WorkspaceAccess
const provider = new ModelToolProvider(access)
const signal = new AbortController().signal
await expect(
provider.callTool(
'workspace_read_text',
{ path: 'remote.txt' },
signal,
toolContext
)
).resolves.toEqual({
parts: [{ type: 'text', text: 'remote content' }],
contextBytes: 14
})
await provider.callTool(
'workspace_list_directory',
{},
signal,
toolContext
)
await provider.callTool(
'workspace_write_text',
{ path: 'output.txt', content: 'saved' },
signal,
toolContext
)
expect(readText).toHaveBeenCalledWith(
expect.objectContaining({
path: 'remote.txt',
maximumBytes: 256 * 1024,
signal
})
)
expect(listDirectory).toHaveBeenCalledWith(
expect.objectContaining({
path: '.',
maximumEntries: 200,
includeOther: true,
signal
})
)
expect(writeTextAtomic).toHaveBeenCalledWith({
path: 'output.txt',
content: 'saved',
maximumBytes: 512 * 1024,
signal
})
await provider.dispose()
expect(dispose).toHaveBeenCalledOnce()
})
it('denies spoofed mutating tool calls in Ask before reaching sinks', async () => {
const readText = vi.fn(async () => ({
path: 'remote.txt',
name: 'remote.txt',
content: 'remote content',
size: 14
}))
const listDirectory = vi.fn(async () => ({
path: '',
entries: [],
truncated: false
}))
const writeTextAtomic = vi.fn()
const access = {
getIdentity: vi.fn(),
listDirectory,
stat: vi.fn(),
readText,
writeTextAtomic,
search: vi.fn(),
getChanges: vi.fn(),
dispose: vi.fn(async () => undefined)
} as unknown as WorkspaceAccess
const browserService = createBrowserService()
const createMagicNote = vi.fn()
const callGoodBuddyConfigTool = vi.fn()
const gateway = {
createMagicNote,
callGoodBuddyConfigTool,
getAvailableToolNames: vi.fn(() => [
'note_create',
'goodbuddy_config_apply'
])
} as unknown as KnowledgeMcpGateway
const provider = new ModelToolProvider(
access,
[createMcpServer()],
browserService,
gateway
)
const signal = new AbortController().signal
const askContext = {
conversationId: 'spoofed-ask-tools',
workMode: 'ask',
knowledgeCapabilityToken: 'main-only-token'
} satisfies ModelToolCallContext
const denial = 'Ask 模式仅允许调用已声明的只读工具'
for (const [name, argumentsValue] of [
[
'workspace_write_text',
{ path: 'output.txt', content: 'blocked' }
],
['browser_click', { ref: 'b_target' }],
['note_create', { title: 'blocked' }],
[
'goodbuddy_config_apply',
{ planId: '00000000-0000-4000-8000-000000000702' }
],
['mcp_spoofed_mutation', {}]
] as const) {
await expect(
provider.callTool(
name,
argumentsValue,
signal,
askContext
)
).rejects.toThrow(denial)
}
expect(writeTextAtomic).not.toHaveBeenCalled()
expect(browserService.click).not.toHaveBeenCalled()
expect(createMagicNote).not.toHaveBeenCalled()
expect(callGoodBuddyConfigTool).not.toHaveBeenCalled()
expect(mocks.Client).not.toHaveBeenCalled()
const writeTool = {
name: 'workspace_write_text',
displayName: '写入工作区文本',
description: 'write',
inputSchema: {},
source: 'builtin'
} as const
expect(() =>
provider.getApproval(
writeTool,
{ path: 'output.txt', content: 'blocked' },
'output.txt',
askContext
)
).toThrow(denial)
expect(() =>
provider.getApproval(
{
...writeTool,
name: 'browser_click',
displayName: '点击浏览器元素'
},
{ ref: 'b_target' },
'b_target',
askContext
)
).toThrow(denial)
expect(browserService.getOrigin).not.toHaveBeenCalled()
await expect(
provider.callTool(
'workspace_read_text',
{ path: 'remote.txt' },
signal,
askContext
)
).resolves.toMatchObject({
parts: [{ type: 'text', text: 'remote content' }]
})
await expect(
provider.callTool(
'workspace_list_directory',
{},
signal,
askContext
)
).resolves.toMatchObject({
parts: [{ type: 'text', text: expect.any(String) }]
})
expect(readText).toHaveBeenCalledOnce()
expect(listDirectory).toHaveBeenCalledOnce()
})
it('exposes scoped reads in Ask and Magic Notes writes only in Execute', async () => {
const workspace = await createWorkspace()
const search = vi.fn(async () => [])
+63 -126
View File
@@ -1,18 +1,5 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { randomUUID } from 'node:crypto'
import {
lstat,
open,
rename,
realpath,
rm,
stat
} from 'node:fs/promises'
import {
dirname,
isAbsolute,
resolve
} from 'node:path'
import { isAbsolute } from 'node:path'
import { isIP } from 'node:net'
import { z } from 'zod'
import { builtinModelTools } from '../../shared/builtin-model-tools'
@@ -26,11 +13,9 @@ import {
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import { createMcpTransport } from '../capabilities/mcp-client-transport'
import {
getCanonicalWorkspace,
isPathInside,
listBoundedDirectoryEntries,
readBoundedUtf8File
} from '../workspace-file-access'
LocalWorkspaceAccess,
type WorkspaceAccess
} from '../workspace'
import type { RuntimeApprovalRequest } from './runtime'
import {
BrowserModelTools,
@@ -88,6 +73,11 @@ const goodbuddyConfigWriteToolNameSet = new Set<string>(
goodbuddyConfigWriteToolNames
)
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
const builtinModelToolAccessByName = new Map<string, 'read' | 'write'>(
builtinModelTools.map((tool) => [tool.name, tool.access])
)
const ASK_TOOL_DENIAL_MESSAGE =
'Ask 模式仅允许调用已声明的只读工具'
const scopedToolJsonSchemas = new Map(
[...scopedDataToolByName].map(([name, definition]) => {
const schema = z.toJSONSchema(
@@ -309,6 +299,23 @@ function createTextToolResult(text: string): ModelToolResult {
}
}
function assertToolAuthorizedForWorkMode(
name: string,
context: ModelToolCallContext
): void {
if (context.workMode !== 'ask') {
return
}
const access =
builtinModelToolAccessByName.get(name) ??
scopedDataToolByName.get(
name as Parameters<typeof scopedDataToolByName.get>[0]
)?.access
if (access !== 'read') {
throw new Error(ASK_TOOL_DENIAL_MESSAGE)
}
}
function parseMcpImage(
content: Record<string, unknown>
): Extract<ModelToolResultPart, { type: 'image' }> {
@@ -472,7 +479,6 @@ function normalizeMcpResult(result: unknown): ModelToolResult {
}
export class ModelToolProvider implements ModelToolProviderLike {
private canonicalWorkspace?: Promise<string>
private mcpConnections?: Promise<ConnectedMcp[]>
private webSearchBindings?: Promise<Map<string, McpToolBinding>>
private readonly clients = new Set<Client>()
@@ -480,12 +486,19 @@ export class ModelToolProvider implements ModelToolProviderLike {
private readonly webSearchClients = new Set<Client>()
constructor(
private readonly workspace: string,
workspace: string | WorkspaceAccess,
private readonly mcpServers: ResolvedMcpServer[] = [],
private readonly browserService?: BrowserToolService,
private readonly knowledgeGateway?: KnowledgeMcpGateway,
private readonly webSearchEnabled = false
) {}
) {
this.workspaceAccess =
typeof workspace === 'string'
? new LocalWorkspaceAccess(workspace)
: workspace
}
private readonly workspaceAccess: WorkspaceAccess
private getScopedTools(
context: ModelToolCallContext
@@ -607,71 +620,6 @@ export class ModelToolProvider implements ModelToolProviderLike {
]
}
private async getWorkspace(): Promise<string> {
this.canonicalWorkspace ??= getCanonicalWorkspace(
this.workspace,
'直连模型工作区不是目录'
)
return this.canonicalWorkspace
}
private async resolveExistingPath(
inputPath: string,
expected: 'file' | 'directory'
): Promise<string> {
const root = await this.getWorkspace()
const relativePath = workspacePathSchema.parse(inputPath)
const candidate = resolve(root, relativePath)
if (!isPathInside(root, candidate)) {
throw new Error('工具路径不能超出工作区')
}
const canonical = await realpath(candidate)
if (!isPathInside(root, canonical)) {
throw new Error('工具路径不能通过符号链接超出工作区')
}
const metadata = await stat(canonical)
if (
(expected === 'file' && !metadata.isFile()) ||
(expected === 'directory' && !metadata.isDirectory())
) {
throw new Error(
expected === 'file' ? '工具路径不是普通文件' : '工具路径不是目录'
)
}
return canonical
}
private async resolveWritablePath(inputPath: string): Promise<string> {
const root = await this.getWorkspace()
const relativePath = workspacePathSchema.parse(inputPath)
const candidate = resolve(root, relativePath)
if (!isPathInside(root, candidate) || candidate === root) {
throw new Error('工具路径不能超出工作区')
}
const canonicalParent = await realpath(dirname(candidate))
if (!isPathInside(root, canonicalParent)) {
throw new Error('工具路径不能通过符号链接超出工作区')
}
const existing = await lstat(candidate).catch((error: unknown) => {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return undefined
}
throw error
})
if (existing?.isSymbolicLink()) {
throw new Error('工作区写入工具拒绝符号链接')
}
if (existing && !existing.isFile()) {
throw new Error('工作区写入目标不是普通文件')
}
return candidate
}
private getBuiltinTools(): ModelToolDefinition[] {
return [
{
@@ -1005,6 +953,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
argumentSummary: string,
context: ModelToolCallContext
): RuntimeApprovalRequest {
assertToolAuthorizedForWorkMode(tool.name, context)
const browserTools = this.getBrowserTools(context)
if (browserTools?.ownsTool(tool.name)) {
return browserTools.getApproval(
@@ -1082,6 +1031,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
context: ModelToolCallContext
): Promise<ModelToolResult> {
signal.throwIfAborted()
assertToolAuthorizedForWorkMode(name, context)
if (name === 'knowledge_list') {
if (
!this.knowledgeGateway ||
@@ -1369,28 +1319,27 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
if (name === 'workspace_read_text') {
const input = readInputSchema.parse(argumentsValue)
const filePath = await this.resolveExistingPath(input.path, 'file')
return createTextToolResult(
(
await readBoundedUtf8File(
filePath,
MAX_READ_BYTES,
'工作区文本文件超过 256KB 安全限制',
'工作区读取目标不是有效 UTF-8 文本'
)
await this.workspaceAccess.readText({
path: input.path,
maximumBytes: MAX_READ_BYTES,
tooLargeMessage: '工作区文本文件超过 256KB 安全限制',
invalidUtf8Message: '工作区读取目标不是有效 UTF-8 文本',
signal
})
).content
)
}
if (name === 'workspace_list_directory') {
const input = listInputSchema.parse(argumentsValue)
const directoryPath = await this.resolveExistingPath(
input.path,
'directory'
)
const listing = await listBoundedDirectoryEntries(
directoryPath,
200
)
const listing = await this.workspaceAccess.listDirectory({
path: input.path,
maximumEntries: 200,
includeGit: true,
includeOther: true,
signal
})
return createTextToolResult(
boundedJson(
{
@@ -1398,11 +1347,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => ({
name: entry.name,
type: entry.isDirectory()
? 'directory'
: entry.isFile()
? 'file'
: 'other'
type: entry.type
})),
truncated: listing.truncated
},
@@ -1415,26 +1360,15 @@ export class ModelToolProvider implements ModelToolProviderLike {
if (Buffer.byteLength(input.content) > MAX_WRITE_BYTES) {
throw new Error('写入内容超过 512KB 安全限制')
}
const filePath = await this.resolveWritablePath(input.path)
const temporaryPath = `${filePath}.${randomUUID()}.tmp`
const handle = await open(temporaryPath, 'wx', 0o600)
try {
try {
await handle.writeFile(input.content, 'utf8')
} finally {
await handle.close()
}
await rename(temporaryPath, filePath)
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined)
throw new Error('无法安全写入工作区文件', { cause: error })
}
const written = await this.workspaceAccess.writeTextAtomic({
path: input.path,
content: input.content,
maximumBytes: MAX_WRITE_BYTES,
signal
})
return createTextToolResult(
boundedJson(
{
path: input.path,
bytesWritten: Buffer.byteLength(input.content)
},
written,
'工作区写入结果无法序列化'
)
)
@@ -1499,7 +1433,10 @@ export class ModelToolProvider implements ModelToolProviderLike {
this.webSearchClients.clear()
this.mcpConnections = undefined
this.webSearchBindings = undefined
await Promise.allSettled(clients.map((client) => client.close()))
await Promise.allSettled([
...clients.map((client) => client.close()),
this.workspaceAccess.dispose()
])
}
async releaseConversation(conversationId: string): Promise<void> {
@@ -0,0 +1,104 @@
import {
chmod,
mkdir,
mkdtemp,
rename,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
openPrivateSqliteDatabase,
PreparedPrivateSqliteDatabaseFile
} from './private-sqlite-database'
describe('private SQLite database opening', () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function privateDirectory(): Promise<string> {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-private-sqlite-')
)
temporaryDirectories.push(directory)
return directory
}
it('opens a verified regular filesystem database', async () => {
const path = join(await privateDirectory(), 'verified.sqlite')
const database = openPrivateSqliteDatabase(path)
database.exec('CREATE TABLE verified (value TEXT) STRICT')
database.close()
})
it.runIf(process.platform !== 'win32')(
'rejects a symbolic-link database path',
async () => {
const directory = await privateDirectory()
const target = join(directory, 'target.sqlite')
const link = join(directory, 'link.sqlite')
await writeFile(target, '')
await symlink(target, link)
expect(() => openPrivateSqliteDatabase(link)).toThrow(
'non-symbolic-link'
)
}
)
it.runIf(process.platform !== 'win32')(
'rejects a symbolic-link parent path',
async () => {
const directory = await privateDirectory()
const realParent = join(directory, 'real-parent')
const linkedParent = join(directory, 'linked-parent')
await mkdir(realParent, { mode: 0o700 })
await symlink(realParent, linkedParent, 'dir')
expect(() =>
openPrivateSqliteDatabase(join(linkedParent, 'database.sqlite'))
).toThrow('parent must be a real directory')
}
)
it.runIf(process.platform !== 'win32')(
'rejects a parent outside the private same-UID boundary',
async () => {
const directory = await privateDirectory()
await chmod(directory, 0o755)
expect(() =>
openPrivateSqliteDatabase(join(directory, 'database.sqlite'))
).toThrow('parent must be private')
}
)
it.runIf(process.platform !== 'win32')(
'rejects path replacement while the verified descriptor remains open',
async () => {
const directory = await privateDirectory()
const path = join(directory, 'database.sqlite')
const moved = join(directory, 'moved.sqlite')
const prepared = new PreparedPrivateSqliteDatabaseFile(path)
try {
await rename(path, moved)
await writeFile(path, '')
await expect(() => prepared.openDatabase()).toThrow(
'path identity changed'
)
} finally {
prepared.close()
}
}
)
})
@@ -0,0 +1,4 @@
export {
PreparedPrivateSqliteDatabaseFile,
openPrivateSqliteDatabase
} from '../../shared/node/private-sqlite-database'
+683
View File
@@ -0,0 +1,683 @@
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import { ModelCallOperationStore } from './model-call-operation-store'
import {
createModelCallOperationId,
createResolvedModelProfileDigest,
RemoteModelGateway,
type RemoteModelGatewayDispatchContext
} from './remote-model-gateway'
const stores: ModelCallOperationStore[] = []
const storePaths = new Map<ModelCallOperationStore, string>()
const anthropicProfile: ResolvedModelProfile = {
id: '00000000-0000-4000-8000-000000000001',
name: 'Anthropic',
baseUrl: 'https://provider.example',
modelName: 'claude-test',
protocol: 'anthropic-messages',
authentication: 'api-key',
apiKey: 'main-only-secret'
}
const context: RemoteModelGatewayDispatchContext = {
requestId: 'request-1',
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0,
roundIndex: 0,
modelProfileDigest: createResolvedModelProfileDigest(anthropicProfile),
modelProfile: anthropicProfile
}
const request = {
method: 'POST',
path: '/v1/messages',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
bodyBase64: Buffer.from(
JSON.stringify({ model: 'claude-test', messages: [] })
).toString('base64')
} as const
afterEach(() => {
vi.restoreAllMocks()
for (const store of stores.splice(0)) {
store.close()
}
})
async function createStore(): Promise<ModelCallOperationStore> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-gateway-'))
const path = join(directory, 'operations.sqlite')
const store = new ModelCallOperationStore(path)
stores.push(store)
storePaths.set(store, path)
return store
}
describe('RemoteModelGateway', () => {
it('prepares and claims before one authenticated provider fetch', async () => {
const store = await createStore()
const order: string[] = []
const prepare = vi.spyOn(store, 'prepare')
prepare.mockImplementation(function (input) {
order.push('prepare')
return ModelCallOperationStore.prototype.prepare.call(store, input)
})
const begin = vi.spyOn(store, 'beginDispatch')
begin.mockImplementation(function (id, metadata) {
order.push('begin')
return ModelCallOperationStore.prototype.beginDispatch.call(
store,
id,
metadata
)
})
const complete = vi.spyOn(store, 'complete')
let sentHeaders: Headers | undefined
let sentBody: Record<string, unknown> | undefined
const fetcher = vi.fn(async (url: URL, init?: RequestInit) => {
order.push('fetch')
expect(url.toString()).toBe(
'https://provider.example/v1/messages'
)
expect(init?.method).toBe('POST')
expect(init?.redirect).toBe('error')
sentHeaders = new Headers(init?.headers)
sentBody = JSON.parse(String(init?.body)) as Record<string, unknown>
return new Response(
JSON.stringify({
id: 'message-1',
stop_reason: 'end_turn',
usage: {
input_tokens: 11,
output_tokens: 7,
cache_read_input_tokens: 3
}
}),
{
status: 200,
headers: {
'content-type': 'application/json',
'x-request-id': 'request-provider-1',
authorization: 'must-not-return'
}
}
)
})
const gateway = new RemoteModelGateway({ store, fetcher })
const result = await gateway.dispatch(
context,
request,
new AbortController().signal
)
expect(order).toEqual(['prepare', 'begin', 'fetch'])
expect(fetcher).toHaveBeenCalledOnce()
expect(sentHeaders?.get('x-api-key')).toBe('main-only-secret')
expect(sentHeaders?.get('anthropic-version')).toBe('2023-06-01')
expect(sentBody).toEqual({
model: 'claude-test',
messages: []
})
expect(result.status).toBe(200)
expect(result.headers).toEqual({
'content-type': 'application/json',
'x-request-id': 'request-provider-1'
})
expect(JSON.stringify(result)).not.toContain('main-only-secret')
const completed = complete.mock.results[0]?.value
expect(completed?.status).toBe('completed')
expect(completed?.terminalEvidence).toMatchObject({
status: 'completed',
providerRequestId: 'request-provider-1',
providerResponseId: 'message-1',
result: {
finishReason: 'end_turn',
outputDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u)
},
usage: {
inputTokens: 11,
outputTokens: 7,
cachedInputTokens: 3
}
})
expect(JSON.stringify(completed)).not.toContain('main-only-secret')
expect(JSON.stringify(completed)).not.toContain('"messages"')
})
it('does not clamp Runtime-selected provider output parameters', async () => {
const store = await createStore()
let sentBody: Record<string, unknown> | undefined
const gateway = new RemoteModelGateway({
store,
fetcher: async (_url, init) => {
sentBody = JSON.parse(String(init?.body)) as Record<
string,
unknown
>
return new Response('{}')
}
})
const requestWithProviderMaximum = {
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [],
max_tokens: 1_000_000
})
).toString('base64')
}
await gateway.dispatch(
context,
requestWithProviderMaximum,
new AbortController().signal
)
expect(sentBody).toMatchObject({ max_tokens: 1_000_000 })
})
it('does not persist endpoints, credentials, or provider bodies', async () => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
new Response(JSON.stringify({ private_response_field: 'secret' }))
})
await gateway.dispatch(context, request, new AbortController().signal)
store.close()
const databaseBytes = await readFile(storePaths.get(store)!)
const persisted = databaseBytes.toString('utf8')
expect(persisted).not.toContain('https://provider.example')
expect(persisted).not.toContain('main-only-secret')
expect(persisted).not.toContain('"messages"')
expect(persisted).not.toContain('private_response_field')
})
it('completes the ledger for a full HTTP 4xx response', async () => {
const store = await createStore()
const complete = vi.spyOn(store, 'complete')
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
new Response(JSON.stringify({ error: { type: 'invalid_request' } }), {
status: 400,
headers: { 'content-type': 'application/json' }
})
})
const result = await gateway.dispatch(
context,
request,
new AbortController().signal
)
expect(result.status).toBe(400)
expect(complete).toHaveBeenCalledOnce()
expect(complete.mock.results[0]?.value.status).toBe('completed')
})
it('permits only one fetch for duplicate canonical dispatches', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({
code: 'already-dispatched'
})
expect(fetcher).toHaveBeenCalledOnce()
})
it('uses profile-independent round identity and rejects a profile swap', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
const swappedProfile = {
...anthropicProfile,
id: '00000000-0000-4000-8000-000000000002',
modelName: 'claude-other'
}
await expect(
gateway.dispatch(
{
...context,
modelProfile: swappedProfile,
modelProfileDigest:
createResolvedModelProfileDigest(swappedProfile)
},
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({ model: 'claude-other', messages: [] })
).toString('base64')
},
new AbortController().signal
)
).rejects.toThrow('different identity or evidence')
expect(fetcher).toHaveBeenCalledOnce()
})
it('validates the Main profile snapshot digest before ledger preparation', async () => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await expect(
gateway.dispatch(
{ ...context, modelProfileDigest: `sha256:${'f'.repeat(64)}` },
request,
new AbortController().signal
)
).rejects.toMatchObject({ code: 'model-profile-digest-mismatch' })
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
})
it('fails closed on a different request for the same bound call', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
await expect(
gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [{ role: 'user', content: 'different' }]
})
).toString('base64')
},
new AbortController().signal
)
).rejects.toThrow('different identity or evidence')
expect(fetcher).toHaveBeenCalledOnce()
})
it('rejects illegal paths, headers, and protocol mismatches before prepare', async () => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
for (const invalidRequest of [
{ ...request, path: 'https://attacker.example/v1/messages' },
{
...request,
headers: { authorization: 'Bearer remote-secret' }
},
{ ...request, path: '/v1/responses' },
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({ model: 'untrusted-model' })
).toString('base64')
},
{ ...request, path: '/v1/responses' }
]) {
await expect(
gateway.dispatch(
context,
invalidRequest as typeof request,
new AbortController().signal
)
).rejects.toThrow()
}
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
expect(store.listUnresolved()).toEqual([])
})
it('enforces the trusted profile image-input policy before prepare', async () => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
const imageRequest = {
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [
{
role: 'user',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'AA=='
}
}
]
}
]
})
).toString('base64')
}
await expect(
gateway.dispatch(
context,
imageRequest,
new AbortController().signal
)
).rejects.toMatchObject({ code: 'request-policy-mismatch' })
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
await expect(
gateway.dispatch(
{
...context,
modelProfile: {
...anthropicProfile,
supportsImageInput: true
},
modelProfileDigest: createResolvedModelProfileDigest({
...anthropicProfile,
supportsImageInput: true
})
},
imageRequest,
new AbortController().signal
)
).resolves.toMatchObject({ status: 200 })
expect(fetcher).toHaveBeenCalledOnce()
})
it.each([
{ background: true },
{ store: true },
{ service_tier: 'priority' },
{ web_search_options: {} },
{
tools: [
{
type: 'web_search_20250305',
name: 'web_search'
}
]
}
])(
'rejects independently billed provider capabilities before prepare',
async (providerCapability) => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await expect(
gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [],
...providerCapability
})
).toString('base64')
},
new AbortController().signal
)
).rejects.toMatchObject({ code: 'request-policy-mismatch' })
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
}
)
it('allows ordinary client-defined tools within the token policy', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await expect(
gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [],
tools: [
{
name: 'read_file',
description: 'Read a file',
input_schema: { type: 'object' }
}
]
})
).toString('base64')
},
new AbortController().signal
)
).resolves.toMatchObject({ status: 200 })
expect(fetcher).toHaveBeenCalledOnce()
})
it.each([
{
name: 'network failure',
expectedCode: 'network-error',
run: async () => {
throw new Error('socket exposed sensitive upstream details')
}
},
{
name: 'timeout',
expectedCode: 'timeout',
run: async (_url: URL, init?: RequestInit) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
}),
timeoutMs: 5
}
])(
'marks $name after dispatch as outcome unknown',
async ({ expectedCode, run, timeoutMs }) => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: run,
...(timeoutMs ? { timeoutMs } : {})
})
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({
code: expectedCode,
message: expect.not.stringContaining('sensitive')
})
expect(store.listUnresolved()).toHaveLength(1)
expect(store.listUnresolved()[0]).toMatchObject({
status: 'outcome-unknown',
terminalEvidence: {
status: 'outcome-unknown',
reason: { code: expectedCode, retryable: false }
}
})
}
)
it('marks caller cancellation after dispatch as outcome unknown', async () => {
const store = await createStore()
const controller = new AbortController()
const gateway = new RemoteModelGateway({
store,
fetcher: async (_url, init) =>
await new Promise<Response>((_resolve, reject) => {
controller.abort()
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
if (init?.signal?.aborted) {
reject(init.signal.reason)
}
})
})
await expect(
gateway.dispatch(context, request, controller.signal)
).rejects.toMatchObject({ code: 'cancelled' })
expect(store.listUnresolved()[0]?.status).toBe('outcome-unknown')
})
it('marks an oversized response as outcome unknown', async () => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
new Response(Buffer.alloc(768 * 1024 + 1), {
headers: {
'content-length': String(768 * 1024 + 1)
}
})
})
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'response-too-large' })
expect(store.listUnresolved()[0]).toMatchObject({
status: 'outcome-unknown',
terminalEvidence: {
reason: {
code: 'response-too-large',
retryable: false
}
}
})
})
it('keeps completion pending until delivery ACK and then permits the next round', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
const nextContext = { ...context, roundIndex: 1 }
await expect(
gateway.dispatch(nextContext, request, new AbortController().signal)
).rejects.toThrow('not been completed and delivered')
expect(fetcher).toHaveBeenCalledOnce()
const pending = store.listStartupRecords().records
expect(pending).toMatchObject([{ status: 'completed' }])
expect(pending[0]).not.toHaveProperty('responseDeliveredAt')
gateway.markResponseDelivered(context)
await gateway.dispatch(
nextContext,
request,
new AbortController().signal
)
expect(fetcher).toHaveBeenCalledTimes(2)
})
it('permits sequential responses without a cumulative output allowance', async () => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: async () => new Response('{}')
})
for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) {
const round = { ...context, roundIndex }
await gateway.dispatch(
round,
request,
new AbortController().signal
)
gateway.markResponseDelivered(round)
}
expect(store.get(
createModelCallOperationId({ ...context, roundIndex: 11 })
)).toMatchObject({
status: 'completed',
responseDeliveredAt: expect.any(Number)
})
})
it('marks schema-invalid bridge responses unknown before terminal completion', async () => {
const store = await createStore()
const response = new Response('{}')
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
({
status: 999,
headers: response.headers,
body: response.body
}) as Response
})
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'invalid-bridge-response' })
expect(store.listStartupRecords().records).toMatchObject([
{
status: 'outcome-unknown',
terminalEvidence: {
reason: { code: 'invalid-bridge-response' }
}
}
])
})
it('injects OpenAI bearer auth without returning or persisting it', async () => {
const store = await createStore()
const profile: ResolvedModelProfile = {
...anthropicProfile,
baseUrl: 'https://openai.example/v1',
protocol: 'openai-responses'
}
let authorization: string | null = null
const gateway = new RemoteModelGateway({
store,
fetcher: async (_url, init) => {
authorization = new Headers(init?.headers).get('authorization')
return new Response('{}')
}
})
const result = await gateway.dispatch(
{
...context,
modelProfile: profile,
modelProfileDigest: createResolvedModelProfileDigest(profile)
},
{ ...request, path: '/v1/responses' },
new AbortController().signal
)
expect(authorization).toBe('Bearer main-only-secret')
expect(JSON.stringify(result)).not.toContain('main-only-secret')
})
})
+714
View File
@@ -0,0 +1,714 @@
import { createHash } from 'node:crypto'
import {
modelCallIdentitySchema,
modelCallResultMetadataSchema,
modelCallUsageMetadataSchema,
type ModelCallResultMetadata,
type ModelCallUsageMetadata
} from '../../shared/model-call-operation-contracts'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
remoteModelGatewayRequestSchema,
remoteModelGatewayResponseSchema,
type RemoteModelGatewayRequest,
type RemoteModelGatewayResponse
} from '../../shared/remote-model-gateway-contracts'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import { createAnthropicMessagesUrl } from './anthropic-endpoint'
import { ModelCallOperationStore } from './model-call-operation-store'
import {
createOpenAIChatCompletionsUrl,
createOpenAIResponsesUrl
} from './openai-endpoint'
import {
BoundedResponseTooLargeError,
readBoundedResponseBytes
} from './bounded-response'
const DEFAULT_TIMEOUT_MS = 60_000
const RESPONSE_HEADER_NAMES = [
'content-type',
'openai-request-id',
'request-id',
'x-request-id'
] as const
export type RemoteModelGatewayDispatchContext = {
requestId: string
bindingId: string
promptOperationId: string
promptSequence: number
roundIndex: number
modelProfileDigest: string
modelProfile: ResolvedModelProfile
}
export type RemoteModelGatewayOptions = {
store: ModelCallOperationStore
fetcher?: (
input: URL,
init: RequestInit
) => Promise<Response>
timeoutMs?: number
}
export class RemoteModelGatewayError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = 'RemoteModelGatewayError'
this.code = code
}
}
/**
* Main-process-only, one-shot provider forwarder. Remote runtimes choose only
* a protocol-specific relative path and a bounded payload. Provider location,
* credentials, stable operation identity, and dispatch ownership stay in Main.
*/
export class RemoteModelGateway {
readonly #store: ModelCallOperationStore
readonly #fetcher: NonNullable<RemoteModelGatewayOptions['fetcher']>
readonly #timeoutMs: number
constructor(options: RemoteModelGatewayOptions) {
this.#store = options.store
this.#fetcher = options.fetcher ?? fetch
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
if (
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 1 ||
timeoutMs > 300_000
) {
throw new RangeError('Remote model gateway timeout is invalid')
}
this.#timeoutMs = timeoutMs
}
async dispatch(
context: RemoteModelGatewayDispatchContext,
request: RemoteModelGatewayRequest,
signal: AbortSignal
): Promise<RemoteModelGatewayResponse> {
signal.throwIfAborted()
const prepared = prepareDispatch(context, request)
this.#store.prepare({
identity: prepared.identity,
requestDigest: prepared.requestDigest,
modelProfileDigest: prepared.modelProfileDigest
})
const claim = this.#store.beginDispatch(
prepared.identity.callOperationId
)
if (!claim.permitted) {
throw new RemoteModelGatewayError(
'already-dispatched',
'Remote model request was already dispatched'
)
}
const timeoutController = new AbortController()
const timeoutError = new RemoteModelGatewayError(
'timeout',
'Remote model request timed out'
)
const timer = setTimeout(
() => timeoutController.abort(timeoutError),
this.#timeoutMs
)
const requestSignal = AbortSignal.any([
signal,
timeoutController.signal
])
let response: Response
try {
response = await this.#fetcher(prepared.url, {
method: 'POST',
redirect: 'error',
headers: prepared.headers,
body: prepared.body,
signal: requestSignal
})
} catch {
clearTimeout(timer)
const code =
timeoutController.signal.aborted
? 'timeout'
: signal.aborted
? 'cancelled'
: 'network-error'
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
code
)
}
let responseBody: Uint8Array
try {
responseBody = await readBoundedResponseBytes(response, {
maxBytes:
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes,
tooLargeMessage: 'Remote model response is too large',
truncatedMessage: 'Remote model response was truncated'
})
} catch (error) {
clearTimeout(timer)
const code =
timeoutController.signal.aborted
? 'timeout'
: signal.aborted
? 'cancelled'
: error instanceof BoundedResponseTooLargeError
? 'response-too-large'
: 'response-read-failed'
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
code
)
} finally {
clearTimeout(timer)
}
if (timeoutController.signal.aborted || signal.aborted) {
const code = timeoutController.signal.aborted
? 'timeout'
: 'cancelled'
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
code
)
}
const responseHeaders = selectResponseHeaders(response.headers)
const providerRequestId =
safeProviderIdentifier(
response.headers.get('x-request-id')
) ??
safeProviderIdentifier(
response.headers.get('openai-request-id')
) ??
safeProviderIdentifier(response.headers.get('request-id'))
let bridgeResponse: RemoteModelGatewayResponse
try {
bridgeResponse = remoteModelGatewayResponseSchema.parse({
status: response.status,
headers: responseHeaders,
bodyBase64: Buffer.from(responseBody).toString('base64')
})
} catch {
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
'invalid-bridge-response'
)
}
const parsedMetadata = parseResponseMetadata(responseBody)
this.#store.complete(prepared.identity.callOperationId, {
status: 'completed',
...(providerRequestId ? { providerRequestId } : {}),
...(parsedMetadata.providerResponseId
? { providerResponseId: parsedMetadata.providerResponseId }
: {}),
result: {
outputDigest: sha256(responseBody),
...(parsedMetadata.result ?? {})
},
...(parsedMetadata.usage ? { usage: parsedMetadata.usage } : {})
})
return bridgeResponse
}
markResponseDelivered(
context: Pick<
RemoteModelGatewayDispatchContext,
'bindingId' | 'promptOperationId' | 'roundIndex'
>
): void {
this.#store.markResponseDelivered(
createModelCallOperationId(context)
)
}
finalizePrompt(context: {
bindingId: string
promptOperationId: string
promptSequence: number
}): void {
this.#store.finalizePrompt(context)
}
#markUnknownAndThrow(
callOperationId: string,
code: string
): never {
this.#store.markOutcomeUnknown(callOperationId, {
status: 'outcome-unknown',
reason: {
code,
retryable: false
}
})
throw new RemoteModelGatewayError(
code,
remoteModelGatewayErrorMessage(code)
)
}
}
function prepareDispatch(
context: RemoteModelGatewayDispatchContext,
request: RemoteModelGatewayRequest
): {
identity: ReturnType<typeof modelCallIdentitySchema.parse>
requestDigest: string
modelProfileDigest: string
url: URL
headers: Record<string, string>
body: Uint8Array
} {
const parsedRequest = remoteModelGatewayRequestSchema.parse(request)
const profile = context.modelProfile
const modelProfileDigest = createResolvedModelProfileDigest(profile)
if (modelProfileDigest !== context.modelProfileDigest) {
throw new RemoteModelGatewayError(
'model-profile-digest-mismatch',
'Trusted model profile snapshot does not match its digest'
)
}
const url = providerUrl(profile, parsedRequest.path)
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password
) {
throw new RemoteModelGatewayError(
'invalid-provider-endpoint',
'Configured model provider endpoint is invalid'
)
}
if (profile.authentication === 'api-key' && !profile.apiKey) {
throw new RemoteModelGatewayError(
'missing-provider-credential',
'Configured model provider credential is unavailable'
)
}
const body = normalizeProviderRequestBody(
profile,
Buffer.from(parsedRequest.bodyBase64, 'base64')
)
const normalizedBodyBase64 = body.toString('base64')
const canonicalRequest = JSON.stringify({
profileId: profile.id,
protocol: profile.protocol,
authentication: profile.authentication,
model: profile.modelName,
url: url.toString(),
request: {
method: parsedRequest.method,
path: parsedRequest.path,
headers: Object.fromEntries(
Object.entries(parsedRequest.headers).sort(([left], [right]) =>
left.localeCompare(right)
)
),
bodyBase64: normalizedBodyBase64
}
})
const requestDigest = sha256(canonicalRequest)
const callOperationId = createModelCallOperationId(context)
const identity = modelCallIdentitySchema.parse({
callOperationId,
requestId: context.requestId,
bindingId: context.bindingId,
promptOperationId: context.promptOperationId,
promptSequence: context.promptSequence,
roundIndex: context.roundIndex,
provider:
profile.protocol === 'anthropic-messages'
? 'anthropic'
: 'openai',
profile: profile.id,
model: profile.modelName,
protocol: profile.protocol
})
const headers: Record<string, string> = {
...parsedRequest.headers
}
if (profile.protocol === 'anthropic-messages') {
headers['anthropic-version'] = '2023-06-01'
if (profile.authentication === 'api-key') {
headers['x-api-key'] = profile.apiKey!
}
} else if (profile.authentication === 'api-key') {
headers.authorization = `Bearer ${profile.apiKey!}`
}
return {
identity,
requestDigest,
modelProfileDigest,
url,
headers,
body
}
}
export function createModelCallOperationId(
context: Pick<
RemoteModelGatewayDispatchContext,
'bindingId' | 'promptOperationId' | 'roundIndex'
>
): string {
return `model-call-${createHash('sha256')
.update(
JSON.stringify({
bindingId: context.bindingId,
promptOperationId: context.promptOperationId,
roundIndex: context.roundIndex
})
)
.digest('hex')}`
}
export function createResolvedModelProfileDigest(
profile: ResolvedModelProfile
): string {
return sha256(
JSON.stringify({
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput === true,
contextWindowTokens: profile.contextWindowTokens ?? null,
imageGenerationQuality: profile.imageGenerationQuality ?? null
})
)
}
function normalizeProviderRequestBody(
profile: ResolvedModelProfile,
body: Buffer
): Buffer {
let value: unknown
try {
value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(body)
) as unknown
} catch {
throw requestPolicyError()
}
if (!isRecord(value) || value.model !== profile.modelName) {
throw requestPolicyError()
}
if (
profile.supportsImageInput !== true &&
containsImageInput(value)
) {
throw requestPolicyError()
}
const request = { ...value }
assertProviderBodyPolicy(profile, request)
if (
profile.protocol !== 'anthropic-messages' &&
profile.protocol !== 'openai-chat-completions' &&
profile.protocol !== 'openai-responses'
) {
throw requestPolicyError()
}
return Buffer.from(JSON.stringify(request), 'utf8')
}
function requestPolicyError(): RemoteModelGatewayError {
return new RemoteModelGatewayError(
'request-policy-mismatch',
'Remote model request does not match the trusted model policy'
)
}
function assertProviderBodyPolicy(
profile: ResolvedModelProfile,
request: Record<string, unknown>
): void {
if (
request.background === true ||
request.store === true ||
request.web_search_options !== undefined ||
request.mcp_servers !== undefined ||
request.conversation !== undefined ||
request.previous_response_id !== undefined ||
(request.service_tier !== undefined &&
request.service_tier !== 'auto' &&
request.service_tier !== 'default') ||
(Array.isArray(request.modalities) &&
request.modalities.some((value) => value !== 'text'))
) {
throw requestPolicyError()
}
if (
Array.isArray(request.include) &&
request.include.some(
(value) =>
typeof value !== 'string' ||
/(?:web_search|file_search|computer|code_interpreter|mcp|image_generation)/iu.test(
value
)
)
) {
throw requestPolicyError()
}
if (request.tools === undefined) {
return
}
if (!Array.isArray(request.tools)) {
throw requestPolicyError()
}
for (const tool of request.tools) {
if (!isRecord(tool)) {
throw requestPolicyError()
}
const type = tool.type
if (profile.protocol === 'anthropic-messages') {
if (type !== undefined && type !== 'custom') {
throw requestPolicyError()
}
} else if (type !== 'function') {
throw requestPolicyError()
}
}
}
function providerUrl(
profile: ResolvedModelProfile,
path: string
): URL {
if (profile.protocol === 'anthropic-messages') {
if (path !== '/v1/messages') {
throw protocolPathError()
}
return createAnthropicMessagesUrl(profile.baseUrl)
}
if (profile.protocol === 'openai-chat-completions') {
if (
path !== '/chat/completions' &&
path !== '/v1/chat/completions'
) {
throw protocolPathError()
}
return createOpenAIChatCompletionsUrl(profile.baseUrl)
}
if (profile.protocol === 'openai-responses') {
if (path !== '/responses' && path !== '/v1/responses') {
throw protocolPathError()
}
return createOpenAIResponsesUrl(profile.baseUrl)
}
throw protocolPathError()
}
function protocolPathError(): RemoteModelGatewayError {
return new RemoteModelGatewayError(
'protocol-path-mismatch',
'Remote model request path does not match the configured protocol'
)
}
function selectResponseHeaders(
headers: Headers
): Record<string, string> {
const selected: Record<string, string> = {}
for (const name of RESPONSE_HEADER_NAMES) {
const value = headers.get(name)
const encodedBytes =
value === null ? 0 : new TextEncoder().encode(value).byteLength
if (
value !== null &&
encodedBytes > 0 &&
!/[\r\n]/u.test(value) &&
encodedBytes <= REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes
) {
selected[name] = value
}
}
return selected
}
function parseResponseMetadata(body: Uint8Array): {
providerResponseId?: string
result?: ModelCallResultMetadata
usage?: ModelCallUsageMetadata
} {
let parsed: unknown
try {
parsed = JSON.parse(new TextDecoder().decode(body)) as unknown
} catch {
return {}
}
if (!isRecord(parsed)) {
return {}
}
const providerResponseId = safeProviderIdentifier(parsed.id)
const finishReason = safeFinishReason(
isRecordArray(parsed.choices) &&
isRecord(parsed.choices[0])
? parsed.choices[0].finish_reason
: parsed.stop_reason
)
const resultCandidate = {
...(finishReason ? { finishReason } : {})
}
const result = modelCallResultMetadataSchema.safeParse(resultCandidate)
const usage = parseUsage(parsed.usage)
return {
...(providerResponseId ? { providerResponseId } : {}),
...(result.success && Object.keys(result.data).length > 0
? { result: result.data }
: {}),
...(usage ? { usage } : {})
}
}
function parseUsage(value: unknown): ModelCallUsageMetadata | undefined {
if (!isRecord(value)) {
return undefined
}
const promptDetails = isRecord(value.prompt_tokens_details)
? value.prompt_tokens_details
: undefined
const inputDetails = isRecord(value.input_tokens_details)
? value.input_tokens_details
: undefined
const completionDetails = isRecord(value.completion_tokens_details)
? value.completion_tokens_details
: undefined
const outputDetails = isRecord(value.output_tokens_details)
? value.output_tokens_details
: undefined
const candidate = {
inputTokens: safeTokenCount(value.input_tokens ?? value.prompt_tokens),
outputTokens: safeTokenCount(
value.output_tokens ?? value.completion_tokens
),
cachedInputTokens: safeTokenCount(
value.cache_read_input_tokens ??
inputDetails?.cached_tokens ??
promptDetails?.cached_tokens
),
reasoningTokens: safeTokenCount(
outputDetails?.reasoning_tokens ??
completionDetails?.reasoning_tokens
)
}
const compact = Object.fromEntries(
Object.entries(candidate).filter((entry) => entry[1] !== undefined)
)
const result = modelCallUsageMetadataSchema.safeParse(compact)
return result.success && Object.keys(result.data).length > 0
? result.data
: undefined
}
function safeTokenCount(value: unknown): number | undefined {
return typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= 0
? value
: undefined
}
function safeProviderIdentifier(value: unknown): string | undefined {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > 256 ||
value.trim() !== value ||
new TextEncoder().encode(value).byteLength > 256
) {
return undefined
}
return value
}
function safeFinishReason(value: unknown): string | undefined {
const result =
modelCallResultMetadataSchema.shape.finishReason.safeParse(value)
return result.success ? result.data : undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
)
}
function isRecordArray(value: unknown): value is Record<string, unknown>[] {
return Array.isArray(value) && value.every(isRecord)
}
function containsImageInput(value: unknown): boolean {
const maximumNodes = 100_000
const pending: unknown[] = [value]
let visited = 0
while (pending.length > 0) {
visited += 1
if (visited > maximumNodes) {
throw requestPolicyError()
}
const current = pending.pop()
if (Array.isArray(current)) {
if (visited + pending.length + current.length > maximumNodes) {
throw requestPolicyError()
}
for (const item of current) {
pending.push(item)
}
continue
}
if (!isRecord(current)) {
continue
}
if (
current.type === 'image' ||
current.type === 'image_url' ||
current.type === 'input_image'
) {
return true
}
const values = Object.values(current)
if (visited + pending.length + values.length > maximumNodes) {
throw requestPolicyError()
}
for (const item of values) {
pending.push(item)
}
}
return false
}
function sha256(value: string | Uint8Array): string {
return `sha256:${createHash('sha256').update(value).digest('hex')}`
}
function remoteModelGatewayErrorMessage(code: string): string {
switch (code) {
case 'timeout':
return 'Remote model request timed out after dispatch'
case 'cancelled':
return 'Remote model request was cancelled after dispatch'
case 'response-too-large':
return 'Remote model response exceeded the allowed size'
case 'response-read-failed':
return 'Remote model response could not be read'
case 'invalid-bridge-response':
return 'Remote model response could not be safely delivered'
default:
return 'Remote model request outcome is unknown'
}
}
+153
View File
@@ -0,0 +1,153 @@
import type {
AcpBindingCursors,
AcpCompletePromptRequest,
AcpCompletePromptResult,
AcpEscalateCancellationRequest,
AcpReconcilePromptRequest,
AcpReconcilePromptResult
} from '../../shared/agent-protocol'
import type {
RemotePromptOperationAcceptance,
RemotePromptOperationPreparation,
} from '../../shared/remote-agent-contracts'
import type {
ModelBridgePolicy
} from '../../shared/model-bridge-contracts'
export type RemoteRuntimeChannelCapabilities = {
cancellationEscalation: boolean
promptOperationReconciliation: boolean
modelBridge?: boolean
}
export type RuntimeSessionBindingCursors = AcpBindingCursors
export type RemoteCancellationEscalation = Omit<
AcpEscalateCancellationRequest,
'reason'
> & {
reason: unknown
}
export type RemotePromptOperationReconciliation =
AcpReconcilePromptRequest
export type RemotePromptOperationReconciliationResult =
AcpReconcilePromptResult
export type RemotePromptOperationCompletion = AcpCompletePromptRequest
export type RemotePromptOperationCompletionResult =
AcpCompletePromptResult
export type RemoteModelBridgeOpenRequest = {
bindingId: string
promptOperationId: string
requestId: string
policy: ModelBridgePolicy
}
export type RemoteModelBridgeCloseResult = {
clean: boolean
poisoned: boolean
}
export interface RemoteModelBridgeSession {
readonly version: 'goodbuddy-model-bridge-v1'
readonly channelId: string
readonly channelEpoch: string
readonly policy: ModelBridgePolicy
readonly closed: Promise<RemoteModelBridgeCloseResult>
close(reason?: string): Promise<RemoteModelBridgeCloseResult>
}
/**
* A process-neutral, already authenticated ACP virtual channel.
*
* Implementations own framing, journaling and transport generation checks.
* The runtime intentionally receives no SSH client, socket or command API.
*/
export interface RemoteRuntimeChannel {
readonly input: ReadableStream<Uint8Array>
readonly output: WritableStream<Uint8Array>
readonly generation: number
readonly channelEpoch: string
/**
* Signed Runtime capability identity advertised by the Agent before the
* untrusted Runtime process starts.
*/
readonly advertisedAcpCapabilitiesDigest: string
readonly capabilities: RemoteRuntimeChannelCapabilities
readonly closed: Promise<void>
isCurrentGeneration(): boolean
getBindingCursors(
bindingId: string
): Promise<RuntimeSessionBindingCursors>
/** Accepts the prompt and starts its Ask or Execute Runtime process. */
preparePrompt(
preparation: RemotePromptOperationPreparation
): Promise<RemotePromptOperationAcceptance>
/**
* Bounds same-daemon stream recovery to the signed prompt lifetime and
* caller cancellation. Implementations that cannot recover may omit it.
*/
setRecoveryBoundary?(
deadlineAt: string,
signal: AbortSignal
): void
clearRecoveryBoundary?(): void
/**
* Allocates and registers a fresh Main-owned model channel for one prompt.
* Legacy channels may omit this when the runtime is configured without a
* model bridge.
*/
openModelBridge?(
request: RemoteModelBridgeOpenRequest
): Promise<RemoteModelBridgeSession>
completePromptOperation(
operation: RemotePromptOperationCompletion
): Promise<RemotePromptOperationCompletionResult>
/**
* Stops or resumes inbound ACP delivery. Pausing must take effect before
* the returned promise resolves and must not advance the Main ACK cursor.
*/
setInboundPaused(paused: boolean): Promise<void>
escalateCancellation(
escalation: RemoteCancellationEscalation
): Promise<void>
reconcilePromptOperation(
operation: RemotePromptOperationReconciliation
): Promise<RemotePromptOperationReconciliationResult>
close(): Promise<void>
}
export class StaleRemoteRuntimeGenerationError extends Error {
constructor() {
super('远端 Runtime 连接代际已失效')
this.name = 'StaleRemoteRuntimeGenerationError'
}
}
export function isDefinitiveRemoteRuntimeRequestRejection(
error: unknown,
method: string
): boolean {
if (
error === null ||
typeof error !== 'object' ||
!('remoteRequestOutcome' in error) ||
error.remoteRequestOutcome !== 'rejected' ||
!('remoteMethod' in error) ||
error.remoteMethod !== method
) {
return false
}
return true
}
export function assertCurrentRemoteRuntimeGeneration(
channel: RemoteRuntimeChannel
): void {
if (!channel.isCurrentGeneration()) {
throw new StaleRemoteRuntimeGenerationError()
}
}
+238 -1
View File
@@ -5,7 +5,11 @@ import type {
AgentRuntimeStatus
} from '../../shared/contracts'
import type { AgentRuntime, RuntimeAuthorizer } from './runtime'
import { AgentRuntimeController } from './runtime-controller'
import {
AgentRuntimeController,
MAX_DRAINING_RUNTIME_GENERATIONS,
RuntimeReplacementCapacityError
} from './runtime-controller'
class TestRuntime implements AgentRuntime {
readonly dispose = vi.fn(async () => {})
@@ -65,6 +69,33 @@ class TestRuntime implements AgentRuntime {
finish(): void {
this.release?.()
}
releaseConversation(): Promise<void> {
return Promise.resolve()
}
}
class DrainableTestRuntime extends TestRuntime {
readonly beginDrain = vi.fn(async () => {})
readonly forceShutdown = vi.fn(async () => {})
private readonly drained: Promise<void>
private resolveDrained!: () => void
constructor() {
super(true)
this.drained = new Promise((resolve) => {
this.resolveDrained = resolve
})
}
waitForDrain(): Promise<void> {
return this.drained
}
releaseConversation(): Promise<void> {
this.resolveDrained()
return Promise.resolve()
}
}
describe('AgentRuntimeController', () => {
@@ -135,6 +166,212 @@ describe('AgentRuntimeController', () => {
await controller.dispose()
})
it('drains remote sessions without interrupting their active prompt', async () => {
const previous = new DrainableTestRuntime()
const next = new TestRuntime()
const controller = new AgentRuntimeController(previous)
const stream = controller.run(
{
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b13',
conversationId: 'conversation-drain',
prompt: 'test',
workMode: 'ask'
},
new AbortController().signal
)
const pending = stream.next()
await previous.started
await controller.replace(next)
expect(previous.beginDrain).toHaveBeenCalledOnce()
expect(previous.dispose).not.toHaveBeenCalled()
previous.finish()
await expect(pending).resolves.toMatchObject({
value: { type: 'text' }
})
await stream.return()
expect(previous.dispose).not.toHaveBeenCalled()
await controller.releaseConversation('conversation-drain')
await vi.waitFor(() =>
expect(previous.dispose).toHaveBeenCalledOnce()
)
await controller.dispose()
})
it('clears conversation ownership when a Runtime release fails', async () => {
const runtime = new TestRuntime()
runtime.releaseConversation = vi.fn(async () => {
throw new Error('release failed')
})
const controller = new AgentRuntimeController(runtime)
const stream = controller.run(
{
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b16',
conversationId: 'conversation-release-failure',
prompt: 'test',
workMode: 'ask'
},
new AbortController().signal
)
await stream.next()
await stream.return()
expect(controller.ownedConversationCount).toBe(1)
await expect(
controller.releaseConversation(
'conversation-release-failure'
)
).rejects.toBeInstanceOf(AggregateError)
expect(controller.ownedConversationCount).toBe(0)
expect(controller.canRetire).toBe(true)
await controller.dispose()
})
it('counts a draining generation until disposal actually settles', async () => {
const previous = new DrainableTestRuntime()
let finishDisposal!: () => void
previous.dispose.mockImplementation(
async () =>
await new Promise<void>((resolve) => {
finishDisposal = resolve
})
)
const controller = new AgentRuntimeController(previous)
await controller.replace(new TestRuntime())
await controller.releaseConversation('conversation-disposal')
await vi.waitFor(() =>
expect(previous.dispose).toHaveBeenCalledOnce()
)
expect(controller.replacementStatus.drainingGenerations).toBe(1)
finishDisposal()
await vi.waitFor(() =>
expect(
controller.replacementStatus.drainingGenerations
).toBe(0)
)
await controller.dispose()
})
it('bounds retained draining generations and rejects further replacements', async () => {
const first = new DrainableTestRuntime()
const second = new DrainableTestRuntime()
const third = new DrainableTestRuntime()
const rejected = new TestRuntime()
const controller = new AgentRuntimeController(first)
await controller.replace(second)
await controller.replace(third)
expect(controller.replacementStatus).toEqual({
drainingGenerations: MAX_DRAINING_RUNTIME_GENERATIONS,
failedDrainingGenerations: 0,
pendingReplacements: 0,
maximumDrainingGenerations: MAX_DRAINING_RUNTIME_GENERATIONS,
saturated: true
})
const replacement = controller.replace(rejected)
await expect(replacement).rejects.toBeInstanceOf(
RuntimeReplacementCapacityError
)
await expect(replacement).rejects.toMatchObject({
code: 'runtime-drain-capacity',
occupiedGenerations: MAX_DRAINING_RUNTIME_GENERATIONS,
maximumGenerations: MAX_DRAINING_RUNTIME_GENERATIONS
})
expect(rejected.dispose).toHaveBeenCalledOnce()
expect(first.forceShutdown).not.toHaveBeenCalled()
expect(second.forceShutdown).not.toHaveBeenCalled()
await controller.releaseConversation('conversation-drain')
await vi.waitFor(() => {
expect(first.dispose).toHaveBeenCalledOnce()
expect(second.dispose).toHaveBeenCalledOnce()
})
await controller.dispose()
})
it('retains a recoverable generation when drain observation fails', async () => {
const previous = new DrainableTestRuntime()
previous.waitForDrain = vi.fn(async () => {
throw new Error('temporary drain status failure')
})
const controller = new AgentRuntimeController(previous, 1)
await controller.replace(new TestRuntime())
await vi.waitFor(() =>
expect(controller.replacementStatus).toMatchObject({
drainingGenerations: 1,
failedDrainingGenerations: 1
})
)
expect(previous.dispose).not.toHaveBeenCalled()
expect(previous.forceShutdown).not.toHaveBeenCalled()
await controller.dispose()
expect(previous.forceShutdown).toHaveBeenCalledOnce()
expect(previous.dispose).toHaveBeenCalledOnce()
})
it('uses the configured shutdown grace while replacing a busy runtime', async () => {
vi.useFakeTimers()
try {
const previous = new TestRuntime(true)
const controller = new AgentRuntimeController(previous, 25)
const stream = controller.run(
{
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b14',
conversationId: 'conversation-replacement-grace',
prompt: 'test',
workMode: 'ask'
},
new AbortController().signal
)
const pending = stream.next()
await previous.started
const replacement = controller.replace(new TestRuntime())
await vi.advanceTimersByTimeAsync(24)
let replaced = false
void replacement.then(() => {
replaced = true
})
await Promise.resolve()
expect(replaced).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await replacement
expect(replaced).toBe(true)
previous.finish()
await expect(pending).rejects.toThrow('Runtime 已切换')
await stream.return()
await controller.dispose()
} finally {
vi.useRealTimers()
}
})
it('clears bounded-wait timers after fast lifecycle operations', async () => {
vi.useFakeTimers()
try {
const controller = new AgentRuntimeController(
new TestRuntime(),
25
)
await controller.replace(new TestRuntime())
expect(vi.getTimerCount()).toBe(0)
await controller.dispose()
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('forces runtime disposal when active work does not stop during shutdown', async () => {
const runtime = new TestRuntime(true)
const controller = new AgentRuntimeController(runtime, 1)
+271 -38
View File
@@ -16,14 +16,67 @@ type RuntimeSlot = {
runtime: AgentRuntime
activeRequests: number
retiring: boolean
drainable: boolean
drainStarted?: Promise<void>
drainError?: Error
disposal?: Promise<void>
disposeStarted?: Promise<void>
resolveDisposal?: () => void
}
export const MAX_DRAINING_RUNTIME_GENERATIONS = 2
export type RuntimeReplacementStatus = {
drainingGenerations: number
failedDrainingGenerations: number
pendingReplacements: number
maximumDrainingGenerations: number
saturated: boolean
}
export class RuntimeReplacementCapacityError extends Error {
readonly code = 'runtime-drain-capacity'
constructor(
readonly occupiedGenerations: number,
readonly maximumGenerations: number
) {
super(
`Runtime replacement is unavailable while previous generations are draining (${occupiedGenerations}/${maximumGenerations})`
)
this.name = 'RuntimeReplacementCapacityError'
}
}
export interface DrainableAgentRuntime extends AgentRuntime {
/** Stop accepting new sessions while existing session ownership remains. */
beginDrain(): Promise<void>
/** Resolve only after all owned sessions are released or interrupted. */
waitForDrain(): Promise<void>
/** Explicit bounded-shutdown escalation; never used by replacement. */
forceShutdown(): Promise<void>
}
function isDrainableRuntime(
runtime: AgentRuntime
): runtime is DrainableAgentRuntime {
const candidate = runtime as Partial<DrainableAgentRuntime>
return (
typeof candidate.beginDrain === 'function' &&
typeof candidate.waitForDrain === 'function' &&
typeof candidate.forceShutdown === 'function'
)
}
export class AgentRuntimeController implements AgentRuntime {
private current: RuntimeSlot
private readonly retired = new Set<RuntimeSlot>()
private replacementQueue: Promise<void> = Promise.resolve()
private pendingReplacements = 0
private closing = false
private retirement?: Promise<void>
private disposal?: Promise<void>
private readonly ownedConversationIds = new Set<string>()
constructor(
runtime: AgentRuntime,
@@ -32,7 +85,8 @@ export class AgentRuntimeController implements AgentRuntime {
this.current = {
runtime,
activeRequests: 0,
retiring: false
retiring: false,
drainable: isDrainableRuntime(runtime)
}
}
@@ -56,15 +110,69 @@ export class AgentRuntimeController implements AgentRuntime {
return this.current.runtime.capability
}
get replacementStatus(): RuntimeReplacementStatus {
const drainingGenerations = [...this.retired].filter(
(slot) => slot.drainable
).length
const failedDrainingGenerations = [...this.retired].filter(
(slot) =>
slot.drainable &&
slot.drainError !== undefined
).length
const occupiedGenerations =
drainingGenerations + this.pendingReplacements
return {
drainingGenerations,
failedDrainingGenerations,
pendingReplacements: this.pendingReplacements,
maximumDrainingGenerations: MAX_DRAINING_RUNTIME_GENERATIONS,
saturated:
occupiedGenerations >= MAX_DRAINING_RUNTIME_GENERATIONS
}
}
get activeRequestCount(): number {
return [...new Set([this.current, ...this.retired])].reduce(
(total, slot) => total + slot.activeRequests,
0
)
}
get ownedConversationCount(): number {
return this.ownedConversationIds.size
}
get canRetire(): boolean {
return (
!this.closing &&
this.activeRequestCount === 0 &&
this.ownedConversationIds.size === 0
)
}
replace(next: AgentRuntime): Promise<void> {
if (this.closing) {
return next.dispose().then(() => {
return this.disposeRejectedRuntime(next).then(() => {
throw new Error('Agent Runtime 正在关闭')
})
}
const operation = this.replacementQueue.then(() =>
this.performReplace(next)
)
const status = this.replacementStatus
const occupiedGenerations =
status.drainingGenerations + status.pendingReplacements
if (occupiedGenerations >= MAX_DRAINING_RUNTIME_GENERATIONS) {
return this.disposeRejectedRuntime(next).then(() => {
throw new RuntimeReplacementCapacityError(
occupiedGenerations,
MAX_DRAINING_RUNTIME_GENERATIONS
)
})
}
this.pendingReplacements += 1
const operation = this.replacementQueue
.then(() => this.performReplace(next))
.finally(() => {
this.pendingReplacements -= 1
})
this.replacementQueue = operation.catch(() => undefined)
return operation
}
@@ -74,13 +182,22 @@ export class AgentRuntimeController implements AgentRuntime {
this.current = {
runtime: next,
activeRequests: 0,
retiring: false
retiring: false,
drainable: isDrainableRuntime(next)
}
const disposal = this.retire(previous)
await Promise.race([
disposal,
new Promise<void>((resolve) => setTimeout(resolve, 2_000))
])
const disposal = this.retireSlot(previous)
if (previous.drainable) {
await previous.drainStarted
return
}
await waitWithin(disposal, this.shutdownGraceMs)
}
private async disposeRejectedRuntime(runtime: AgentRuntime): Promise<void> {
await waitWithin(
runtime.dispose().catch(() => undefined),
this.shutdownGraceMs
)
}
async getStatus(): Promise<AgentRuntimeStatus> {
@@ -107,6 +224,7 @@ export class AgentRuntimeController implements AgentRuntime {
request: RuntimeConversationCompactInput,
signal: AbortSignal
): Promise<RuntimeConversationCompactOutcome> {
this.ownedConversationIds.add(request.conversationId)
return this.invoke((runtime) => {
if (!runtime.compactConversation) {
throw new Error('当前 Runtime 不支持手动压缩')
@@ -141,7 +259,11 @@ export class AgentRuntimeController implements AgentRuntime {
return status
} finally {
slot.activeRequests -= 1
if (slot.retiring && slot.activeRequests === 0) {
if (
slot.retiring &&
!slot.drainable &&
slot.activeRequests === 0
) {
await this.disposeSlot(slot)
}
}
@@ -156,6 +278,7 @@ export class AgentRuntimeController implements AgentRuntime {
throw new Error('Agent Runtime 正在关闭')
}
const slot = this.current
this.ownedConversationIds.add(request.conversationId)
const toolsAllowed = request.workMode === 'execute'
const effectiveAuthorize: RuntimeAuthorizer | undefined = toolsAllowed
? authorize
@@ -186,24 +309,43 @@ export class AgentRuntimeController implements AgentRuntime {
signal,
effectiveAuthorize
)) {
if (slot !== this.current) {
if (slot !== this.current && !slot.drainable) {
throw new Error('Runtime 已切换,当前请求已中断')
}
yield event
}
if (slot !== this.current) {
if (slot !== this.current && !slot.drainable) {
throw new Error('Runtime 已切换,当前请求已中断')
}
} finally {
slot.activeRequests -= 1
if (slot.retiring && slot.activeRequests === 0) {
if (
slot.retiring &&
!slot.drainable &&
slot.activeRequests === 0
) {
await this.disposeSlot(slot)
}
}
}
async releaseConversation(conversationId: string): Promise<void> {
await this.current.runtime.releaseConversation?.(conversationId)
const results = await Promise.allSettled([
this.current.runtime.releaseConversation?.(conversationId),
...[...this.retired].map((slot) =>
slot.runtime.releaseConversation?.(conversationId)
)
])
this.ownedConversationIds.delete(conversationId)
const failures = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
)
if (failures.length > 0) {
throw new AggregateError(
failures,
'One or more Agent Runtimes failed to release the conversation'
)
}
}
async respondToQuestion(
@@ -220,52 +362,143 @@ export class AgentRuntimeController implements AgentRuntime {
await runtime.respondToQuestion(questionId, answers)
}
private retire(slot: RuntimeSlot): Promise<void> {
private retireSlot(slot: RuntimeSlot): Promise<void> {
slot.retiring = true
this.retired.add(slot)
if (!slot.disposal) {
slot.disposal = new Promise((resolve) => {
slot.resolveDisposal = resolve
})
}
if (slot.activeRequests === 0) {
if (slot.drainable && !slot.drainStarted) {
const runtime = slot.runtime as DrainableAgentRuntime
slot.drainStarted = runtime.beginDrain()
void slot.drainStarted
.then(() => runtime.waitForDrain())
.then(() => this.disposeSlot(slot))
.catch((error: unknown) => {
slot.drainError =
error instanceof Error ? error : new Error(String(error))
})
}
if (!slot.drainable && slot.activeRequests === 0) {
void this.disposeSlot(slot)
}
return slot.disposal
}
private async disposeSlot(slot: RuntimeSlot): Promise<void> {
private async disposeSlot(
slot: RuntimeSlot,
timeoutMs?: number,
abandonOnTimeout = false
): Promise<void> {
if (!slot.resolveDisposal) {
return
}
const resolve = slot.resolveDisposal
slot.resolveDisposal = undefined
slot.disposeStarted ??= Promise.resolve().then(() =>
slot.runtime.dispose()
)
let completed = true
try {
await slot.runtime.dispose()
if (timeoutMs === undefined) {
await slot.disposeStarted
} else {
completed = await waitWithin(
slot.disposeStarted,
timeoutMs
)
}
} catch {
resolve()
completed = true
}
if (!completed && !abandonOnTimeout) {
return
}
const resolve = slot.resolveDisposal
if (resolve === undefined) {
return
}
slot.resolveDisposal = undefined
this.retired.delete(slot)
resolve()
}
async dispose(): Promise<void> {
retire(): Promise<void> {
if (this.retirement !== undefined) {
return this.retirement
}
this.closing = true
const operation = this.replacementQueue.then(async () => {
this.retireSlot(this.current)
await Promise.all(
[...this.retired].map(
(slot) => slot.disposal ?? Promise.resolve()
)
)
})
this.retirement = operation
return operation
}
dispose(): Promise<void> {
if (this.disposal !== undefined) {
return this.disposal
}
this.closing = true
const operation = this.replacementQueue.then(async () => {
const slot = this.current
const disposal = this.retire(slot)
if (slot.activeRequests === 0) {
return disposal
}
await Promise.race([
disposal,
new Promise<void>((resolve) =>
setTimeout(resolve, this.shutdownGraceMs)
)
])
await this.disposeSlot(slot)
return disposal
this.retireSlot(slot)
const slots = [...this.retired]
const disposals = slots.map(
(candidate) => candidate.disposal ?? Promise.resolve()
)
await waitWithin(
Promise.all(disposals).then(() => undefined),
this.shutdownGraceMs
)
await Promise.all(
slots.map(async (candidate) => {
if (!candidate.resolveDisposal) {
return
}
if (candidate.drainable) {
await waitWithin(
(
candidate.runtime as DrainableAgentRuntime
).forceShutdown().catch(() => undefined),
this.shutdownGraceMs
)
}
await this.disposeSlot(
candidate,
this.shutdownGraceMs,
true
)
})
)
await Promise.all(disposals)
})
this.replacementQueue = operation.catch(() => undefined)
await operation
this.disposal = operation
return operation
}
}
async function waitWithin(
promise: Promise<void>,
timeoutMs: number
): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
promise.then(() => true),
new Promise<false>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs)
timer.unref?.()
})
])
} finally {
if (timer !== undefined) {
clearTimeout(timer)
}
}
}
+1 -4
View File
@@ -331,8 +331,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
baseUrl,
model: modelName,
protocol,
authentication: 'api-key',
maxOutputTokens: 128
authentication: 'api-key'
})
try {
@@ -382,7 +381,6 @@ describe.runIf(enabled)('runtime end-to-end', () => {
protocol,
authentication: 'api-key',
supportsImageInput: true,
maxOutputTokens: 128,
contextCompression: {
settings: {
...defaultContextCompressionSettings,
@@ -593,7 +591,6 @@ describe.runIf(enabled)('runtime end-to-end', () => {
model: modelName,
protocol,
authentication: 'api-key',
maxOutputTokens: 128,
contextCompression: {
settings: {
...defaultContextCompressionSettings,
@@ -0,0 +1,738 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import type { RuntimeSessionBinding } from '../../shared/remote-agent-contracts'
import {
MemoryRuntimeSessionBindingStore,
RuntimeSessionBindingCorruptionError,
RuntimeSessionBindingConflictError,
RuntimeSessionClaimConflictError,
SqliteRuntimeSessionBindingStore
} from './runtime-session-binding-store'
const digest = `sha256:${'a'.repeat(64)}`
function binding(
bindingId: string,
conversationId: string
): RuntimeSessionBinding {
return {
bindingId,
controllerId: 'controller-1',
controllerGeneration: 1,
conversationId,
hostId: 'host-1',
hostRevision: 1,
hostKeyGeneration: 1,
workspaceIdentity: 'workspace-1',
agentInstallationId: 'installation-1',
daemonBootIdAtOpen: 'boot-1',
runtimeId: 'runtime-1',
runtimeBundleDigest: digest,
runtimeAdapterDigest: digest,
acpSessionId: `opening-${bindingId}`,
acpCapabilitiesDigest: digest,
state: 'opening',
promptSequence: 0,
channelEpoch: '1',
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
}
}
describe('MemoryRuntimeSessionBindingStore', () => {
it('atomically rejects concurrent claims for one live ACP session', async () => {
const store = new MemoryRuntimeSessionBindingStore()
await store.put(binding('binding-1', 'conversation-1'))
await store.put(binding('binding-2', 'conversation-2'))
const claims = await Promise.allSettled([
store.claimAcpSession('binding-1', 'shared-session'),
store.claimAcpSession('binding-2', 'shared-session')
])
expect(claims.filter((claim) => claim.status === 'fulfilled')).toHaveLength(1)
const rejected = claims.find(
(claim): claim is PromiseRejectedResult =>
claim.status === 'rejected'
)
expect(rejected?.reason).toBeInstanceOf(
RuntimeSessionClaimConflictError
)
})
it('allows a closed owner session identity to be claimed again', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const first = binding('binding-1', 'conversation-1')
await store.put(first)
const claimed = await store.claimAcpSession(
first.bindingId,
'session-1'
)
await store.put({ ...claimed, state: 'closed' })
const second = binding('binding-2', 'conversation-2')
await store.put(second)
await expect(
store.claimAcpSession(second.bindingId, 'session-1')
).resolves.toMatchObject({
bindingId: 'binding-2',
acpSessionId: 'session-1'
})
})
it('keeps binding and channel ownership immutable', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const original = binding('binding-1', 'conversation-1')
await store.put(original)
await expect(
store.put({ ...original, channelEpoch: '2' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put({
...original,
controllerGeneration: 2
})
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put({ ...original, hostRevision: 2 })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put(
binding('binding-2', original.conversationId)
)
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
})
it('keeps the persisted model bridge version and policy immutable', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const original = {
...binding('binding-model', 'conversation-model'),
modelBridgeVersion:
'goodbuddy-model-bridge-v1' as const,
modelBridgePolicy: {
protocol: 'openai-responses' as const,
model: 'gpt-test',
modelProfileDigest: digest,
supportsImageInput: false
}
}
await store.put(original)
await expect(
store.put({
...original,
modelBridgePolicy: {
...original.modelBridgePolicy,
model: 'different-model'
}
})
).rejects.toThrow('model bridge policy is immutable')
await expect(
store.put({
...original,
modelBridgeVersion: undefined,
modelBridgePolicy: undefined
})
).rejects.toThrow('modelBridgeVersion is immutable')
})
it('atomically rotates only an idle ready transport and resets its cursors', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const ready = {
...binding('binding-1', 'conversation-1'),
state: 'ready' as const,
acpSessionId: 'session-1',
lastOutboundJournaledSequence: '4',
lastOutboundDeliveredSequence: '3',
lastInboundJournaledSequence: '6',
lastMainAckSequence: '5'
}
await store.put(ready)
await expect(
store.rotateReadyTransport(
ready.bindingId,
{
controllerGeneration: 1,
daemonBootIdAtOpen: 'boot-1',
channelEpoch: '1'
},
{
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2',
channelEpoch: '2'
}
)
).resolves.toMatchObject({
acpSessionId: 'session-1',
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2',
channelEpoch: '2',
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
})
await expect(
store.rotateReadyTransport(
ready.bindingId,
{
controllerGeneration: 1,
daemonBootIdAtOpen: 'boot-1',
channelEpoch: '1'
},
{
controllerGeneration: 3,
daemonBootIdAtOpen: 'boot-3',
channelEpoch: '3'
}
)
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
})
})
describe('SqliteRuntimeSessionBindingStore', () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function databasePath(): Promise<string> {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-runtime-bindings-')
)
temporaryDirectories.push(directory)
return join(directory, 'bindings.sqlite')
}
it.runIf(process.platform !== 'win32')(
'creates the database as a private regular file',
async () => {
const path = await databasePath()
const store = new SqliteRuntimeSessionBindingStore(path)
const file = await stat(path)
expect(file.isFile()).toBe(true)
expect(file.mode & 0o777).toBe(0o600)
store.close()
}
)
it('rejects in-memory and file URI database paths', () => {
expect(
() => new SqliteRuntimeSessionBindingStore(':memory:')
).toThrow('filesystem path')
expect(
() =>
new SqliteRuntimeSessionBindingStore(
'file:runtime-bindings.sqlite'
)
).toThrow('filesystem path')
})
it('persists bindings and cursors across reopen', async () => {
const path = await databasePath()
const first = new SqliteRuntimeSessionBindingStore(path)
const persisted = {
...binding('binding-1', 'conversation-1'),
state: 'ready' as const,
modelBridgeVersion:
'goodbuddy-model-bridge-v1' as const,
modelBridgePolicy: {
protocol: 'openai-responses' as const,
model: 'gpt-test',
modelProfileDigest: digest,
supportsImageInput: false
},
lastOutboundJournaledSequence: '4',
lastOutboundDeliveredSequence: '3',
lastInboundJournaledSequence: '6',
lastMainAckSequence: '5'
}
await first.put(persisted)
first.close()
const reopened = new SqliteRuntimeSessionBindingStore(path)
await expect(reopened.getById(persisted.bindingId)).resolves.toEqual(
persisted
)
const returned = await reopened.getById(persisted.bindingId)
if (!returned) {
throw new Error('Expected persisted binding')
}
returned.lastMainAckSequence = '0'
await expect(reopened.getById(persisted.bindingId)).resolves.toEqual(
persisted
)
reopened.dispose()
})
it('persists an atomic ready transport rotation', async () => {
const path = await databasePath()
const store = new SqliteRuntimeSessionBindingStore(path)
const ready = {
...binding('binding-1', 'conversation-1'),
state: 'ready' as const,
acpSessionId: 'session-1',
lastOutboundJournaledSequence: '4',
lastOutboundDeliveredSequence: '3',
lastInboundJournaledSequence: '6',
lastMainAckSequence: '5'
}
await store.put(ready)
await store.rotateReadyTransport(
ready.bindingId,
{
controllerGeneration: 1,
daemonBootIdAtOpen: 'boot-1',
channelEpoch: '1'
},
{
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2',
channelEpoch: '2'
}
)
store.close()
const reopened = new SqliteRuntimeSessionBindingStore(path)
await expect(reopened.getById(ready.bindingId)).resolves.toMatchObject({
acpSessionId: 'session-1',
state: 'ready',
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2',
channelEpoch: '2',
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
})
reopened.close()
})
it('rejects live conversation conflicts and permits idempotent writes', async () => {
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
const original = binding('binding-1', 'conversation-1')
await store.put(original)
await expect(store.put(original)).resolves.toBeUndefined()
await expect(
store.put(binding('binding-2', original.conversationId))
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
store.close()
})
it('enforces a lower row cap when reopening by pruning only closed rows', async () => {
const path = await databasePath()
const original = new SqliteRuntimeSessionBindingStore(path, {
maximumRows: 3
})
await original.put(binding('binding-live-1', 'conversation-live-1'))
await original.put(binding('binding-live-2', 'conversation-live-2'))
await original.put({
...binding('binding-closed', 'conversation-closed'),
state: 'closed'
})
original.close()
const capped = new SqliteRuntimeSessionBindingStore(path, {
maximumRows: 2
})
await expect(
capped.getById('binding-closed')
).resolves.toBeUndefined()
await expect(
capped.getById('binding-live-1')
).resolves.toBeDefined()
await expect(
capped.getById('binding-live-2')
).resolves.toBeDefined()
capped.close()
expect(
() =>
new SqliteRuntimeSessionBindingStore(path, {
maximumRows: 1
})
).toThrow(RuntimeSessionBindingConflictError)
})
it('atomically resolves competing claims from separate connections', async () => {
const path = await databasePath()
const first = new SqliteRuntimeSessionBindingStore(path)
const second = new SqliteRuntimeSessionBindingStore(path)
await first.put(binding('binding-1', 'conversation-1'))
await second.put(binding('binding-2', 'conversation-2'))
const claims = await Promise.allSettled([
first.claimAcpSession('binding-1', 'shared-session'),
second.claimAcpSession('binding-2', 'shared-session')
])
expect(claims.filter((claim) => claim.status === 'fulfilled')).toHaveLength(
1
)
const failure = claims.find(
(claim): claim is PromiseRejectedResult => claim.status === 'rejected'
)
expect(failure?.reason).toBeInstanceOf(RuntimeSessionClaimConflictError)
first.close()
second.close()
})
it('releases conversation and ACP ownership when a binding closes', async () => {
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
const first = binding('binding-1', 'conversation-1')
await store.put(first)
const claimed = await store.claimAcpSession(first.bindingId, 'session-1')
await store.put({ ...claimed, state: 'closed' })
const replacement = {
...binding('binding-2', first.conversationId),
acpSessionId: 'session-1'
}
await expect(store.put(replacement)).resolves.toBeUndefined()
await expect(
store.getByConversation(first.conversationId)
).resolves.toMatchObject({ bindingId: replacement.bindingId })
store.close()
})
it('rejects immutable mutation and direct ACP identity mutation', async () => {
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
const original = binding('binding-1', 'conversation-1')
await store.put(original)
await expect(
store.put({ ...original, channelEpoch: '2' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put({ ...original, workspaceIdentity: 'workspace-2' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put({ ...original, hostRevision: 2 })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
store.put({ ...original, acpSessionId: 'other-session' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
store.close()
})
it('rejects reopening a closed binding and preserves the closed row', async () => {
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
const original = binding('binding-1', 'conversation-1')
await store.put(original)
const closed = { ...original, state: 'closed' as const }
await store.put(closed)
await expect(
store.put({ ...closed, state: 'ready' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(store.getById(original.bindingId)).resolves.toEqual(closed)
store.close()
})
it('rejects interrupted bindings becoming ready', async () => {
const store = new SqliteRuntimeSessionBindingStore(await databasePath())
const original = binding('binding-1', 'conversation-1')
const interrupted = { ...original, state: 'interrupted' as const }
await store.put(original)
await store.put(interrupted)
await expect(
store.put({ ...interrupted, state: 'ready' })
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(store.getById(original.bindingId)).resolves.toEqual(
interrupted
)
store.close()
})
it('fails closed when persisted JSON is malformed', async () => {
const path = await databasePath()
const store = new SqliteRuntimeSessionBindingStore(path)
const original = binding('binding-1', 'conversation-1')
await store.put(original)
store.close()
const raw = new DatabaseSync(path)
raw
.prepare(`
UPDATE runtime_session_bindings
SET binding_json = ?
WHERE binding_id = ?
`)
.run('{}', original.bindingId)
raw.close()
const reopened = new SqliteRuntimeSessionBindingStore(path)
await expect(reopened.put(original)).rejects.toBeInstanceOf(
RuntimeSessionBindingCorruptionError
)
await expect(reopened.getById(original.bindingId)).rejects.toBeInstanceOf(
RuntimeSessionBindingCorruptionError
)
await expect(
reopened.listByController(original.controllerId)
).rejects.toBeInstanceOf(RuntimeSessionBindingCorruptionError)
reopened.close()
})
it('fails closed when indexed identity disagrees with valid JSON', async () => {
const path = await databasePath()
const store = new SqliteRuntimeSessionBindingStore(path)
const original = binding('binding-1', 'conversation-1')
await store.put(original)
store.close()
const raw = new DatabaseSync(path)
raw
.prepare(`
UPDATE runtime_session_bindings
SET controller_id = ?
WHERE binding_id = ?
`)
.run('controller-corrupt', original.bindingId)
raw.close()
const reopened = new SqliteRuntimeSessionBindingStore(path)
await expect(reopened.getById(original.bindingId)).rejects.toBeInstanceOf(
RuntimeSessionBindingCorruptionError
)
reopened.close()
})
})
describe('RuntimeSessionBindingStore lifecycle parity', () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
it('enforces the same legal and terminal transitions in both stores', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-runtime-bindings-parity-')
)
temporaryDirectories.push(directory)
const sqliteStore = new SqliteRuntimeSessionBindingStore(
join(directory, 'bindings.sqlite')
)
const stores: Array<{
store:
| MemoryRuntimeSessionBindingStore
| SqliteRuntimeSessionBindingStore
close(): void
}> = [
{
store: new MemoryRuntimeSessionBindingStore(),
close() {}
},
{
store: sqliteStore,
close() {
sqliteStore.close()
}
}
]
for (const entry of stores) {
const original = binding('binding-1', 'conversation-1')
const running = {
...original,
state: 'prompt-running' as const,
activePromptOperationId: 'operation-1'
}
await entry.store.put(original)
await entry.store.put(running)
const advancedRunning = {
...running,
lastOutboundJournaledSequence: '1'
}
await entry.store.put(advancedRunning)
await expect(
entry.store.put(running)
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
entry.store.put({
...advancedRunning,
activePromptOperationId: 'operation-2'
})
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
const ready = {
...advancedRunning,
state: 'ready' as const,
activePromptOperationId: undefined
}
await entry.store.put(ready)
await entry.store.put({
...ready,
state: 'interrupted'
})
await expect(
entry.store.put(ready)
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
const interrupted = {
...ready,
state: 'interrupted' as const,
lastOutboundJournaledSequence: '2'
}
await entry.store.put(interrupted)
await entry.store.put({
...interrupted,
state: 'closed'
})
await expect(
entry.store.put(ready)
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
const directReady = binding('binding-2', 'conversation-2')
await entry.store.put(directReady)
await entry.store.put({
...directReady,
state: 'ready'
})
entry.close()
}
})
it('caps rows by pruning only closed bindings in both stores', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-runtime-bindings-cap-')
)
temporaryDirectories.push(directory)
const sqliteStore = new SqliteRuntimeSessionBindingStore(
join(directory, 'bindings.sqlite'),
{ maximumRows: 3 }
)
const stores = [
{
store: new MemoryRuntimeSessionBindingStore({ maximumRows: 3 }),
close() {}
},
{
store: sqliteStore,
close() {
sqliteStore.close()
}
}
]
for (const entry of stores) {
const authority = {
...binding('binding-live', 'conversation-live'),
state: 'interrupted' as const
}
const closed = {
...binding('binding-closed', 'conversation-closed'),
state: 'closed' as const
}
const unknown = {
...binding('binding-unknown', 'conversation-unknown'),
state: 'outcome-unknown' as const,
activePromptOperationId: 'operation-unknown'
}
await entry.store.put(authority)
await entry.store.put(unknown)
await entry.store.put(closed)
await entry.store.put(binding('binding-new', 'conversation-new'))
await expect(
entry.store.getById(closed.bindingId)
).resolves.toBeUndefined()
await expect(
entry.store.getById(authority.bindingId)
).resolves.toEqual(authority)
await expect(
entry.store.getById(unknown.bindingId)
).resolves.toEqual(unknown)
await expect(
entry.store.put(binding('binding-over', 'conversation-over'))
).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError)
await expect(
entry.store.getById(authority.bindingId)
).resolves.toEqual(authority)
entry.close()
}
})
it('prunes closed rows deterministically and paginates both stores', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-runtime-bindings-page-')
)
temporaryDirectories.push(directory)
const sqliteStore = new SqliteRuntimeSessionBindingStore(
join(directory, 'bindings.sqlite'),
{ maximumRows: 10 }
)
const stores = [
{
store: new MemoryRuntimeSessionBindingStore({ maximumRows: 10 }),
close() {}
},
{
store: sqliteStore,
close() {
sqliteStore.close()
}
}
]
for (const entry of stores) {
for (const id of ['binding-3', 'binding-1', 'binding-2']) {
await entry.store.put(binding(id, `conversation-${id}`))
}
const firstPage = await entry.store.listByController('controller-1', {
limit: 2
})
expect(firstPage.map((item) => item.bindingId)).toEqual([
'binding-1',
'binding-2'
])
await expect(
entry.store.listByController('controller-1', {
limit: 2,
afterBindingId: 'binding-2'
})
).resolves.toMatchObject([{ bindingId: 'binding-3' }])
await expect(
entry.store.listByController('controller-1', { limit: 1_001 })
).rejects.toBeInstanceOf(RangeError)
await entry.store.put({
...(await entry.store.getById('binding-1'))!,
state: 'closed'
})
await entry.store.put({
...(await entry.store.getById('binding-2'))!,
state: 'closed'
})
await expect(entry.store.pruneClosed(1)).resolves.toBe(1)
await expect(entry.store.pruneClosed(1_001)).rejects.toBeInstanceOf(
RangeError
)
await expect(
entry.store.getById('binding-1')
).resolves.toBeUndefined()
await expect(
entry.store.getById('binding-2')
).resolves.toBeDefined()
await expect(
entry.store.getById('binding-3')
).resolves.toBeDefined()
entry.close()
}
})
})
@@ -0,0 +1,906 @@
import type { DatabaseSync } from 'node:sqlite'
import { isDeepStrictEqual } from 'node:util'
import {
runtimeSessionBindingSchema,
type RuntimeSessionBinding
} from '../../shared/remote-agent-contracts'
import { openPrivateSqliteDatabase } from './private-sqlite-database'
export type RuntimeSessionBindingListOptions = {
limit?: number
afterBindingId?: string
}
export type RuntimeSessionBindingStoreOptions = {
maximumRows?: number
}
export type RuntimeSessionTransportIdentity = Pick<
RuntimeSessionBinding,
'controllerGeneration' | 'daemonBootIdAtOpen' | 'channelEpoch'
>
const defaultMaximumRows = 10_000
const maximumPageSize = 1_000
const maximumPruneBatch = 1_000
function boundedPositiveInteger(
value: number,
maximum: number,
name: string
): number {
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
throw new RangeError(`${name} must be between 1 and ${maximum}`)
}
return value
}
function resolveMaximumRows(options?: RuntimeSessionBindingStoreOptions): number {
return boundedPositiveInteger(
options?.maximumRows ?? defaultMaximumRows,
1_000_000,
'maximumRows'
)
}
function resolveListOptions(
options?: RuntimeSessionBindingListOptions
): Required<RuntimeSessionBindingListOptions> {
return {
limit: boundedPositiveInteger(
options?.limit ?? maximumPageSize,
maximumPageSize,
'limit'
),
afterBindingId: options?.afterBindingId ?? ''
}
}
/**
* Durable Main-side index for ACP session ownership. Implementations must make
* each put atomic; callers never mutate an object returned by this interface.
*/
export interface RuntimeSessionBindingStore {
getByConversation(
conversationId: string
): Promise<RuntimeSessionBinding | undefined>
getById(bindingId: string): Promise<RuntimeSessionBinding | undefined>
/**
* Atomically claims a Runtime-returned ACP session identity for this live
* binding. A session identity cannot be shared by live bindings, even when
* new/load/resume complete concurrently.
*/
claimAcpSession(
bindingId: string,
acpSessionId: string
): Promise<RuntimeSessionBinding>
/**
* Atomically moves an idle durable ACP session onto a newly opened
* transport epoch. Prompt identity and ACP session ownership remain fixed;
* transport cursors restart at zero for the new epoch.
*/
rotateReadyTransport(
bindingId: string,
expected: RuntimeSessionTransportIdentity,
next: RuntimeSessionTransportIdentity
): Promise<RuntimeSessionBinding>
put(binding: RuntimeSessionBinding): Promise<void>
pruneClosed(limit?: number): Promise<number>
listByController(
controllerId: string,
options?: RuntimeSessionBindingListOptions
): Promise<RuntimeSessionBinding[]>
}
export class RuntimeSessionClaimConflictError extends Error {
readonly code = 'runtime-session-claim-conflict'
constructor(readonly acpSessionId: string) {
super('Runtime ACP session identity is already owned by another live binding')
this.name = 'RuntimeSessionClaimConflictError'
}
}
export class RuntimeSessionBindingConflictError extends Error {
readonly code = 'runtime-session-binding-conflict'
constructor(message: string) {
super(message)
this.name = 'RuntimeSessionBindingConflictError'
}
}
export class RuntimeSessionBindingCorruptionError extends Error {
readonly code = 'runtime-session-binding-corruption'
constructor(readonly bindingId: string, options?: ErrorOptions) {
super(`Persisted Runtime binding ${bindingId} is malformed`, options)
this.name = 'RuntimeSessionBindingCorruptionError'
}
}
const immutableBindingKeys = [
'bindingId',
'controllerId',
'controllerGeneration',
'conversationId',
'hostId',
'hostRevision',
'hostKeyGeneration',
'workspaceIdentity',
'agentInstallationId',
'daemonBootIdAtOpen',
'runtimeId',
'runtimeBundleDigest',
'runtimeAdapterDigest',
'modelBridgeVersion',
'acpCapabilitiesDigest',
'channelEpoch'
] as const
const monotonicCursorKeys = [
'lastOutboundJournaledSequence',
'lastOutboundDeliveredSequence',
'lastInboundJournaledSequence',
'lastMainAckSequence'
] as const
type RuntimeSessionBindingState = RuntimeSessionBinding['state']
const allowedStateTransitions: Readonly<
Record<
RuntimeSessionBindingState,
ReadonlySet<RuntimeSessionBindingState>
>
> = {
opening: new Set([
'opening',
'ready',
'prompt-running',
'interrupted',
'closed'
]),
ready: new Set(['ready', 'prompt-running', 'interrupted', 'closed']),
'prompt-running': new Set([
'prompt-running',
'ready',
'outcome-unknown',
'interrupted',
'closed'
]),
reconciling: new Set([
'reconciling',
'ready',
'outcome-unknown',
'interrupted',
'closed'
]),
'outcome-unknown': new Set([
'outcome-unknown',
'reconciling',
'ready',
'closed'
]),
interrupted: new Set(['interrupted', 'closed']),
closed: new Set(['closed'])
}
function assertBindingUpdateAllowed(
previous: RuntimeSessionBinding,
next: RuntimeSessionBinding
): void {
for (const key of immutableBindingKeys) {
if (previous[key] !== next[key]) {
throw new RuntimeSessionBindingConflictError(
`Runtime binding ${key} is immutable`
)
}
}
if (
!isDeepStrictEqual(
previous.modelBridgePolicy,
next.modelBridgePolicy
)
) {
throw new RuntimeSessionBindingConflictError(
'Runtime binding model bridge policy is immutable'
)
}
if (previous.acpSessionId !== next.acpSessionId) {
throw new RuntimeSessionBindingConflictError(
'ACP session identity must be changed through atomic claim'
)
}
for (const key of monotonicCursorKeys) {
if (BigInt(next[key]) < BigInt(previous[key])) {
throw new RuntimeSessionBindingConflictError(
`Runtime binding ${key} cannot regress`
)
}
}
if (
previous.activePromptOperationId !== undefined &&
next.activePromptOperationId !== undefined &&
previous.activePromptOperationId !==
next.activePromptOperationId
) {
throw new RuntimeSessionBindingConflictError(
'Active prompt operation identity cannot change before terminalization'
)
}
const startingPrompt =
previous.activePromptOperationId === undefined &&
next.activePromptOperationId !== undefined
const expectedPromptSequence = startingPrompt
? previous.state === 'opening'
? previous.promptSequence
: previous.promptSequence + 1
: previous.promptSequence
if (
!Number.isSafeInteger(expectedPromptSequence) ||
next.promptSequence !== expectedPromptSequence
) {
throw new RuntimeSessionBindingConflictError(
'Runtime binding prompt sequence is not the next allowed value'
)
}
if (!allowedStateTransitions[previous.state].has(next.state)) {
throw new RuntimeSessionBindingConflictError(
`Runtime binding cannot transition from ${previous.state} to ${next.state}`
)
}
}
function rotateReadyBinding(
current: RuntimeSessionBinding,
bindingId: string,
expected: RuntimeSessionTransportIdentity,
next: RuntimeSessionTransportIdentity
): RuntimeSessionBinding {
if (
current.bindingId !== bindingId ||
current.state !== 'ready' ||
current.activePromptOperationId !== undefined
) {
throw new RuntimeSessionBindingConflictError(
'Only an idle ready Runtime binding can rotate transport'
)
}
if (
current.controllerGeneration !== expected.controllerGeneration ||
current.daemonBootIdAtOpen !== expected.daemonBootIdAtOpen ||
current.channelEpoch !== expected.channelEpoch
) {
throw new RuntimeSessionBindingConflictError(
'Runtime binding transport changed before rotation'
)
}
if (
expected.controllerGeneration === next.controllerGeneration &&
expected.daemonBootIdAtOpen === next.daemonBootIdAtOpen &&
expected.channelEpoch === next.channelEpoch
) {
return structuredClone(current)
}
return runtimeSessionBindingSchema.parse({
...current,
...next,
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
})
}
type PersistedBindingRow = {
binding_id: string
controller_id: string
conversation_id: string
acp_session_id: string
state: string
binding_json: string
}
const liveBindingPredicate = "state <> 'closed'"
/**
* Durable Runtime binding index. The duplicated, constrained identity columns
* are intentional: they let SQLite enforce live ownership while the complete
* strict contract remains the canonical JSON payload.
*/
export class SqliteRuntimeSessionBindingStore
implements RuntimeSessionBindingStore
{
private database?: DatabaseSync
private readonly maximumRows: number
constructor(
databasePath: string,
options?: RuntimeSessionBindingStoreOptions
) {
this.maximumRows = resolveMaximumRows(options)
const database = openPrivateSqliteDatabase(databasePath)
try {
database.exec('PRAGMA journal_mode = WAL')
database.exec('PRAGMA synchronous = FULL')
database.exec('PRAGMA foreign_keys = ON')
database.exec(`
CREATE TABLE IF NOT EXISTS runtime_session_bindings (
binding_id TEXT PRIMARY KEY
CHECK(length(binding_id) BETWEEN 1 AND 128),
controller_id TEXT NOT NULL
CHECK(length(controller_id) BETWEEN 1 AND 128),
conversation_id TEXT NOT NULL
CHECK(length(conversation_id) BETWEEN 1 AND 128),
acp_session_id TEXT NOT NULL
CHECK(length(acp_session_id) BETWEEN 1 AND 128),
state TEXT NOT NULL CHECK(state IN (
'opening',
'ready',
'prompt-running',
'reconciling',
'closed',
'interrupted',
'outcome-unknown'
)),
binding_json TEXT NOT NULL
CHECK(length(binding_json) BETWEEN 2 AND 32768)
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS
runtime_session_bindings_live_conversation
ON runtime_session_bindings(conversation_id)
WHERE ${liveBindingPredicate};
CREATE UNIQUE INDEX IF NOT EXISTS
runtime_session_bindings_live_acp_session
ON runtime_session_bindings(acp_session_id)
WHERE ${liveBindingPredicate};
CREATE INDEX IF NOT EXISTS
runtime_session_bindings_controller
ON runtime_session_bindings(controller_id, binding_id);
`)
this.database = database
this.enforceExistingCapacity(database)
} catch (error) {
this.database = undefined
database.close()
throw error
}
}
async getByConversation(
conversationId: string
): Promise<RuntimeSessionBinding | undefined> {
const rows = this.requireDatabase()
.prepare(`
SELECT binding_id, controller_id, conversation_id, acp_session_id,
state, binding_json
FROM runtime_session_bindings
WHERE conversation_id = ? AND ${liveBindingPredicate}
`)
.all(conversationId) as PersistedBindingRow[]
if (rows.length > 1) {
throw new RuntimeSessionBindingCorruptionError(rows[0]!.binding_id)
}
return rows[0] ? this.decode(rows[0]) : undefined
}
async getById(
bindingId: string
): Promise<RuntimeSessionBinding | undefined> {
const row = this.selectById(bindingId)
return row ? this.decode(row) : undefined
}
async put(binding: RuntimeSessionBinding): Promise<void> {
const parsed = runtimeSessionBindingSchema.parse(structuredClone(binding))
const database = this.requireDatabase()
this.transaction(database, () => {
const previousRow = this.selectById(parsed.bindingId)
if (previousRow) {
const previous = this.decode(previousRow)
assertBindingUpdateAllowed(previous, parsed)
} else {
this.makeCapacityForInsert(database)
}
if (parsed.state !== 'closed') {
this.assertConversationAvailable(
parsed.conversationId,
parsed.bindingId
)
this.assertSessionAvailable(parsed.acpSessionId, parsed.bindingId)
}
database
.prepare(`
INSERT INTO runtime_session_bindings (
binding_id, controller_id, conversation_id, acp_session_id,
state, binding_json
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(binding_id) DO UPDATE SET
controller_id = excluded.controller_id,
conversation_id = excluded.conversation_id,
acp_session_id = excluded.acp_session_id,
state = excluded.state,
binding_json = excluded.binding_json
`)
.run(
parsed.bindingId,
parsed.controllerId,
parsed.conversationId,
parsed.acpSessionId,
parsed.state,
JSON.stringify(parsed)
)
})
}
async claimAcpSession(
bindingId: string,
acpSessionId: string
): Promise<RuntimeSessionBinding> {
const database = this.requireDatabase()
let result: RuntimeSessionBinding | undefined
this.transaction(database, () => {
const row = this.selectById(bindingId)
if (!row) {
throw new RuntimeSessionBindingConflictError(
'Cannot claim an ACP session for an unknown binding'
)
}
const current = this.decode(row)
if (current.state === 'closed') {
throw new RuntimeSessionBindingConflictError(
'Cannot claim an ACP session for a closed binding'
)
}
const claimed = runtimeSessionBindingSchema.parse({
...current,
acpSessionId
})
this.assertSessionAvailable(claimed.acpSessionId, bindingId)
database
.prepare(`
UPDATE runtime_session_bindings
SET acp_session_id = ?, binding_json = ?
WHERE binding_id = ?
`)
.run(
claimed.acpSessionId,
JSON.stringify(claimed),
claimed.bindingId
)
result = structuredClone(claimed)
})
if (!result) {
throw new RuntimeSessionBindingConflictError(
'ACP session claim did not produce a binding'
)
}
return structuredClone(result)
}
async rotateReadyTransport(
bindingId: string,
expected: RuntimeSessionTransportIdentity,
next: RuntimeSessionTransportIdentity
): Promise<RuntimeSessionBinding> {
const database = this.requireDatabase()
let result: RuntimeSessionBinding | undefined
this.transaction(database, () => {
const row = this.selectById(bindingId)
if (!row) {
throw new RuntimeSessionBindingConflictError(
'Cannot rotate transport for an unknown Runtime binding'
)
}
const rotated = rotateReadyBinding(
this.decode(row),
bindingId,
expected,
next
)
database
.prepare(`
UPDATE runtime_session_bindings
SET binding_json = ?
WHERE binding_id = ?
`)
.run(JSON.stringify(rotated), rotated.bindingId)
result = structuredClone(rotated)
})
if (!result) {
throw new RuntimeSessionBindingConflictError(
'Runtime binding transport rotation did not produce a binding'
)
}
return structuredClone(result)
}
async listByController(
controllerId: string,
options?: RuntimeSessionBindingListOptions
): Promise<RuntimeSessionBinding[]> {
const page = resolveListOptions(options)
const rows = this.requireDatabase()
.prepare(`
SELECT binding_id, controller_id, conversation_id, acp_session_id,
state, binding_json
FROM runtime_session_bindings
WHERE controller_id = ? AND binding_id > ?
ORDER BY binding_id
LIMIT ?
`)
.all(
controllerId,
page.afterBindingId,
page.limit
) as PersistedBindingRow[]
return rows.map((row) => this.decode(row))
}
async pruneClosed(limit = maximumPruneBatch): Promise<number> {
const boundedLimit = boundedPositiveInteger(
limit,
maximumPruneBatch,
'limit'
)
const database = this.requireDatabase()
let deleted = 0
this.transaction(database, () => {
deleted = this.deleteClosed(database, boundedLimit)
})
return deleted
}
close(): void {
this.database?.close()
this.database = undefined
}
dispose(): void {
this.close()
}
private selectById(bindingId: string): PersistedBindingRow | undefined {
return this.requireDatabase()
.prepare(`
SELECT binding_id, controller_id, conversation_id, acp_session_id,
state, binding_json
FROM runtime_session_bindings
WHERE binding_id = ?
`)
.get(bindingId) as PersistedBindingRow | undefined
}
private decode(row: PersistedBindingRow): RuntimeSessionBinding {
try {
const parsed = runtimeSessionBindingSchema.parse(
JSON.parse(row.binding_json)
)
if (
parsed.bindingId !== row.binding_id ||
parsed.controllerId !== row.controller_id ||
parsed.conversationId !== row.conversation_id ||
parsed.acpSessionId !== row.acp_session_id ||
parsed.state !== row.state
) {
throw new Error('Indexed binding fields do not match payload')
}
return structuredClone(parsed)
} catch (error) {
throw new RuntimeSessionBindingCorruptionError(row.binding_id, {
cause: error
})
}
}
private assertConversationAvailable(
conversationId: string,
bindingId: string
): void {
const owner = this.requireDatabase()
.prepare(`
SELECT binding_id, controller_id, conversation_id, acp_session_id,
state, binding_json
FROM runtime_session_bindings
WHERE conversation_id = ? AND binding_id <> ?
AND ${liveBindingPredicate}
`)
.get(conversationId, bindingId) as PersistedBindingRow | undefined
if (owner) {
this.decode(owner)
throw new RuntimeSessionBindingConflictError(
'Conversation is already owned by another live Runtime binding'
)
}
}
private assertSessionAvailable(
acpSessionId: string,
bindingId: string
): void {
const owner = this.requireDatabase()
.prepare(`
SELECT binding_id, controller_id, conversation_id, acp_session_id,
state, binding_json
FROM runtime_session_bindings
WHERE acp_session_id = ? AND binding_id <> ?
AND ${liveBindingPredicate}
`)
.get(acpSessionId, bindingId) as PersistedBindingRow | undefined
if (owner) {
this.decode(owner)
throw new RuntimeSessionClaimConflictError(acpSessionId)
}
}
private makeCapacityForInsert(database: DatabaseSync): void {
const row = database
.prepare(
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
)
.get() as { count: number }
const required = row.count - this.maximumRows + 1
if (required <= 0) {
return
}
this.deleteClosed(database, required)
const remaining = database
.prepare(
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
)
.get() as { count: number }
if (remaining.count >= this.maximumRows) {
throw new RuntimeSessionBindingConflictError(
'Runtime binding store capacity is exhausted by authoritative bindings'
)
}
}
private enforceExistingCapacity(database: DatabaseSync): void {
const row = database
.prepare(
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
)
.get() as { count: number }
const excess = row.count - this.maximumRows
if (excess <= 0) {
return
}
this.transaction(database, () => {
this.deleteClosed(database, excess)
const remaining = database
.prepare(
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
)
.get() as { count: number }
if (remaining.count > this.maximumRows) {
throw new RuntimeSessionBindingConflictError(
'Runtime binding store exceeds capacity with authoritative bindings'
)
}
})
}
private deleteClosed(database: DatabaseSync, limit: number): number {
const rows = database
.prepare(`
SELECT binding_id, controller_id, conversation_id, acp_session_id,
state, binding_json
FROM runtime_session_bindings
WHERE state = 'closed'
ORDER BY binding_id
LIMIT ?
`)
.all(limit) as PersistedBindingRow[]
for (const row of rows) {
const binding = this.decode(row)
if (binding.state !== 'closed') {
throw new RuntimeSessionBindingCorruptionError(binding.bindingId)
}
}
const remove = database.prepare(
'DELETE FROM runtime_session_bindings WHERE binding_id = ?'
)
let deleted = 0
for (const row of rows) {
deleted += Number(remove.run(row.binding_id).changes)
}
return deleted
}
private transaction(database: DatabaseSync, operation: () => void): void {
database.exec('BEGIN IMMEDIATE')
try {
operation()
database.exec('COMMIT')
} catch (error) {
try {
database.exec('ROLLBACK')
} catch {
// Preserve the original domain, validation, or SQLite failure.
}
throw error
}
}
private requireDatabase(): DatabaseSync {
if (!this.database) {
throw new Error('Runtime session binding store is closed')
}
return this.database
}
}
/** A strict store useful for tests and non-persistent composition roots. */
export class MemoryRuntimeSessionBindingStore
implements RuntimeSessionBindingStore
{
private readonly bindings = new Map<string, RuntimeSessionBinding>()
private readonly maximumRows: number
constructor(options?: RuntimeSessionBindingStoreOptions) {
this.maximumRows = resolveMaximumRows(options)
}
async getByConversation(
conversationId: string
): Promise<RuntimeSessionBinding | undefined> {
const binding = [...this.bindings.values()].find(
(candidate) =>
candidate.conversationId === conversationId &&
candidate.state !== 'closed'
)
return binding ? structuredClone(binding) : undefined
}
async getById(
bindingId: string
): Promise<RuntimeSessionBinding | undefined> {
const binding = this.bindings.get(bindingId)
return binding ? structuredClone(binding) : undefined
}
async put(binding: RuntimeSessionBinding): Promise<void> {
const parsed = runtimeSessionBindingSchema.parse(binding)
const previous = this.bindings.get(parsed.bindingId)
if (previous) {
assertBindingUpdateAllowed(previous, parsed)
} else {
this.makeCapacityForInsert()
}
if (parsed.state !== 'closed') {
const conversationOwner = [...this.bindings.values()].find(
(candidate) =>
candidate.bindingId !== parsed.bindingId &&
candidate.state !== 'closed' &&
candidate.conversationId === parsed.conversationId
)
if (conversationOwner) {
throw new RuntimeSessionBindingConflictError(
'Conversation is already owned by another live Runtime binding'
)
}
this.assertSessionAvailable(parsed.acpSessionId, parsed.bindingId)
}
this.bindings.set(parsed.bindingId, structuredClone(parsed))
}
async claimAcpSession(
bindingId: string,
acpSessionId: string
): Promise<RuntimeSessionBinding> {
const current = this.bindings.get(bindingId)
if (!current) {
throw new RuntimeSessionBindingConflictError(
'Cannot claim an ACP session for an unknown binding'
)
}
if (current.state === 'closed') {
throw new RuntimeSessionBindingConflictError(
'Cannot claim an ACP session for a closed binding'
)
}
this.assertSessionAvailable(acpSessionId, bindingId)
const claimed = runtimeSessionBindingSchema.parse({
...current,
acpSessionId
})
this.bindings.set(bindingId, structuredClone(claimed))
return structuredClone(claimed)
}
async rotateReadyTransport(
bindingId: string,
expected: RuntimeSessionTransportIdentity,
next: RuntimeSessionTransportIdentity
): Promise<RuntimeSessionBinding> {
const current = this.bindings.get(bindingId)
if (!current) {
throw new RuntimeSessionBindingConflictError(
'Cannot rotate transport for an unknown Runtime binding'
)
}
const rotated = rotateReadyBinding(
current,
bindingId,
expected,
next
)
this.bindings.set(bindingId, structuredClone(rotated))
return structuredClone(rotated)
}
private assertSessionAvailable(
acpSessionId: string,
bindingId: string
): void {
const owner = [...this.bindings.values()].find(
(candidate) =>
candidate.bindingId !== bindingId &&
candidate.state !== 'closed' &&
candidate.acpSessionId === acpSessionId
)
if (owner) {
throw new RuntimeSessionClaimConflictError(acpSessionId)
}
}
async listByController(
controllerId: string,
options?: RuntimeSessionBindingListOptions
): Promise<RuntimeSessionBinding[]> {
const page = resolveListOptions(options)
return [...this.bindings.values()]
.filter(
(binding) =>
binding.controllerId === controllerId &&
binding.bindingId > page.afterBindingId
)
.sort((left, right) =>
left.bindingId < right.bindingId
? -1
: left.bindingId > right.bindingId
? 1
: 0
)
.slice(0, page.limit)
.map((binding) => structuredClone(binding))
}
async pruneClosed(limit = maximumPruneBatch): Promise<number> {
const boundedLimit = boundedPositiveInteger(
limit,
maximumPruneBatch,
'limit'
)
return this.deleteClosed(boundedLimit)
}
private makeCapacityForInsert(): void {
const required = this.bindings.size - this.maximumRows + 1
if (required <= 0) {
return
}
this.deleteClosed(required)
if (this.bindings.size >= this.maximumRows) {
throw new RuntimeSessionBindingConflictError(
'Runtime binding store capacity is exhausted by authoritative bindings'
)
}
}
private deleteClosed(limit: number): number {
const closedIds = [...this.bindings.values()]
.filter((binding) => binding.state === 'closed')
.map((binding) => binding.bindingId)
.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
.slice(0, limit)
for (const bindingId of closedIds) {
this.bindings.delete(bindingId)
}
return closedIds.length
}
}
+283 -13
View File
@@ -5,6 +5,34 @@ import type {
} from './runtime'
import { SelectedRuntimeManager } from './selected-runtime-manager'
import { describe, expect, it, vi } from 'vitest'
import type { ExecutionSpaceDescriptor } from '../execution-space'
function executionSpace(
cacheIdentity: string
): ExecutionSpaceDescriptor {
return {
kind: 'local',
rootPath: cacheIdentity,
cacheIdentity,
routeIdentity: cacheIdentity,
workspaceAccess: {} as ExecutionSpaceDescriptor['workspaceAccess']
}
}
function remoteExecutionSpace(
hostId: string,
cacheIdentity: string
): ExecutionSpaceDescriptor {
return {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project',
cacheIdentity,
routeIdentity: cacheIdentity,
workspaceAccess:
{} as ExecutionSpaceDescriptor['workspaceAccess']
}
}
function runtime() {
const releaseConversation = vi.fn(async () => undefined)
@@ -111,30 +139,26 @@ describe('SelectedRuntimeManager', () => {
const manager = new SelectedRuntimeManager(create)
const selection = { provider: 'opencode' as const }
const projectOne = await manager.getRuntime(
selection,
'C:\\Projects\\One'
)
const firstSpace = executionSpace('local:project-one')
const secondSpace = executionSpace('local:project-two')
const projectOne = await manager.getRuntime(selection, firstSpace)
const projectOneAgain = await manager.getRuntime(
selection,
'C:\\Projects\\One'
)
const projectTwo = await manager.getRuntime(
selection,
'C:\\Projects\\Two'
executionSpace('local:project-one')
)
const projectTwo = await manager.getRuntime(selection, secondSpace)
expect(projectOneAgain).toBe(projectOne)
expect(projectTwo).not.toBe(projectOne)
expect(create).toHaveBeenNthCalledWith(
1,
selection,
'C:\\Projects\\One'
firstSpace
)
expect(create).toHaveBeenNthCalledWith(
2,
selection,
'C:\\Projects\\Two'
secondSpace
)
await manager.dispose()
})
@@ -162,6 +186,64 @@ describe('SelectedRuntimeManager', () => {
expect(second.dispose).toHaveBeenCalledOnce()
})
it('retires only cached runtimes for an edited SSH Host', async () => {
const firstHost = runtime()
const secondHost = runtime()
const local = runtime()
const replacement = runtime()
const create = vi
.fn()
.mockResolvedValueOnce(firstHost.value)
.mockResolvedValueOnce(secondHost.value)
.mockResolvedValueOnce(local.value)
.mockResolvedValueOnce(replacement.value)
const manager = new SelectedRuntimeManager(create)
const selection = { provider: 'opencode' as const }
const firstSpace = remoteExecutionSpace(
'host-one',
'ssh:host-one:revision-one'
)
const secondSpace = remoteExecutionSpace(
'host-two',
'ssh:host-two:revision-one'
)
const localSpace = executionSpace('local:project')
const firstController = await manager.getRuntime(
selection,
firstSpace
)
const secondController = await manager.getRuntime(
selection,
secondSpace
)
const localController = await manager.getRuntime(
selection,
localSpace
)
await manager.invalidateHost('host-one')
expect(firstHost.dispose).toHaveBeenCalledOnce()
expect(secondHost.dispose).not.toHaveBeenCalled()
expect(local.dispose).not.toHaveBeenCalled()
expect(
await manager.getRuntime(selection, secondSpace)
).toBe(secondController)
expect(
await manager.getRuntime(selection, localSpace)
).toBe(localController)
expect(
await manager.getRuntime(selection, firstSpace)
).not.toBe(firstController)
expect(create).toHaveBeenCalledTimes(4)
await manager.dispose()
expect(secondHost.dispose).toHaveBeenCalledOnce()
expect(local.dispose).toHaveBeenCalledOnce()
expect(replacement.dispose).toHaveBeenCalledOnce()
})
it('disposes a connection-test runtime without caching it', async () => {
const tested = runtime()
const cached = runtime()
@@ -194,7 +276,10 @@ describe('SelectedRuntimeManager', () => {
const selection = { provider: 'opencode' as const }
await expect(
manager.getNativeSnapshot(selection, 'C:\\Projects\\One')
manager.getNativeSnapshot(
selection,
executionSpace('local:project-one')
)
).resolves.toMatchObject({
provider: 'opencode',
inventoryStatus: 'available'
@@ -202,7 +287,10 @@ describe('SelectedRuntimeManager', () => {
expect(inspected.getNativeSnapshot).toHaveBeenCalledOnce()
expect(inspected.dispose).toHaveBeenCalledOnce()
await manager.getRuntime(selection, 'C:\\Projects\\One')
await manager.getRuntime(
selection,
executionSpace('local:project-one')
)
expect(create).toHaveBeenCalledTimes(2)
await manager.dispose()
})
@@ -297,6 +385,152 @@ describe('SelectedRuntimeManager', () => {
await manager.dispose()
})
it('retires a drainable Runtime without forcing it and keeps routing conversation release', async () => {
let finishDrain!: () => void
const drain = new Promise<void>((resolve) => {
finishDrain = resolve
})
const active = runtime()
const beginDrain = vi.fn(async () => undefined)
const waitForDrain = vi.fn(async () => await drain)
const forceShutdown = vi.fn(async () => undefined)
Object.assign(active.value, {
beginDrain,
waitForDrain,
forceShutdown
})
const manager = new SelectedRuntimeManager(
vi.fn(async () => active.value)
)
const controller = await manager.getRuntime({
provider: 'opencode'
})
const stream = controller.run(
{
requestId: '00000000-0000-4000-8000-000000000021',
conversationId: 'conversation-draining',
prompt: 'finish normally',
workMode: 'ask'
},
new AbortController().signal
)
await stream.next()
await stream.next()
await manager.reset()
expect(beginDrain).toHaveBeenCalledOnce()
expect(forceShutdown).not.toHaveBeenCalled()
expect(active.dispose).not.toHaveBeenCalled()
await manager.releaseConversation('conversation-draining')
expect(active.releaseConversation).toHaveBeenCalledWith(
'conversation-draining'
)
finishDrain()
await vi.waitFor(() =>
expect(active.dispose).toHaveBeenCalledOnce()
)
expect(forceShutdown).not.toHaveBeenCalled()
await manager.dispose()
})
it('evicts only the least-recently-used idle Runtime at the cache bound', async () => {
const first = runtime()
const second = runtime()
const third = runtime()
const create = vi
.fn()
.mockResolvedValueOnce(first.value)
.mockResolvedValueOnce(second.value)
.mockResolvedValueOnce(third.value)
const manager = new SelectedRuntimeManager(create, 2)
const selection = (suffix: string) => ({
provider: 'model' as const,
profileId: `00000000-0000-4000-8000-0000000000${suffix}`
})
const firstController = await manager.getRuntime(selection('31'))
await manager.getRuntime(selection('32'))
await expect(
manager.getRuntime(selection('31'))
).resolves.toBe(firstController)
await manager.getRuntime(selection('33'))
await vi.waitFor(() =>
expect(second.dispose).toHaveBeenCalledOnce()
)
expect(first.dispose).not.toHaveBeenCalled()
expect(third.dispose).not.toHaveBeenCalled()
await manager.dispose()
})
it('bounds current and drain-retained Runtime controllers across resets', async () => {
const created = [runtime(), runtime(), runtime()]
for (const item of created) {
Object.assign(item.value, {
beginDrain: vi.fn(async () => undefined),
waitForDrain: vi.fn(async () => await new Promise(() => undefined)),
forceShutdown: vi.fn(async () => undefined)
})
}
const create = vi
.fn()
.mockResolvedValueOnce(created[0]!.value)
.mockResolvedValueOnce(created[1]!.value)
.mockResolvedValueOnce(created[2]!.value)
const manager = new SelectedRuntimeManager(create, 1, 2)
const selection = (suffix: string) => ({
provider: 'model' as const,
profileId: `00000000-0000-4000-8000-0000000000${suffix}`
})
await manager.getRuntime(selection('41'))
await manager.reset()
await manager.getRuntime(selection('42'))
await manager.reset()
await expect(
manager.getRuntime(selection('43'))
).rejects.toThrow(/退役容量/iu)
expect(create).toHaveBeenCalledTimes(2)
await manager.dispose()
expect(created[0]!.dispose).toHaveBeenCalledOnce()
expect(created[1]!.dispose).toHaveBeenCalledOnce()
})
it('reserves retained capacity while an invalidated Runtime is still being created', async () => {
let finishCreate!: (runtime: AgentRuntime) => void
const pendingCreate = new Promise<AgentRuntime>((resolve) => {
finishCreate = resolve
})
const created = runtime()
const create = vi.fn(async () => await pendingCreate)
const manager = new SelectedRuntimeManager(create, 1, 1)
const selection = (suffix: string) => ({
provider: 'model' as const,
profileId: `00000000-0000-4000-8000-0000000000${suffix}`
})
const firstRuntime = manager
.getRuntime(selection('51'))
.catch((error: unknown) => error)
const reset = manager.reset()
await expect(
manager.getRuntime(selection('52'))
).rejects.toThrow(/退役容量/iu)
expect(create).toHaveBeenCalledOnce()
finishCreate(created.value)
await expect(firstRuntime).resolves.toEqual(
expect.objectContaining({
message: expect.stringMatching(/设置已更改/iu)
})
)
await reset
expect(created.dispose).toHaveBeenCalledOnce()
await manager.dispose()
})
it('releases a conversation from every selected runtime', async () => {
const first = runtime()
const second = runtime()
@@ -321,4 +555,40 @@ describe('SelectedRuntimeManager', () => {
)
await manager.dispose()
})
it('uses only the resolver-owned execution-space cache identity', async () => {
const create = vi.fn(async () => runtime().value)
const manager = new SelectedRuntimeManager(create)
const selection = { provider: 'opencode' as const }
const remote = (
cacheIdentity: string
): ExecutionSpaceDescriptor => ({
kind: 'ssh',
hostId: 'host-one',
remoteRootPath: '/srv/project',
cacheIdentity,
routeIdentity: 'remote-route',
workspaceAccess:
{} as ExecutionSpaceDescriptor['workspaceAccess']
})
const baseline = await manager.getRuntime(
selection,
remote('resolver-identity-one')
)
await expect(
manager.getRuntime(
selection,
remote('resolver-identity-one')
)
).resolves.toBe(baseline)
await expect(
manager.getRuntime(
selection,
remote('resolver-identity-two')
)
).resolves.not.toBe(baseline)
expect(create).toHaveBeenCalledTimes(2)
await manager.dispose()
})
})
+172 -36
View File
@@ -12,11 +12,12 @@ import type {
RuntimeConversationCompactOutcome
} from './runtime'
import { AgentRuntimeController } from './runtime-controller'
import type { ExecutionSpaceDescriptor } from '../execution-space'
export type SelectedRuntimeResolver = {
getRuntime(
selection: AgentRuntimeSelection,
workspacePath?: string
executionSpace?: ExecutionSpaceDescriptor
): Promise<AgentRuntime>
getStatus(
selection: AgentRuntimeSelection
@@ -26,65 +27,124 @@ export type SelectedRuntimeResolver = {
): Promise<AgentRuntimeStatus>
getNativeSnapshot(
selection: AgentRuntimeSelection,
workspacePath?: string
executionSpace?: ExecutionSpaceDescriptor
): Promise<RuntimeNativeSnapshot>
compactConversation(
request: RuntimeConversationCompactInput,
workspacePath: string | undefined,
executionSpace: ExecutionSpaceDescriptor | undefined,
signal: AbortSignal
): Promise<RuntimeConversationCompactOutcome>
releaseConversation(conversationId: string): Promise<void>
reset?(): Promise<void>
}
type RuntimeEntry = {
operation: Promise<AgentRuntimeController>
controller?: AgentRuntimeController
hostId?: string
lastUsed: number
}
const DEFAULT_MAXIMUM_CACHED_RUNTIMES = 8
const DEFAULT_MAXIMUM_RETAINED_RUNTIMES = 16
export class SelectedRuntimeManager implements SelectedRuntimeResolver {
private readonly entries = new Map<
string,
Promise<AgentRuntimeController>
>()
private readonly entries = new Map<string, RuntimeEntry>()
private disposed = false
private readonly retiring = new Set<Promise<void>>()
private readonly retiringControllers =
new Set<AgentRuntimeController>()
private pendingRetirementControllers = 0
private readonly tests = new Set<Promise<AgentRuntimeStatus>>()
private readonly snapshots = new Set<
Promise<RuntimeNativeSnapshot>
>()
private useSequence = 0
constructor(
private readonly createRuntime: (
selection: AgentRuntimeSelection,
workspacePath?: string
) => Promise<AgentRuntime>
) {}
executionSpace?: ExecutionSpaceDescriptor
) => Promise<AgentRuntime>,
private readonly maximumCachedRuntimes =
DEFAULT_MAXIMUM_CACHED_RUNTIMES,
private readonly maximumRetainedRuntimes =
DEFAULT_MAXIMUM_RETAINED_RUNTIMES
) {
if (
!Number.isSafeInteger(maximumCachedRuntimes) ||
maximumCachedRuntimes < 1 ||
maximumCachedRuntimes > 64
) {
throw new RangeError('Invalid selected Runtime cache limit')
}
if (
!Number.isSafeInteger(maximumRetainedRuntimes) ||
maximumRetainedRuntimes < maximumCachedRuntimes ||
maximumRetainedRuntimes > 64
) {
throw new RangeError('Invalid retained Runtime limit')
}
}
async getRuntime(
selection: AgentRuntimeSelection,
workspacePath?: string
executionSpace?: ExecutionSpaceDescriptor
): Promise<AgentRuntimeController> {
if (this.disposed) {
throw new Error('Agent Runtime 正在关闭')
}
const key = JSON.stringify([
agentRuntimeSelectionKey(selection),
workspacePath ?? ''
executionSpace?.cacheIdentity ?? null
])
const existing = this.entries.get(key)
if (existing) {
return existing
existing.lastUsed = ++this.useSequence
return existing.operation
}
const operation = this.createRuntime(selection, workspacePath).then(
this.evictIdleEntries(this.maximumCachedRuntimes - 1)
if (this.entries.size >= this.maximumCachedRuntimes) {
throw new Error(
'Agent Runtime 缓存已被活动会话占满,请先关闭不再使用的会话'
)
}
if (
this.entries.size +
this.retiringControllers.size +
this.pendingRetirementControllers >=
this.maximumRetainedRuntimes
) {
throw new Error(
'Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话'
)
}
const operation = this.createRuntime(selection, executionSpace).then(
async (runtime) => {
if (this.disposed || this.entries.get(key) !== operation) {
if (
this.disposed ||
this.entries.get(key)?.operation !== operation
) {
await runtime.dispose()
throw new Error('Runtime 设置已更改,请重新选择')
}
return new AgentRuntimeController(runtime)
const controller = new AgentRuntimeController(runtime)
entry.controller = controller
return controller
}
)
this.entries.set(key, operation)
const entry: RuntimeEntry = {
operation,
...(executionSpace?.kind === 'ssh'
? { hostId: executionSpace.hostId }
: {}),
lastUsed: ++this.useSequence
}
this.entries.set(key, entry)
try {
return await operation
} catch (error) {
if (this.entries.get(key) === operation) {
if (this.entries.get(key) === entry) {
this.entries.delete(key)
}
throw error
@@ -114,14 +174,14 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
async getNativeSnapshot(
selection: AgentRuntimeSelection,
workspacePath?: string
executionSpace?: ExecutionSpaceDescriptor
): Promise<RuntimeNativeSnapshot> {
if (this.disposed) {
throw new Error('Agent Runtime 正在关闭')
}
const operation = this.runNativeSnapshot(
selection,
workspacePath
executionSpace
)
this.snapshots.add(operation)
try {
@@ -133,27 +193,31 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
async compactConversation(
request: RuntimeConversationCompactInput,
workspacePath: string | undefined,
executionSpace: ExecutionSpaceDescriptor | undefined,
signal: AbortSignal
): Promise<RuntimeConversationCompactOutcome> {
const runtime = await this.getRuntime(
request.runtimeSelection,
workspacePath
executionSpace
)
return runtime.compactConversation(request, signal)
}
async releaseConversation(conversationId: string): Promise<void> {
const controllers = await Promise.allSettled([
...this.entries.values()
...[...this.entries.values()].map(({ operation }) => operation)
])
const current = controllers.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : []
)
await Promise.allSettled(
controllers.flatMap((result) =>
result.status === 'fulfilled'
? [result.value.releaseConversation(conversationId)]
: []
[...new Set([...current, ...this.retiringControllers])].map(
async (controller) => {
await controller.releaseConversation(conversationId)
}
)
)
this.evictIdleEntries(this.maximumCachedRuntimes)
}
async reset(): Promise<void> {
@@ -164,6 +228,20 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
)
}
async invalidateHost(hostId: string): Promise<void> {
const entries: RuntimeEntry[] = []
for (const [key, entry] of this.entries) {
if (entry.hostId !== hostId) {
continue
}
this.entries.delete(key)
entries.push(entry)
}
await Promise.allSettled(
entries.map((entry) => this.startRetiring(entry, false))
)
}
async dispose(): Promise<void> {
this.disposed = true
const entries = [...this.entries.values()]
@@ -171,6 +249,11 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
await Promise.allSettled(
entries.map((entry) => this.startRetiring(entry, true))
)
await Promise.allSettled(
[...this.retiringControllers].map(
async (controller) => await controller.dispose()
)
)
await Promise.allSettled([...this.tests])
await Promise.allSettled([...this.snapshots])
await Promise.allSettled([...this.retiring])
@@ -195,9 +278,9 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
private async runNativeSnapshot(
selection: AgentRuntimeSelection,
workspacePath?: string
executionSpace?: ExecutionSpaceDescriptor
): Promise<RuntimeNativeSnapshot> {
const runtime = await this.createRuntime(selection, workspacePath)
const runtime = await this.createRuntime(selection, executionSpace)
try {
if (this.disposed) {
throw new Error('Agent Runtime 正在关闭')
@@ -212,22 +295,75 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
}
private async startRetiring(
entry: Promise<AgentRuntimeController>,
entry: RuntimeEntry,
waitForDisposal: boolean
): Promise<void> {
this.pendingRetirementControllers += 1
let controllerPending = true
try {
const controller = await entry
const disposal = controller.dispose()
this.retiring.add(disposal)
void disposal.then(
() => this.retiring.delete(disposal),
() => this.retiring.delete(disposal)
const controller =
entry.controller ?? await entry.operation
this.pendingRetirementControllers -= 1
controllerPending = false
const disposal = this.trackRetiringController(
controller,
waitForDisposal
)
if (waitForDisposal) {
await disposal
}
} catch {
return
} finally {
if (controllerPending) {
this.pendingRetirementControllers -= 1
}
}
}
private trackRetiringController(
controller: AgentRuntimeController,
force: boolean
): Promise<void> {
this.retiringControllers.add(controller)
const disposal = force
? controller.dispose()
: controller.retire()
this.retiring.add(disposal)
void disposal.then(
() => {
this.retiring.delete(disposal)
this.retiringControllers.delete(controller)
},
() => {
this.retiring.delete(disposal)
this.retiringControllers.delete(controller)
}
)
return disposal
}
private evictIdleEntries(targetSize: number): void {
if (this.entries.size <= targetSize) {
return
}
const candidates = [...this.entries.entries()]
.filter((entry): entry is [string, RuntimeEntry] => {
const controller = entry[1].controller
return controller !== undefined && controller.canRetire
})
.sort(
(left, right) => left[1].lastUsed - right[1].lastUsed
)
for (const [key, entry] of candidates) {
if (this.entries.size <= targetSize) {
break
}
if (this.entries.get(key) !== entry) {
continue
}
this.entries.delete(key)
void this.startRetiring(entry, false)
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import { AssistantDatabase } from './assistant-database'
import {
ASSISTANT_DATABASE_SCHEMA_VERSION,
AssistantDatabase
} from './assistant-database'
const temporaryDirectories: string[] = []
@@ -102,7 +105,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
).count
check.close()
migrated.close()
expect(version).toBe(26)
expect(version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
expect(heartbeatTableCount).toBe(4)
})
@@ -3,7 +3,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceAccess } from '../workspace'
import {
getWorkspaceChanges,
listWorkspaceDirectory,
@@ -75,6 +76,71 @@ describe('getWorkspaceChanges', () => {
})
describe('workspace file browsing', () => {
it('delegates workspace operations through WorkspaceAccess', async () => {
const listDirectory = vi.fn(async () => ({
path: '',
entries: [
{ name: 'remote.md', path: 'remote.md', type: 'file' as const }
],
truncated: false
}))
const stat = vi.fn(async () => ({
name: 'remote.md',
path: 'remote.md',
type: 'file' as const,
size: 7,
modifiedAt: '2026-08-21T00:00:00.000Z'
}))
const readText = vi.fn(async () => ({
path: 'remote.md',
name: 'remote.md',
content: '# fake\n',
size: 7
}))
const getChanges = vi.fn(async () => ({
rootPath: '/remote',
available: false,
status: '',
patch: '',
files: [],
truncated: false
}))
const workspace = {
getIdentity: vi.fn(),
listDirectory,
stat,
readText,
writeTextAtomic: vi.fn(),
search: vi.fn(),
getChanges,
dispose: vi.fn()
} as unknown as WorkspaceAccess
await expect(
listWorkspaceDirectory(workspace, '')
).resolves.toMatchObject({
entries: [{ name: 'remote.md', type: 'file' }]
})
await expect(
readWorkspaceFile(workspace, 'remote.md')
).resolves.toMatchObject({
content: '# fake\n',
mimeType: 'text/markdown'
})
await expect(getWorkspaceChanges(workspace)).resolves.toMatchObject({
rootPath: '/remote'
})
expect(listDirectory).toHaveBeenCalledWith(
expect.objectContaining({ path: '', maximumEntries: 500 })
)
expect(readText).toHaveBeenCalledWith(
expect.objectContaining({
path: 'remote.md',
maximumBytes: 256 * 1024
})
)
})
it('lists directories and reads bounded Markdown previews', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
temporaryDirectories.push(directory)
+98 -268
View File
@@ -1,23 +1,15 @@
import spawn from 'cross-spawn'
import { basename, extname, join } from 'node:path'
import { stat } from 'node:fs/promises'
import { extname } from 'node:path'
import type {
WorkspaceChangedFile,
WorkspaceChanges,
WorkspaceDirectoryListing,
WorkspaceFilePreview
} from '../../shared/assistant-contracts'
import {
getCanonicalWorkspace,
listBoundedDirectoryEntries,
readBoundedUtf8File,
resolveExistingWorkspacePath
} from '../workspace-file-access'
LocalWorkspaceAccess,
type WorkspaceAccess
} from '../workspace'
const MAX_OUTPUT_BYTES = 512 * 1024
const COMMAND_TIMEOUT_MS = 10_000
const MAX_DIRECTORY_ENTRIES = 500
const MAX_CHANGED_FILES = 2_000
const MAX_PREVIEW_BYTES = 256 * 1024
const previewExtensions = new Set([
'.c',
@@ -64,104 +56,89 @@ const previewFileNames = new Set([
'readme'
])
type CommandResult = {
code: number | null
stdout: string
stderr: string
truncated: boolean
}
export class WorkspaceChangesService {
constructor(private readonly workspace: WorkspaceAccess) {}
function runGit(
rootPath: string,
args: string[]
): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const child = spawn('git', args, {
cwd: rootPath,
shell: false,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe']
getChanges(signal?: AbortSignal): Promise<WorkspaceChanges> {
return this.workspace.getChanges({ signal })
}
async listDirectory(
inputPath: string,
signal?: AbortSignal
): Promise<WorkspaceDirectoryListing> {
const listing = await this.workspace.listDirectory({
path: inputPath,
maximumEntries: MAX_DIRECTORY_ENTRIES,
includeGit: false,
includeOther: false,
signal
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
let bytes = 0
let truncated = false
const capture = (target: Buffer[], chunk: Buffer | string): void => {
const buffer = Buffer.from(chunk)
const remaining = MAX_OUTPUT_BYTES - bytes
if (remaining <= 0) {
truncated = true
return
}
target.push(buffer.subarray(0, remaining))
bytes += Math.min(buffer.byteLength, remaining)
truncated ||= buffer.byteLength > remaining
return {
path: listing.path,
entries: listing.entries.map((entry) => {
if (entry.type === 'other') {
throw new Error('工作区目录返回了不支持的条目类型')
}
return {
name: entry.name,
path: entry.path,
type: entry.type
}
}),
truncated: listing.truncated
}
child.stdout?.on('data', (chunk: Buffer | string) =>
capture(stdout, chunk)
)
child.stderr?.on('data', (chunk: Buffer | string) =>
capture(stderr, chunk)
)
const timeout = setTimeout(() => {
child.kill()
reject(new Error('读取文件更改超时'))
}, COMMAND_TIMEOUT_MS)
child.once('error', (error) => {
clearTimeout(timeout)
reject(error)
}
async readFile(
inputPath: string,
signal?: AbortSignal
): Promise<WorkspaceFilePreview> {
const entry = await this.workspace.stat({
path: inputPath,
signal
})
child.once('close', (code) => {
clearTimeout(timeout)
resolve({
code,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8'),
truncated
})
if (entry.type !== 'file') {
throw new Error('目标不是普通文件')
}
const extension = extname(entry.name).toLowerCase()
if (
!previewExtensions.has(extension) &&
!previewFileNames.has(entry.name.toLowerCase())
) {
throw new Error('当前文件类型不支持安全预览')
}
const preview = await this.workspace.readText({
path: inputPath,
maximumBytes: MAX_PREVIEW_BYTES,
tooLargeMessage: '工作区文件超过 256KB 预览限制',
invalidUtf8Message: '工作区文件不是有效 UTF-8 文本',
signal
})
})
return {
path: preview.path,
name: preview.name,
content: preview.content,
mimeType:
extension === '.md' || extension === '.markdown'
? 'text/markdown'
: extension === '.json'
? 'application/json'
: 'text/plain',
size: preview.size
}
}
}
function pathSegments(inputPath: string, allowRoot: boolean): string[] {
const normalized = inputPath.replaceAll('\\', '/')
if (allowRoot && normalized === '') {
return []
}
if (
!normalized ||
normalized.startsWith('/') ||
/^[a-zA-Z]:\//u.test(normalized)
) {
throw new Error('路径必须是工作区内的相对路径')
}
const segments = normalized.split('/')
if (
segments.some(
(segment) =>
!segment || segment === '.' || segment === '..' || segment.includes('\0')
)
) {
throw new Error('路径必须是工作区内的相对路径')
}
return segments
}
async function resolveWorkspacePath(
async function withLocalWorkspace<T>(
rootPath: string,
inputPath: string,
expected: 'file' | 'directory'
): Promise<{ canonicalPath: string; path: string }> {
const canonicalRoot = await getCanonicalWorkspace(rootPath)
const segments = pathSegments(inputPath, expected === 'directory')
const canonicalPath = await resolveExistingWorkspacePath(
canonicalRoot,
segments,
expected
)
return {
canonicalPath,
path: segments.join('/')
operation: (workspace: LocalWorkspaceAccess) => Promise<T>
): Promise<T> {
const workspace = new LocalWorkspaceAccess(rootPath)
try {
return await operation(workspace)
} finally {
await workspace.dispose()
}
}
@@ -170,189 +147,42 @@ export async function resolveWorkspaceEntryPath(
inputPath: string,
expected: 'file' | 'directory'
): Promise<string> {
return (await resolveWorkspacePath(rootPath, inputPath, expected))
.canonicalPath
}
function parseChangedFiles(status: string): {
files: WorkspaceChangedFile[]
truncated: boolean
} {
const records = status.split('\0')
const files: WorkspaceChangedFile[] = []
let index = 0
while (index < records.length && files.length < MAX_CHANGED_FILES) {
const record = records[index]
index += 1
if (!record) {
continue
}
const statusCode = record.slice(0, 2)
const path = record.slice(3)
if (!path) {
continue
}
const renamed = statusCode.includes('R') || statusCode.includes('C')
const previousPath = renamed ? records[index] : undefined
if (renamed) {
index += 1
}
files.push({
path,
status: statusCode,
...(previousPath ? { previousPath } : {})
})
}
return {
files,
truncated: index < records.length - 1
}
}
function formatChangedFiles(files: WorkspaceChangedFile[]): string {
return files
.map((file) =>
file.previousPath
? `${file.status} ${file.previousPath} -> ${file.path}`
: `${file.status} ${file.path}`
)
.join('\n')
return withLocalWorkspace(rootPath, (workspace) =>
workspace.resolveEntryPath(inputPath, expected)
)
}
export async function getWorkspaceChanges(
rootPath: string
workspace: string | WorkspaceAccess
): Promise<WorkspaceChanges> {
if (!rootPath.trim()) {
return {
rootPath,
available: false,
status: '',
patch: '',
files: [],
truncated: false,
error: '项目尚未配置工作区目录'
}
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).getChanges()
}
const gitMetadata = await stat(join(rootPath, '.git')).catch(
() => undefined
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).getChanges()
)
if (!gitMetadata) {
return {
rootPath,
available: false,
status: '',
patch: '',
files: [],
truncated: false
}
}
try {
const [status, patch] = await Promise.all([
runGit(rootPath, [
'status',
'--porcelain=v1',
'-z',
'--untracked-files=normal'
]),
runGit(rootPath, ['diff', '--no-ext-diff', '--no-color', 'HEAD'])
])
if (status.code !== 0 || patch.code !== 0) {
const detail = status.stderr || patch.stderr
return {
rootPath,
available: false,
status: '',
patch: '',
files: [],
truncated: status.truncated || patch.truncated,
error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区'
}
}
const changedFiles = parseChangedFiles(status.stdout)
return {
rootPath,
available: true,
status: formatChangedFiles(changedFiles.files),
patch: patch.stdout,
files: changedFiles.files,
truncated:
status.truncated || patch.truncated || changedFiles.truncated
}
} catch (error) {
return {
rootPath,
available: false,
status: '',
patch: '',
files: [],
truncated: false,
error:
error instanceof Error ? error.message : '无法读取 Git 工作区'
}
}
}
export async function listWorkspaceDirectory(
rootPath: string,
workspace: string | WorkspaceAccess,
inputPath: string
): Promise<WorkspaceDirectoryListing> {
const directory = await resolveWorkspacePath(
rootPath,
inputPath,
'directory'
)
const listing = await listBoundedDirectoryEntries(
directory.canonicalPath,
MAX_DIRECTORY_ENTRIES,
(entry) =>
entry.name !== '.git' && (entry.isDirectory() || entry.isFile())
)
const entries = listing.entries.sort((left, right) => {
if (left.isDirectory() !== right.isDirectory()) {
return left.isDirectory() ? -1 : 1
}
return left.name.localeCompare(right.name)
})
return {
path: directory.path,
entries: entries.map((entry) => ({
name: entry.name,
path: [directory.path, entry.name].filter(Boolean).join('/'),
type: entry.isDirectory() ? 'directory' : 'file'
})),
truncated: listing.truncated
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).listDirectory(inputPath)
}
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).listDirectory(inputPath)
)
}
export async function readWorkspaceFile(
rootPath: string,
workspace: string | WorkspaceAccess,
inputPath: string
): Promise<WorkspaceFilePreview> {
const file = await resolveWorkspacePath(rootPath, inputPath, 'file')
const name = basename(file.canonicalPath)
const extension = extname(name).toLowerCase()
if (
!previewExtensions.has(extension) &&
!previewFileNames.has(name.toLowerCase())
) {
throw new Error('当前文件类型不支持安全预览')
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).readFile(inputPath)
}
const preview = await readBoundedUtf8File(
file.canonicalPath,
MAX_PREVIEW_BYTES,
'工作区文件超过 256KB 预览限制',
'工作区文件不是有效 UTF-8 文本'
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).readFile(inputPath)
)
return {
path: file.path,
name,
content: preview.content,
mimeType:
extension === '.md' || extension === '.markdown'
? 'text/markdown'
: extension === '.json'
? 'application/json'
: 'text/plain',
size: preview.size
}
}
@@ -0,0 +1,372 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { describe, expect, it, vi } from 'vitest'
import type { AssistantProject } from '../../shared/assistant-contracts'
import { AssistantDatabase } from '../assistant/assistant-database'
import {
ExecutionSpaceResolver,
REMOTE_EXECUTION_SPACE_UNAVAILABLE
} from './execution-space-resolver'
function project(
overrides: Partial<AssistantProject> = {}
): AssistantProject {
return {
id: '00000000-0000-4000-8000-000000000101',
name: 'Project',
description: '',
rootPath: 'C:\\Workspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\Workspace'
},
defaultWorkMode: 'ask',
kind: 'user',
status: 'active',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
...overrides
}
}
describe('ExecutionSpaceResolver', () => {
it('uses a stable local cache identity without accessing the workspace', () => {
const resolver = new ExecutionSpaceResolver()
const first = resolver.resolveProject(project())
const second = resolver.resolveProject(project())
expect(first.kind).toBe('local')
expect(second.cacheIdentity).toBe(first.cacheIdentity)
expect(first.workspaceAccess).not.toBe(second.workspaceAccess)
})
it('keeps every remote workspace operation fail closed', async () => {
const resolver = new ExecutionSpaceResolver()
const descriptor = resolver.resolveProject(
project({
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000201',
remoteRootPath: '/srv/project'
}
})
)
expect(descriptor.kind).toBe('ssh')
await expect(
descriptor.workspaceAccess.listDirectory({ path: '' })
).rejects.toThrow(REMOTE_EXECUTION_SPACE_UNAVAILABLE)
await expect(
descriptor.workspaceAccess.getChanges({})
).rejects.toThrow(REMOTE_EXECUTION_SPACE_UNAVAILABLE)
await expect(
descriptor.workspaceAccess.writeTextAtomic({
path: 'file.txt',
content: 'no'
})
).rejects.toThrow(REMOTE_EXECUTION_SPACE_UNAVAILABLE)
})
it('uses an injected remote factory only for complete matching validation', async () => {
const workspaceAccess = {
getIdentity: vi.fn(),
listDirectory: vi.fn(),
stat: vi.fn(),
readText: vi.fn(),
writeTextAtomic: vi.fn(),
search: vi.fn(),
getChanges: vi.fn(),
dispose: vi.fn()
}
const factory = {
create: vi.fn(() => workspaceAccess)
}
const validation = {
hostRevision: 1,
hostKeyGeneration: 2,
remoteUsername: 'builder',
workspaceIdentity: 'workspace-1',
agentProtocolMajor: 1,
agentInstallationIdAtValidation: 'agent-1',
agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`,
agentVersionAtValidation: '0.11.0',
agentArchitectureAtValidation: 'x64' as const,
validatedAt: '2026-08-01T00:00:00.000Z'
}
const runtimeValidation = {
runtimeSelectionKey: 'opencode',
runtimeBundleDigest: `sha256:${'b'.repeat(64)}`,
runtimeAdapterDigest: `sha256:${'c'.repeat(64)}`,
agentInstallationIdAtValidation: 'agent-1',
validatedAt: '2026-08-01T00:00:00.000Z',
workMode: 'ask' as const
}
const resolver = new ExecutionSpaceResolver(factory)
const descriptor = resolver.resolveProject(
project({
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000201',
remoteRootPath: '/srv/project',
validation
},
runtimeValidation
})
)
expect(descriptor.workspaceAccess).toBe(workspaceAccess)
expect(descriptor).toMatchObject({
kind: 'ssh',
validation: {
agentInstallationIdAtValidation: 'agent-1',
agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`,
agentVersionAtValidation: '0.11.0',
agentArchitectureAtValidation: 'x64'
},
runtimeValidation
})
expect(factory.create).toHaveBeenCalledWith({
hostId: '00000000-0000-4000-8000-000000000201',
remoteRootPath: '/srv/project',
hostRevision: 1,
hostKeyGeneration: 2,
remoteUsername: 'builder',
workspaceIdentity: 'workspace-1',
agentProtocolMajor: 1,
agentInstallationId: 'agent-1',
agentBinaryDigest: `sha256:${'a'.repeat(64)}`,
agentVersion: '0.11.0',
agentArchitecture: 'x64'
})
const stale = resolver.resolveProject(
project({
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000201',
remoteRootPath: '/srv/project',
validation
},
runtimeValidation: {
...runtimeValidation,
agentInstallationIdAtValidation: 'agent-2'
}
})
)
await expect(stale.workspaceAccess.getIdentity()).rejects.toThrow(
REMOTE_EXECUTION_SPACE_UNAVAILABLE
)
expect(factory.create).toHaveBeenCalledOnce()
})
it('includes remote Host, Agent, Runtime, and work-mode identities in the cache identity', () => {
const resolver = new ExecutionSpaceResolver()
const baseValidation = {
hostRevision: 1,
hostKeyGeneration: 2,
remoteUsername: 'builder',
workspaceIdentity: 'workspace-1',
agentProtocolMajor: 1,
agentInstallationIdAtValidation: 'agent-1',
agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`,
agentVersionAtValidation: '0.11.0',
agentArchitectureAtValidation: 'x64' as const,
validatedAt: '2026-08-01T00:00:00.000Z'
}
const runtimeValidation = {
runtimeSelectionKey: 'opencode',
runtimeBundleDigest: `sha256:${'b'.repeat(64)}`,
runtimeAdapterDigest: `sha256:${'c'.repeat(64)}`,
agentInstallationIdAtValidation: 'agent-1',
validatedAt: '2026-08-01T00:00:00.000Z',
workMode: 'ask' as const
}
const first = resolver.resolveProject(
project({
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000201',
remoteRootPath: '/srv/project',
validation: baseValidation
},
runtimeValidation
})
)
const second = resolver.resolveProject(
project({
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000201',
remoteRootPath: '/srv/project',
validation: {
...baseValidation,
hostRevision: 2
}
},
runtimeValidation
})
)
expect(second.cacheIdentity).not.toBe(first.cacheIdentity)
for (const validation of [
{
...baseValidation,
agentBinaryDigestAtValidation: `sha256:${'b'.repeat(64)}`
},
{
...baseValidation,
agentVersionAtValidation: '0.11.1'
},
{
...baseValidation,
agentArchitectureAtValidation: 'arm64' as const
}
]) {
const changed = resolver.resolveProject(
project({
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000201',
remoteRootPath: '/srv/project',
validation
},
runtimeValidation
})
)
expect(changed.cacheIdentity).not.toBe(first.cacheIdentity)
}
for (const changedRuntimeValidation of [
{
...runtimeValidation,
workMode: 'execute' as const
}
]) {
const changed = resolver.resolveProject(
project({
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000201',
remoteRootPath: '/srv/project',
validation: baseValidation
},
runtimeValidation: changedRuntimeValidation
})
)
expect(changed.cacheIdentity).not.toBe(first.cacheIdentity)
}
})
it('changes DB-backed cache identity for host and Runtime generations', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-execution-resolver-')
)
const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath)
const raw = new DatabaseSync(databasePath)
try {
database.initialize('C:\\Workspace')
const projectId = database.listProjects()[0]!.id
raw
.prepare(
`UPDATE project_execution_spaces
SET kind = 'ssh', root_path = ?, ssh_host_id = ?,
host_revision = ?, host_key_generation = ?,
remote_username = ?, workspace_identity = ?,
agent_installation_id_at_validation = ?,
agent_binary_digest_at_validation = ?,
agent_version_at_validation = ?,
agent_architecture_at_validation = ?,
agent_protocol_major = ?,
trust_attestation_revision = ?, validated_at = ?
WHERE project_id = ?`
)
.run(
'/srv/project',
'00000000-0000-4000-8000-000000000201',
1,
1,
'builder',
'workspace-1',
'agent-1',
`sha256:${'a'.repeat(64)}`,
'0.11.0',
'x64',
1,
0,
'2026-08-01T00:00:00.000Z',
projectId
)
raw
.prepare(
`INSERT INTO project_runtime_validations
(project_id, runtime_selection_key,
runtime_bundle_digest, runtime_adapter_digest,
confinement_policy_digest, approval_bridge_version,
agent_installation_id_at_validation, validated_at,
work_mode, trust_tier, trust_attestation_revision)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
projectId,
'opencode',
`sha256:${'b'.repeat(64)}`,
`sha256:${'c'.repeat(64)}`,
`sha256:${'c'.repeat(64)}`,
'unused',
'agent-1',
'2026-08-01T00:00:00.000Z',
'ask',
null,
null
)
const resolver = new ExecutionSpaceResolver()
const baseline = resolver.resolveProject(
database.getProject(projectId)
)
raw
.prepare(
`UPDATE project_execution_spaces
SET host_key_generation = 2
WHERE project_id = ?`
)
.run(projectId)
const nextHostGeneration = resolver.resolveProject(
database.getProject(projectId)
)
raw
.prepare(
`UPDATE project_runtime_validations
SET runtime_bundle_digest = ?
WHERE project_id = ?`
)
.run(`sha256:${'e'.repeat(64)}`, projectId)
const nextRuntimeGeneration = resolver.resolveProject(
database.getProject(projectId)
)
expect(nextHostGeneration.cacheIdentity).not.toBe(
baseline.cacheIdentity
)
expect(nextRuntimeGeneration.cacheIdentity).not.toBe(
nextHostGeneration.cacheIdentity
)
} finally {
raw.close()
database.close()
await rm(directory, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,167 @@
import { isAbsolute, normalize, resolve } from 'node:path'
import type {
AssistantProject,
ProjectRuntimeValidation,
SshExecutionValidation
} from '../../shared/assistant-contracts'
import {
LocalWorkspaceAccess,
UnsupportedRemoteWorkspaceAccess,
type RemoteWorkspaceAccessFactory,
type RemoteWorkspaceProjectBinding,
type WorkspaceAccess
} from '../workspace'
export const REMOTE_EXECUTION_SPACE_UNAVAILABLE =
'远程执行空间尚不可用'
export type LocalExecutionSpaceDescriptor = {
kind: 'local'
rootPath: string
cacheIdentity: string
routeIdentity: string
workspaceAccess: WorkspaceAccess
}
export type SshExecutionSpaceDescriptor = {
kind: 'ssh'
hostId: string
remoteRootPath: string
validation?: SshExecutionValidation
runtimeValidation?: ProjectRuntimeValidation
cacheIdentity: string
routeIdentity: string
workspaceAccess: WorkspaceAccess
}
export type ExecutionSpaceDescriptor =
| LocalExecutionSpaceDescriptor
| SshExecutionSpaceDescriptor
function normalizeLocalIdentity(rootPath: string): string {
const trimmed = rootPath.trim()
if (!trimmed) {
return 'local:unconfigured'
}
const normalized = normalize(
isAbsolute(trimmed) ? trimmed : resolve(trimmed)
)
return `local:${
process.platform === 'win32'
? normalized.toLocaleLowerCase('en-US')
: normalized
}`
}
export class ExecutionSpaceResolver {
constructor(
private readonly remoteWorkspaceAccessFactory?:
RemoteWorkspaceAccessFactory
) {}
resolveLocal(rootPath: string): LocalExecutionSpaceDescriptor {
const cacheIdentity = normalizeLocalIdentity(rootPath)
return {
kind: 'local',
rootPath,
cacheIdentity,
routeIdentity: cacheIdentity,
workspaceAccess: new LocalWorkspaceAccess(rootPath)
}
}
resolveProject(project: AssistantProject): ExecutionSpaceDescriptor {
const executionSpace = project.executionSpace ?? {
kind: 'local' as const,
rootPath: project.rootPath
}
if (executionSpace.kind === 'local') {
return this.resolveLocal(executionSpace.rootPath)
}
const sshValidation = executionSpace.validation
const runtimeValidation = project.runtimeValidation
const identity = {
hostId: executionSpace.hostId,
remoteRootPath: executionSpace.remoteRootPath,
validation: sshValidation,
runtimeValidation
}
const remoteBinding: RemoteWorkspaceProjectBinding | undefined =
sshValidation &&
runtimeValidation &&
sshValidation.agentInstallationIdAtValidation ===
runtimeValidation.agentInstallationIdAtValidation
? {
hostId: executionSpace.hostId,
remoteRootPath: executionSpace.remoteRootPath,
hostRevision: sshValidation.hostRevision,
hostKeyGeneration: sshValidation.hostKeyGeneration,
remoteUsername: sshValidation.remoteUsername,
workspaceIdentity: sshValidation.workspaceIdentity,
agentProtocolMajor: sshValidation.agentProtocolMajor,
agentInstallationId:
sshValidation.agentInstallationIdAtValidation,
agentBinaryDigest:
sshValidation.agentBinaryDigestAtValidation,
agentVersion: sshValidation.agentVersionAtValidation,
agentArchitecture:
sshValidation.agentArchitectureAtValidation
}
: undefined
return {
kind: 'ssh',
...identity,
cacheIdentity: JSON.stringify([
'ssh',
identity.hostId,
identity.remoteRootPath,
identity.validation
? [
identity.validation.hostRevision,
identity.validation.hostKeyGeneration,
identity.validation.remoteUsername,
identity.validation.workspaceIdentity,
identity.validation.agentProtocolMajor,
identity.validation.agentInstallationIdAtValidation,
identity.validation.agentBinaryDigestAtValidation,
identity.validation.agentVersionAtValidation,
identity.validation.agentArchitectureAtValidation,
identity.validation.validatedAt
]
: null,
identity.runtimeValidation
? [
identity.runtimeValidation.runtimeSelectionKey,
identity.runtimeValidation.runtimeBundleDigest,
identity.runtimeValidation.runtimeAdapterDigest,
identity.runtimeValidation
.agentInstallationIdAtValidation,
identity.runtimeValidation.validatedAt,
identity.runtimeValidation.workMode
]
: null
]),
routeIdentity: JSON.stringify([
'ssh',
project.id,
identity.hostId,
identity.remoteRootPath
]),
workspaceAccess:
remoteBinding && this.remoteWorkspaceAccessFactory
? this.remoteWorkspaceAccessFactory.create(remoteBinding)
: new UnsupportedRemoteWorkspaceAccess(
REMOTE_EXECUTION_SPACE_UNAVAILABLE
)
}
}
assertLocal(
descriptor: ExecutionSpaceDescriptor
): asserts descriptor is LocalExecutionSpaceDescriptor {
if (descriptor.kind !== 'local') {
throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE)
}
}
}
+9
View File
@@ -0,0 +1,9 @@
export {
ExecutionSpaceResolver,
REMOTE_EXECUTION_SPACE_UNAVAILABLE
} from './execution-space-resolver'
export type {
ExecutionSpaceDescriptor,
LocalExecutionSpaceDescriptor,
SshExecutionSpaceDescriptor
} from './execution-space-resolver'
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import type { SshExecutionSpaceDescriptor } from './execution-space-resolver'
import { assertRemoteRuntimeRequestValidated } from './remote-runtime-request-validation'
const digest = `sha256:${'a'.repeat(64)}`
function descriptor(): SshExecutionSpaceDescriptor {
return {
kind: 'ssh',
hostId: 'host-1',
remoteRootPath: '/srv/project',
validation: {
hostRevision: 1,
hostKeyGeneration: 1,
remoteUsername: 'builder',
workspaceIdentity: 'workspace-1',
agentProtocolMajor: 1,
agentInstallationIdAtValidation: 'installation-1',
agentBinaryDigestAtValidation: digest,
agentVersionAtValidation: '1.0.0',
agentArchitectureAtValidation: 'x64',
validatedAt: '2026-08-21T00:00:00.000Z'
},
runtimeValidation: {
runtimeSelectionKey: 'opencode:default',
runtimeBundleDigest: digest,
runtimeAdapterDigest: digest,
agentInstallationIdAtValidation: 'installation-1',
validatedAt: '2026-08-21T00:00:00.000Z',
workMode: 'ask'
},
cacheIdentity: 'ssh-cache',
routeIdentity: 'ssh-route',
workspaceAccess: {} as SshExecutionSpaceDescriptor['workspaceAccess']
}
}
describe('assertRemoteRuntimeRequestValidated', () => {
it('accepts the exact persisted Runtime, Agent, and work mode', () => {
expect(() =>
assertRemoteRuntimeRequestValidated(
descriptor(),
{ provider: 'opencode' },
'ask'
)
).not.toThrow()
})
it('rejects Runtime selection and work-mode reuse', () => {
expect(() =>
assertRemoteRuntimeRequestValidated(
descriptor(),
{
provider: 'opencode',
profileId: '00000000-0000-4000-8000-000000000001'
},
'ask'
)
).toThrow('Runtime 选择已变化')
expect(() =>
assertRemoteRuntimeRequestValidated(
descriptor(),
{ provider: 'opencode' },
'execute'
)
).toThrow('仅验证了 Ask')
})
it('rejects missing or cross-installation evidence', () => {
expect(() =>
assertRemoteRuntimeRequestValidated(
{ ...descriptor(), runtimeValidation: undefined },
{ provider: 'opencode' },
'ask'
)
).toThrow('缺少完整')
expect(() =>
assertRemoteRuntimeRequestValidated(
{
...descriptor(),
runtimeValidation: {
...descriptor().runtimeValidation!,
agentInstallationIdAtValidation: 'installation-2'
}
},
{ provider: 'opencode' },
'ask'
)
).toThrow('缺少完整')
})
})
@@ -0,0 +1,38 @@
import type { WorkMode } from '../../shared/assistant-contracts'
import {
agentRuntimeSelectionKey,
type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import type { SshExecutionSpaceDescriptor } from './execution-space-resolver'
/**
* Prevents a request from reusing persisted remote Runtime evidence under a
* different Runtime selection, work mode, or Agent install.
*/
export function assertRemoteRuntimeRequestValidated(
executionSpace: SshExecutionSpaceDescriptor,
selection: AgentRuntimeSelection,
workMode: WorkMode
): void {
const executionValidation = executionSpace.validation
const runtimeValidation = executionSpace.runtimeValidation
if (
!executionValidation ||
!runtimeValidation ||
executionValidation.agentInstallationIdAtValidation !==
runtimeValidation.agentInstallationIdAtValidation
) {
throw new Error('远程 Project 缺少完整的当前验证,请重新验证')
}
if (
runtimeValidation.runtimeSelectionKey !==
agentRuntimeSelectionKey(selection)
) {
throw new Error('远程 Project 的 Runtime 选择已变化,请重新验证')
}
if (runtimeValidation.workMode !== workMode) {
throw new Error(
`远程 Project 仅验证了 ${runtimeValidation.workMode === 'ask' ? 'Ask' : 'Execute'} 模式,请先重新验证工作模式`
)
}
}
+145 -8
View File
@@ -28,11 +28,15 @@ import { KnowledgeMcpGateway } from './agent/knowledge-mcp-gateway'
import {
applyRuntimeSelection,
getConfiguredRuntimeTarget,
resolveConfiguredAgentRuntimeSelection,
type SelectedRuntimeTarget
} from './agent/runtime-selection'
import { CapabilityService } from './capabilities/capability-service'
import { ContextManager } from './context-manager'
import { registerIpcHandlers } from './ipc'
import {
registerIpcHandlers,
sendRemoteProjectSaveProgress
} from './ipc'
import { KnowledgeService } from './knowledge/knowledge-service'
import { AssistantDatabase } from './assistant/assistant-database'
import { createModelGraphExtractor } from './knowledge/model-extractor'
@@ -105,10 +109,26 @@ import {
} from './windows-notification-identity'
import { ShortcutSettingsStore } from './shortcut-settings-store'
import { ShortcutSettingsService } from './shortcut-settings-service'
import { SshHostStore } from './ssh/ssh-host-store'
import { Ssh2Transport } from './ssh/ssh-transport'
import { SshHostService } from './ssh/ssh-host-service'
import { SshHostDirectoryBrowser } from './ssh/ssh-host-directory-browser'
import {
SshHostRemoteEnvironmentInspector
} from './ssh/ssh-host-remote-environment'
import { defaultGlobalShortcutSettings } from '../shared/shortcut'
import { requestProcessTreeTermination } from './agent/child-process-termination'
import { createContinueUtilityProcessChild } from './agent/continue-utility-process-adapter'
import type { RuntimeSettings, RuntimeSettingsInput } from '../shared/contracts'
import {
ExecutionSpaceResolver,
type ExecutionSpaceDescriptor
} from './execution-space'
import { RemoteAgentServices } from './remote-agent/remote-agent-services'
import { ManagedRemoteExecutionServices } from './remote-agent/managed-remote-execution-services'
import type {
RuntimeSettings,
RuntimeSettingsInput
} from '../shared/contracts'
const legacyDefaultShortcut =
defaultGlobalShortcutSettings.accelerator
@@ -157,6 +177,10 @@ let embeddingModelManager: EmbeddingModelManager | undefined
const embeddingBrokers = new Set<EmbeddingInferenceBroker>()
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
let remoteAgentServices: RemoteAgentServices | undefined
let managedRemoteExecutionServices:
| ManagedRemoteExecutionServices
| undefined
type ManagedEmbeddingProvider = EmbeddingProvider & {
dispose?: () => void | Promise<void>
@@ -597,6 +621,80 @@ if (hasSingleInstanceLock) {
join(app.getPath('userData'), 'runtime-settings.json'),
secureCipher
)
const sshHostStore = new SshHostStore(
join(app.getPath('userData'), 'ssh-hosts.json'),
secureCipher
)
const startupRemoteAgentServices = new RemoteAgentServices({
sshHostStore,
goodBuddyVersion: app.getVersion(),
userDataPath: app.getPath('userData'),
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
packaged: app.isPackaged
})
remoteAgentServices = startupRemoteAgentServices
const startupManagedRemoteExecutionServices =
new ManagedRemoteExecutionServices({
sshHostStore,
agentServices: startupRemoteAgentServices,
userDataPath: app.getPath('userData'),
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
packaged: app.isPackaged,
resolveRuntimeSelection: async (selection) =>
resolveConfiguredAgentRuntimeSelection(
await settingsStore.getResolvedSettings(),
selection
),
resolveModelProfile: async (selection) =>
applyRuntimeSelection(
await settingsStore.getResolvedSettings(),
selection
).settings.opencodeModelProfile
})
await startupManagedRemoteExecutionServices.initialize()
managedRemoteExecutionServices =
startupManagedRemoteExecutionServices
const beginRemoteHostInvalidation = (hostId: string): void => {
const invalidations = [
startupRemoteAgentServices.invalidateHost(hostId)
]
if (selectedRuntimeManager) {
invalidations.push(
selectedRuntimeManager.invalidateHost(hostId)
)
}
void Promise.allSettled(invalidations).then((results) => {
if (results.some((result) => result.status === 'rejected')) {
console.warn(
'Failed to invalidate complete remote Host state'
)
}
})
}
const sshHostService = new SshHostService(
sshHostStore,
new Ssh2Transport(),
{
onHostEdited: beginRemoteHostInvalidation,
onHostRemoved: beginRemoteHostInvalidation
}
)
const sshHostDirectoryBrowser = new SshHostDirectoryBrowser({
sshHosts: sshHostStore,
sshPool: startupRemoteAgentServices.sshPool
})
const sshHostRemoteEnvironmentInspector =
new SshHostRemoteEnvironmentInspector({
sshHosts: sshHostStore,
sshPool: startupRemoteAgentServices.sshPool,
agentRuntimeLockPath:
startupRemoteAgentServices.resourcePaths.runtimeLockPath,
remoteRuntimeLockPath:
startupManagedRemoteExecutionServices.runtimeResourcePaths
.runtimeLockPath
})
const [initialRuntimeSettings, initialResolvedSettings] =
await Promise.all([
settingsStore.getPublicSettings(),
@@ -745,6 +843,9 @@ if (hasSingleInstanceLock) {
}
)
assistantDatabase = startupAssistantDatabase
const executionSpaceResolver = new ExecutionSpaceResolver(
startupManagedRemoteExecutionServices.workspaceAccessFactory
)
const goodbuddyConfigService = new GoodBuddyConfigService(
applicationSettingsStore,
capabilityService
@@ -759,7 +860,8 @@ if (hasSingleInstanceLock) {
knowledgeGateway = startupKnowledgeGateway
const createRuntimeWithCapabilities = async (
settings: ResolvedRuntimeSettings,
target: SelectedRuntimeTarget
target: SelectedRuntimeTarget,
executionSpace?: ExecutionSpaceDescriptor
): Promise<AgentRuntime> => {
const [
skillContext,
@@ -800,7 +902,9 @@ if (hasSingleInstanceLock) {
? browserService
: undefined,
knowledgeGateway: startupKnowledgeGateway,
webSearchEnabled: webSearchCapability?.enabled
webSearchEnabled: webSearchCapability?.enabled,
executionSpace,
workspaceAccess: executionSpace?.workspaceAccess
})
}
const createConfiguredRuntime = async (
@@ -815,17 +919,38 @@ if (hasSingleInstanceLock) {
}
const createSelectedRuntime = async (
selection: AgentRuntimeSelection,
workspacePath?: string
executionSpace?: ExecutionSpaceDescriptor
): Promise<AgentRuntime> => {
const resolved = applyRuntimeSelection(
await settingsStore.getResolvedSettings(),
selection
)
if (executionSpace?.kind === 'ssh') {
const profile = resolved.settings.opencodeModelProfile
if (profile === undefined) {
throw new Error(
'托管远程 OpenCode 需要选择一个可用的文本模型配置'
)
}
return await startupManagedRemoteExecutionServices.createRuntime({
executionSpace,
selection,
modelProfile: profile,
usage: {
provider: profile.protocol,
model: profile.modelName
}
})
}
return createRuntimeWithCapabilities(
workspacePath
? { ...resolved.settings, workspacePath }
executionSpace?.kind === 'local'
? {
...resolved.settings,
workspacePath: executionSpace.rootPath
}
: resolved.settings,
resolved.target
resolved.target,
executionSpace
)
}
const configuredRuntime = await runStartupPrerequisites({
@@ -1063,6 +1188,11 @@ if (hasSingleInstanceLock) {
}
}
const remoteProjectSaveService =
startupManagedRemoteExecutionServices.createProjectSaveService({
database: startupAssistantDatabase,
notify: sendRemoteProjectSaveProgress
})
removeIpcHandlers = registerIpcHandlers(
mainWindow,
runtime,
@@ -1096,6 +1226,11 @@ if (hasSingleInstanceLock) {
goodbuddyConfigService,
runtimeExtensionStore,
shortcutSettingsService,
sshHostService,
executionSpaceResolver,
remoteProjectSaveService,
sshHostDirectoryBrowser,
sshHostRemoteEnvironmentInspector,
startupEmbeddingModelManager,
async (connectionId) => {
const settings = await settingsStore.getResolvedSettings()
@@ -1207,6 +1342,8 @@ app.on('before-quit', (event) => {
() => documentOcrModelManager?.dispose(),
() => documentOcrBroker?.dispose()
],
[() => managedRemoteExecutionServices?.dispose()],
[() => remoteAgentServices?.dispose()],
[() => knowledgeGateway?.dispose()],
[() => knowledgeService?.dispose()]
])
+548 -7
View File
@@ -16,7 +16,10 @@ import type {
import type { AgentEvent, BrowserLiveState } from '../shared/contracts'
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
import { AssistantDatabase } from './assistant/assistant-database'
import { registerIpcHandlers } from './ipc'
import {
registerIpcHandlers,
sendRemoteProjectSaveProgress
} from './ipc'
describe('embedding IPC boundary', () => {
it('validates explicit IDs and keeps ZIP paths in the native dialog', async () => {
@@ -928,6 +931,444 @@ describe('registerIpcHandlers DSH runtime extensions', () => {
})
})
describe('registerIpcHandlers SSH hosts', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('validates narrow host requests and restricts them to the trusted renderer', async () => {
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const hostId = '00000000-0000-4000-8000-000000000105'
const candidateId = '00000000-0000-4000-8000-000000000106'
const fingerprint = `SHA256:${'A'.repeat(43)}`
const host = {
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent' as const,
credentialConfigured: true,
credentialSource: 'system-agent' as const,
hostKey: { state: 'unverified' as const, generation: 0 },
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
const snapshot = {
hosts: [host],
secureStorageAvailable: true
}
const sshHostService = {
getSnapshot: vi.fn(async () => snapshot),
remove: vi.fn(async () => undefined),
inspectDraftHostKey: vi.fn(async () => ({
candidateId,
hostId,
state: 'unverified' as const,
algorithm: 'ssh-ed25519',
fingerprintSha256: fingerprint
})),
discardCandidate: vi.fn(),
validateAndSave: vi.fn(async () => ({
host,
connection: {
hostId,
connected: true as const,
latencyMs: 12,
platform: 'linux' as const,
architecture: 'x64' as const,
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
}
}))
}
const listProjectIdsReferencingSshHost = vi.fn((): string[] => [])
const remoteProject = {
id: '00000000-0000-4000-8000-000000000107',
name: 'Remote project'
}
const remoteProjectSaveService = {
activate: vi.fn(async () => remoteProject),
save: vi.fn(async () => remoteProject),
cancelCurrent: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const directoryListing = {
path: '/srv',
homeDirectory: '/home/builder',
parentPath: '/',
entries: [
{
name: 'project',
path: '/srv/project'
}
],
truncated: false
}
const listDirectories = vi.fn<
(
hostId: string,
path?: string,
signal?: AbortSignal
) => Promise<typeof directoryListing>
>(async () => directoryListing)
const sshHostDirectoryBrowser = { listDirectories }
const remoteEnvironment = {
hostId,
checkedAt: '2030-01-01T00:00:00.000Z',
architecture: 'x64',
agent: {
state: 'current',
expected: {
version: '0.11.1',
architecture: 'x64'
},
installed: {
version: '0.11.1',
architecture: 'x64'
}
},
runtimes: [{
runtimeId: 'opencode',
provider: 'opencode',
state: 'current',
expected: {
version: '1.18.9',
architecture: 'x64'
},
installed: {
version: '1.18.9',
architecture: 'x64'
}
}]
} as const
const inspectRemoteEnvironment = vi.fn(
async () => remoteEnvironment
)
const sshHostRemoteEnvironmentInspector = {
inspect: inspectRemoteEnvironment
}
const selectedRuntimes = {
reset: vi.fn(async () => undefined)
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
listProjectIdsReferencingSshHost
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined),
undefined, // onBeforeClearLocalData
undefined, // browserControl
undefined, // subagentService
undefined, // channelSettingsStore
undefined, // applicationSettingsStore
undefined, // versionChecker
undefined, // speechModelManager
undefined, // embeddingIndexCoordinator
selectedRuntimes as never,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
sshHostService as never,
undefined,
remoteProjectSaveService as never,
sshHostDirectoryBrowser as never,
sshHostRemoteEnvironmentInspector as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const createInput = {
name: ' Build host ',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
}
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event)
).resolves.toEqual(snapshot)
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsInspectDraftKey
)?.(
event,
{
hostId,
hostname: createInput.hostname,
port: createInput.port,
username: createInput.username
}
)
).resolves.toMatchObject({ fingerprintSha256: fingerprint })
expect(
sshHostService.inspectDraftHostKey
).toHaveBeenCalledWith({
hostId,
hostname: createInput.hostname,
port: createInput.port,
username: createInput.username
})
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsValidateAndSave
)?.(
event,
{
candidateId,
fingerprintSha256: fingerprint,
input: createInput
}
)
).resolves.toMatchObject({
host,
connection: { connected: true }
})
expect(sshHostService.validateAndSave).toHaveBeenCalledWith({
candidateId,
fingerprintSha256: fingerprint,
input: {
...createInput,
name: 'Build host'
}
})
expect(
electronMocks.handlers.get(
ipcChannels.sshHostsDiscardCandidate
)?.(event, { candidateId })
).toBeUndefined()
expect(sshHostService.discardCandidate).toHaveBeenCalledWith(
candidateId
)
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
event,
{ hostId }
)
).resolves.toBeUndefined()
listProjectIdsReferencingSshHost.mockReturnValue([
'00000000-0000-4000-8000-000000000107'
])
expect(() =>
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
event,
{ hostId }
)
).toThrow('SSH 主机仍被项目引用,无法删除')
expect(sshHostService.remove).toHaveBeenCalledOnce()
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsRemoteEnvironment
)?.(event, { hostId })
).resolves.toEqual(remoteEnvironment)
expect(inspectRemoteEnvironment).toHaveBeenCalledWith(hostId)
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsRemoteEnvironment
)?.(event, { hostId: 'not-a-uuid' })
).rejects.toThrow()
expect(inspectRemoteEnvironment).toHaveBeenCalledOnce()
expect(() =>
electronMocks.handlers.get(
ipcChannels.sshHostsValidateAndSave
)?.(event, {
candidateId: 'not-a-uuid',
fingerprintSha256: fingerprint,
input: { ...createInput, command: 'whoami' }
})
).toThrow()
expect(sshHostService.validateAndSave).toHaveBeenCalledOnce()
expect(() =>
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.({
sender: {},
senderFrame: webContents.mainFrame
})
).toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsBrowseDirectories
)?.(event, { hostId, path: '/srv' })
).resolves.toEqual(directoryListing)
expect(
sshHostDirectoryBrowser.listDirectories
).toHaveBeenCalledWith(
hostId,
'/srv',
expect.any(AbortSignal)
)
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsBrowseDirectories
)?.(event, { hostId, path: '../escape' })
).rejects.toThrow()
expect(
sshHostDirectoryBrowser.listDirectories
).toHaveBeenCalledOnce()
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsBrowseDirectories
)?.(
{ sender: {}, senderFrame: webContents.mainFrame },
{ hostId }
)
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
let browseSignal: AbortSignal | undefined
sshHostDirectoryBrowser.listDirectories.mockImplementationOnce(
async (...args): Promise<typeof directoryListing> => {
const signal = args[2]
if (!signal) {
throw new Error('Expected directory browse AbortSignal')
}
return new Promise<typeof directoryListing>((_resolve, reject) => {
browseSignal = signal
signal.addEventListener(
'abort',
() => reject(signal.reason),
{ once: true }
)
})
}
)
const activeBrowse = electronMocks.handlers.get(
ipcChannels.sshHostsBrowseDirectories
)?.(event, { hostId })
await vi.waitFor(() =>
expect(browseSignal).toBeInstanceOf(AbortSignal)
)
expect(
electronMocks.handlers.get(
ipcChannels.sshHostsCancelDirectoryBrowse
)?.(event)
).toBeUndefined()
await expect(activeBrowse).rejects.toMatchObject({
name: 'AbortError'
})
const remoteDraft = {
intent: 'create',
draft: {
name: 'Remote project',
description: '',
runtimeSelection: { provider: 'opencode' },
hostId,
remoteRootPath: '/srv/project',
defaultWorkMode: 'ask'
}
}
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectActivate
)?.(event, remoteProject.id)
).resolves.toEqual(remoteProject)
expect(remoteProjectSaveService.activate).toHaveBeenCalledWith(
webContents,
remoteProject.id
)
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectSave
)?.(event, remoteDraft)
).resolves.toEqual(remoteProject)
expect(remoteProjectSaveService.save).toHaveBeenCalledWith(
webContents,
remoteDraft
)
expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectCancelCurrent
)?.(event)
).toBeUndefined()
expect(
remoteProjectSaveService.cancelCurrent
).toHaveBeenCalledWith(webContents)
expect(selectedRuntimes.reset).not.toHaveBeenCalled()
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectActivate
)?.(event, 'not-a-project-id')
).rejects.toThrow()
expect(remoteProjectSaveService.activate).toHaveBeenCalledOnce()
remoteProjectSaveService.activate.mockRejectedValueOnce(
new Error('activation failed')
)
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectActivate
)?.(event, remoteProject.id)
).rejects.toThrow('activation failed')
expect(selectedRuntimes.reset).not.toHaveBeenCalled()
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectSave
)?.(
{ sender: {}, senderFrame: webContents.mainFrame },
remoteDraft
)
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
expect(remoteProjectSaveService.dispose).toHaveBeenCalledOnce()
})
it('sends save progress only to a live owning WebContents', () => {
const owner = {
isDestroyed: vi.fn(() => false),
send: vi.fn(),
once: vi.fn(),
removeListener: vi.fn()
}
const progress = { phase: 'agent' as const }
sendRemoteProjectSaveProgress(owner as never, progress)
expect(owner.send).toHaveBeenCalledWith(
ipcChannels.remoteProjectSaveProgress,
progress
)
owner.isDestroyed.mockReturnValue(true)
sendRemoteProjectSaveProgress(owner as never, progress)
expect(owner.send).toHaveBeenCalledOnce()
})
})
function runtimeUpdateFixture(
workspacePath: string,
provider: 'model' | 'opencode' | 'continue' = 'model'
@@ -2689,6 +3130,81 @@ describe('registerIpcHandlers workspace files', () => {
await dispose()
})
it('fails remote workspace requests before filesystem, Git, or shell access', async () => {
const projectId = '00000000-0000-4000-8000-000000000102'
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getProject: vi.fn(() => ({
id: projectId,
name: 'Remote',
description: '',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000202',
remoteRootPath: '/srv/project'
},
defaultWorkMode: 'ask',
kind: 'user',
status: 'active',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}))
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
for (const [channel, input] of [
[ipcChannels.workspaceChangesGet, projectId],
[
ipcChannels.workspaceDirectoryList,
{ projectId, path: '' }
],
[
ipcChannels.workspaceFileRead,
{ projectId, path: 'README.md' }
],
[
ipcChannels.workspacePathOpen,
{ projectId, path: 'README.md', type: 'file' }
]
] as const) {
await expect(
electronMocks.handlers.get(channel)?.(event, input)
).rejects.toThrow('远程执行空间尚不可用')
}
expect(electronMocks.openPath).not.toHaveBeenCalled()
await dispose()
})
})
describe('registerIpcHandlers token usage', () => {
@@ -3234,7 +3750,10 @@ describe('registerIpcHandlers Runtime customization', () => {
).resolves.toEqual(snapshot)
expect(selectedRuntimes.getNativeSnapshot).toHaveBeenCalledWith(
{ provider: 'opencode' },
'C:\\ProjectWorkspace'
expect.objectContaining({
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
})
)
const compactInput = {
@@ -3268,7 +3787,10 @@ describe('registerIpcHandlers Runtime customization', () => {
})
expect(selectedRuntimes.compactConversation).toHaveBeenCalledWith(
expect.objectContaining(compactInput),
'C:\\ProjectWorkspace',
expect.objectContaining({
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
}),
expect.any(AbortSignal)
)
expect(assistantDatabase.createTask).toHaveBeenCalledWith(
@@ -3374,13 +3896,17 @@ describe('registerIpcHandlers agent terminal state', () => {
name: '企业微信',
description: '企业微信远程消息与受控任务',
rootPath: 'C:\\ProjectWorkspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
},
defaultWorkMode: 'ask',
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
},
kind: 'channel',
channel: 'wecom',
channel: 'wecom',
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
@@ -5241,10 +5767,14 @@ describe('registerIpcHandlers agent terminal state', () => {
expect(fallbackRuntime.run).not.toHaveBeenCalled()
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
firstSelection,
'C:\\ProjectWorkspace'
expect.objectContaining({
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
})
)
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
secondSelection
secondSelection,
undefined
)
await harness.dispose()
})
@@ -6197,6 +6727,10 @@ describe('registerIpcHandlers agent terminal state', () => {
name: channelLabel,
description: `${channelLabel}远程消息与受控任务`,
rootPath: 'C:\\ProjectWorkspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
},
defaultWorkMode: 'ask',
runtimeSelection: {
provider: 'model',
@@ -6874,6 +7408,10 @@ describe('registerIpcHandlers agent terminal state', () => {
name: '企业微信',
description: '企业微信远程消息与受控任务',
rootPath: 'C:\\ProjectWorkspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
},
defaultWorkMode: 'execute',
runtimeSelection: { provider: 'continue' },
kind: 'channel',
@@ -6908,7 +7446,10 @@ describe('registerIpcHandlers agent terminal state', () => {
})
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
{ provider: 'continue', profileId: configuredProfileId },
'C:\\ProjectWorkspace'
expect.objectContaining({
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
})
)
expect(
harness.assistantDatabase.getOrCreateRemoteConversation
+330 -30
View File
@@ -212,6 +212,29 @@ import {
} from '../shared/runtime-extension-contracts'
import type { RuntimeExtensionStore } from './agent/runtime-extension-store'
import type { ShortcutSettingsService } from './shortcut-settings-service'
import {
sshDirectoryBrowseRequestSchema,
sshDirectoryBrowseResultSchema,
sshHostCandidateRequestSchema,
sshHostDraftInspectionRequestSchema,
sshHostRequestSchema,
sshHostRemoteEnvironmentSchema,
sshHostValidationRequestSchema
} from '../shared/ssh-host-contracts'
import type { SshHostService } from './ssh/ssh-host-service'
import type { SshHostDirectoryBrowser } from './ssh/ssh-host-directory-browser'
import type {
SshHostRemoteEnvironmentInspector
} from './ssh/ssh-host-remote-environment'
import {
remoteProjectSaveProgressSchema,
remoteProjectSaveRequestSchema,
type RemoteProjectSaveProgress
} from '../shared/remote-project-candidate-contracts'
import type {
RemoteProjectSaveOwner,
RemoteProjectSaveService
} from './remote-agent/remote-project-save-service'
import type { ContextManager } from './context-manager'
import type { KnowledgeService } from './knowledge/knowledge-service'
import {
@@ -282,6 +305,11 @@ import {
analyzeMagicTodo
} from './magic-notes/magic-note-analyzer'
import { AgentEventBuffer } from './agent-event-buffer'
import {
ExecutionSpaceResolver,
REMOTE_EXECUTION_SPACE_UNAVAILABLE
} from './execution-space'
import { assertRemoteRuntimeRequestValidated } from './execution-space/remote-runtime-request-validation'
const requestIdSchema = z.string().uuid()
const BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS = 1_000
@@ -660,6 +688,22 @@ function assertTrustedSender(
}
}
export function sendRemoteProjectSaveProgress(
owner: RemoteProjectSaveOwner,
progress: RemoteProjectSaveProgress
): void {
const webContents = owner as RemoteProjectSaveOwner & {
send?: (channel: string, payload: unknown) => void
}
if (owner.isDestroyed() || typeof webContents.send !== 'function') {
return
}
webContents.send(
ipcChannels.remoteProjectSaveProgress,
remoteProjectSaveProgressSchema.parse(progress)
)
}
async function readArtifactImportFile(
path: string,
maximumBytes: number,
@@ -921,6 +965,12 @@ export function registerIpcHandlers(
goodbuddyConfigService?: GoodBuddyConfigService,
runtimeExtensionStore?: RuntimeExtensionStore,
shortcutSettingsService?: ShortcutSettingsService,
sshHostService?: SshHostService,
executionSpaceResolver?: ExecutionSpaceResolver,
remoteProjectSaveService?: RemoteProjectSaveService,
sshHostDirectoryBrowser?: SshHostDirectoryBrowser,
sshHostRemoteEnvironmentInspector?:
SshHostRemoteEnvironmentInspector,
embeddingModelManager?: EmbeddingModelManager & {
importArchive?(
modelId: string,
@@ -966,6 +1016,7 @@ export function registerIpcHandlers(
{ requestId: string; runtime: AgentRuntime }
>()
const heartbeatControllers = new Set<AbortController>()
let activeSshDirectoryBrowse: AbortController | undefined
let shuttingDown = false
let executionPaused = false
let clearLocalDataOperation: Promise<void> | undefined
@@ -987,6 +1038,8 @@ export function registerIpcHandlers(
const executionTracker = createPromiseTracker()
const maintenanceTracker = createPromiseTracker()
const trackExecution = executionTracker.track
const spaceResolver: ExecutionSpaceResolver =
executionSpaceResolver ?? new ExecutionSpaceResolver()
const registerHandler = (
channel: Parameters<typeof ipcMain.handle>[0],
listener: Parameters<typeof ipcMain.handle>[1],
@@ -1002,17 +1055,28 @@ export function registerIpcHandlers(
})
}
const resolveRequestRuntime = async (
request: Pick<AgentRequest, 'projectId' | 'runtimeSelection'> & {
request: Pick<
AgentRequest,
'projectId' | 'runtimeSelection' | 'workMode'
> & {
workspaceOverride?: string
followConfiguredAgentRuntime?: boolean
}
): Promise<AgentRuntime> => {
const projectWorkspace =
request.workspaceOverride?.trim() ??
(request.projectId
? assistantDatabase.getProject(request.projectId).rootPath.trim()
: '')
if (!selectedRuntimes || (!request.runtimeSelection && !projectWorkspace)) {
const executionSpace = request.projectId
? spaceResolver.resolveProject(
assistantDatabase.getProject(request.projectId)
)
: request.workspaceOverride?.trim()
? spaceResolver.resolveLocal(request.workspaceOverride.trim())
: undefined
if (executionSpace?.kind === 'ssh' && !selectedRuntimes) {
throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE)
}
if (
!selectedRuntimes ||
(!request.runtimeSelection && !executionSpace)
) {
return runtime
}
let selection =
@@ -1023,9 +1087,15 @@ export function registerIpcHandlers(
selection
)
}
return projectWorkspace
? selectedRuntimes.getRuntime(selection, projectWorkspace)
: selectedRuntimes.getRuntime(selection)
if (executionSpace?.kind === 'ssh') {
const workMode = normalizeInteractiveWorkMode(request.workMode)
assertRemoteRuntimeRequestValidated(
executionSpace,
selection,
workMode
)
}
return selectedRuntimes.getRuntime(selection, executionSpace)
}
const channels = Object.values(ipcChannels).filter(
(channel) =>
@@ -1036,6 +1106,7 @@ export function registerIpcHandlers(
channel !== ipcChannels.versionCheckResult &&
channel !== ipcChannels.weixinBindingChanged &&
channel !== ipcChannels.remoteChannelActivity &&
channel !== ipcChannels.remoteProjectSaveProgress &&
channel !== ipcChannels.conversationsChanged &&
channel !== ipcChannels.windowMaximizedChanged
)
@@ -1222,7 +1293,8 @@ export function registerIpcHandlers(
{
summarize: async (request) => {
const requestRuntime = await resolveRequestRuntime({
projectId: request.projectId
projectId: request.projectId,
workMode: 'ask'
})
if (requestRuntime.capability === 'image-generation') {
throw new Error('智能心跳需要文本模型,当前默认连接仅支持图像生成')
@@ -1675,6 +1747,7 @@ export function registerIpcHandlers(
remoteContext?.runtimeSelection ??
schedule.runtimeSelection,
workspaceOverride: remoteContext?.rootPath,
workMode: schedule.workMode,
followConfiguredAgentRuntime:
remoteContext?.followConfiguredAgentRuntime
}))
@@ -2457,6 +2530,7 @@ export function registerIpcHandlers(
projectId: project.id,
runtimeSelection,
workspaceOverride: project.rootPath,
workMode: parsed.workMode,
followConfiguredAgentRuntime: true
})
executionStatus = await executionRuntime.getStatus()
@@ -2818,10 +2892,13 @@ export function registerIpcHandlers(
throw new Error('请求包含不存在的知识库')
}
}
const selectedRuntime = await resolveRequestRuntime(parsedInput)
const normalizedWorkMode = normalizeInteractiveWorkMode(
parsedInput.workMode
)
const selectedRuntime = await resolveRequestRuntime({
...parsedInput,
workMode: normalizedWorkMode
})
const agentRuntimeSelected = isAgentRuntime(selectedRuntime)
const parsedRequest = {
...parsedInput,
@@ -2877,11 +2954,19 @@ export function registerIpcHandlers(
applicationSettings?.magicNotesEnabled ?? false
const webSearchEnabled =
webSearchCapability?.enabled === true
const configProject = enrichedRequest.projectId
? assistantDatabase.getProject(enrichedRequest.projectId)
: undefined
const configExecutionSpace = configProject
? spaceResolver.resolveProject(configProject)
: undefined
const configWorkspacePath =
configAccess === 'none'
? undefined
: enrichedRequest.projectId
? assistantDatabase.getProject(enrichedRequest.projectId).rootPath
: configExecutionSpace
? configExecutionSpace.kind === 'local'
? configExecutionSpace.rootPath
: undefined
: resolvedRuntimeSettings?.workspacePath
const controller = new AbortController()
const scopedCapability = grantScopedDataCapability({
@@ -3563,9 +3648,15 @@ export function registerIpcHandlers(
settings,
request.runtimeSelection
)
const workspacePath = request.projectId
? assistantDatabase.getProject(request.projectId).rootPath
: selected.settings.workspacePath
const executionSpace = request.projectId
? spaceResolver.resolveProject(
assistantDatabase.getProject(request.projectId)
)
: spaceResolver.resolveLocal(selected.settings.workspacePath)
if (executionSpace.kind === 'ssh') {
throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE)
}
const workspacePath = executionSpace.rootPath
const controller = new AbortController()
const timeout = setTimeout(
() =>
@@ -3596,7 +3687,7 @@ export function registerIpcHandlers(
}
outcome = await selectedRuntimes.compactConversation(
trustedRequest,
workspacePath,
executionSpace,
controller.signal
)
} else {
@@ -3725,13 +3816,17 @@ export function registerIpcHandlers(
? { profileId: request.profileId }
: {})
}
const workspacePath = request.projectId
? assistantDatabase.getProject(request.projectId).rootPath
: (await settingsStore.getResolvedSettings()).workspacePath
const executionSpace = request.projectId
? spaceResolver.resolveProject(
assistantDatabase.getProject(request.projectId)
)
: spaceResolver.resolveLocal(
(await settingsStore.getResolvedSettings()).workspacePath
)
return runtimeNativeSnapshotSchema.parse(
await selectedRuntimes.getNativeSnapshot(
selection,
workspacePath
executionSpace
)
)
}
@@ -3952,6 +4047,130 @@ export function registerIpcHandlers(
}
)
registerHandler(ipcChannels.sshHostsGet, (event) => {
assertTrustedSender(event, window)
if (!sshHostService) {
throw new Error('SSH 主机设置服务不可用')
}
return sshHostService.getSnapshot()
})
registerHandler(
ipcChannels.sshHostsRemove,
(event, input: unknown) => {
assertTrustedSender(event, window)
if (!sshHostService) {
throw new Error('SSH 主机设置服务不可用')
}
const hostId = sshHostRequestSchema.parse(input).hostId
const referencingProjectIds =
assistantDatabase.listProjectIdsReferencingSshHost(hostId)
if (referencingProjectIds.length > 0) {
throw new Error('SSH 主机仍被项目引用,无法删除')
}
return sshHostService.remove(hostId)
}
)
registerHandler(
ipcChannels.sshHostsInspectDraftKey,
(event, input: unknown) => {
assertTrustedSender(event, window)
if (!sshHostService) {
throw new Error('SSH 主机设置服务不可用')
}
return sshHostService.inspectDraftHostKey(
sshHostDraftInspectionRequestSchema.parse(input)
)
}
)
registerHandler(
ipcChannels.sshHostsDiscardCandidate,
(event, input: unknown) => {
assertTrustedSender(event, window)
if (!sshHostService) {
throw new Error('SSH 主机设置服务不可用')
}
sshHostService.discardCandidate(
sshHostCandidateRequestSchema.parse(input).candidateId
)
}
)
registerHandler(
ipcChannels.sshHostsValidateAndSave,
(event, input: unknown) => {
assertTrustedSender(event, window)
if (!sshHostService) {
throw new Error('SSH 主机设置服务不可用')
}
return sshHostService.validateAndSave(
sshHostValidationRequestSchema.parse(input)
)
}
)
registerHandler(
ipcChannels.sshHostsRemoteEnvironment,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!sshHostRemoteEnvironmentInspector) {
throw new Error('SSH 远端运行环境服务不可用')
}
const hostId = sshHostRequestSchema.parse(input).hostId
return sshHostRemoteEnvironmentSchema.parse(
await sshHostRemoteEnvironmentInspector.inspect(hostId)
)
}
)
registerHandler(
ipcChannels.sshHostsBrowseDirectories,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!sshHostDirectoryBrowser) {
throw new Error('SSH 远端目录浏览服务不可用')
}
const request = sshDirectoryBrowseRequestSchema.parse(input)
activeSshDirectoryBrowse?.abort(
new DOMException(
'SSH directory browse replaced',
'AbortError'
)
)
const controller = new AbortController()
activeSshDirectoryBrowse = controller
try {
return sshDirectoryBrowseResultSchema.parse(
await sshHostDirectoryBrowser.listDirectories(
request.hostId,
request.path,
controller.signal
)
)
} finally {
if (activeSshDirectoryBrowse === controller) {
activeSshDirectoryBrowse = undefined
}
}
}
)
registerHandler(
ipcChannels.sshHostsCancelDirectoryBrowse,
(event) => {
assertTrustedSender(event, window)
activeSshDirectoryBrowse?.abort(
new DOMException(
'SSH directory browse cancelled',
'AbortError'
)
)
activeSshDirectoryBrowse = undefined
}
)
registerHandler(ipcChannels.channelSettingsGet, (event) => {
assertTrustedSender(event, window)
if (!channelManager) {
@@ -4749,6 +4968,47 @@ export function registerIpcHandlers(
}
)
registerHandler(
ipcChannels.remoteProjectActivate,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!remoteProjectSaveService) {
throw new Error('远程项目验证服务不可用')
}
const result = await remoteProjectSaveService.activate(
event.sender,
assistantIdSchema.parse(input)
)
return result
}
)
registerHandler(
ipcChannels.remoteProjectSave,
async (event, input: unknown) => {
assertTrustedSender(event, window)
if (!remoteProjectSaveService) {
throw new Error('远程项目验证服务不可用')
}
const result = await remoteProjectSaveService.save(
event.sender,
remoteProjectSaveRequestSchema.parse(input)
)
return result
}
)
registerHandler(
ipcChannels.remoteProjectCancelCurrent,
(event) => {
assertTrustedSender(event, window)
if (!remoteProjectSaveService) {
throw new Error('远程项目验证服务不可用')
}
remoteProjectSaveService.cancelCurrent(event.sender)
}
)
registerHandler(
ipcChannels.projectsUpdate,
async (event, input: unknown) => {
@@ -5021,7 +5281,14 @@ export function registerIpcHandlers(
const project = assistantDatabase.getProject(
assistantIdSchema.parse(input)
)
return getWorkspaceChanges(project.rootPath)
const executionSpace = spaceResolver.resolveProject(project)
try {
return await getWorkspaceChanges(
executionSpace.workspaceAccess
)
} finally {
await executionSpace.workspaceAccess.dispose()
}
}
)
registerHandler(
@@ -5030,7 +5297,15 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const value = workspaceDirectoryRequestSchema.parse(input)
const project = assistantDatabase.getProject(value.projectId)
return listWorkspaceDirectory(project.rootPath, value.path)
const executionSpace = spaceResolver.resolveProject(project)
try {
return await listWorkspaceDirectory(
executionSpace.workspaceAccess,
value.path
)
} finally {
await executionSpace.workspaceAccess.dispose()
}
}
)
registerHandler(
@@ -5039,7 +5314,15 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const value = workspaceFileRequestSchema.parse(input)
const project = assistantDatabase.getProject(value.projectId)
return readWorkspaceFile(project.rootPath, value.path)
const executionSpace = spaceResolver.resolveProject(project)
try {
return await readWorkspaceFile(
executionSpace.workspaceAccess,
value.path
)
} finally {
await executionSpace.workspaceAccess.dispose()
}
}
)
registerHandler(
@@ -5048,11 +5331,18 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const value = workspaceOpenPathRequestSchema.parse(input)
const project = assistantDatabase.getProject(value.projectId)
const targetPath = await resolveWorkspaceEntryPath(
project.rootPath,
value.path,
value.type
)
const executionSpace = spaceResolver.resolveProject(project)
spaceResolver.assertLocal(executionSpace)
let targetPath: string
try {
targetPath = await resolveWorkspaceEntryPath(
executionSpace.rootPath,
value.path,
value.type
)
} finally {
await executionSpace.workspaceAccess.dispose()
}
const error = await shell.openPath(targetPath)
if (error) {
throw new Error(
@@ -6538,7 +6828,16 @@ export function registerIpcHandlers(
controller.abort(new Error('应用正在退出'))
}
heartbeatControllers.clear()
activeSshDirectoryBrowse?.abort(
new DOMException(
'SSH directory browse disposed',
'AbortError'
)
)
activeSshDirectoryBrowse = undefined
speechTranscriptionService?.dispose()
const remoteProjectSaveCleanup =
remoteProjectSaveService?.dispose()
const speechModelCleanup = speechModelManager
?.getSnapshot()
.then((snapshot) => {
@@ -6567,6 +6866,7 @@ export function registerIpcHandlers(
await Promise.allSettled([
channelCleanup,
speechModelCleanup,
remoteProjectSaveCleanup,
remoteDelegation?.stop(),
wechatBindingController?.stop(),
executionTracker.drain(),
@@ -778,7 +778,7 @@ describe('KnowledgeDatabase', () => {
expect(database.listDocuments(library.id)).toHaveLength(500)
expect(database.listSourcesForSnapshot(library.id)).toHaveLength(501)
expect(database.listDocumentsForSnapshot(library.id)).toHaveLength(501)
})
}, 10_000)
it('edits graph records and merges entities while retaining evidence and locks', async () => {
const { database } = await createDatabase()
@@ -948,7 +948,7 @@ describe('KnowledgeDatabase', () => {
'RELATED_TO'
)?.id
).toBe(expectedRelationId)
})
}, 15_000)
it('persists controlled ontology updates and marks graph rebuild state', async () => {
const { database } = await createDatabase()
@@ -0,0 +1,387 @@
import { EventEmitter } from 'node:events'
import type { ClientChannel } from 'ssh2'
import { describe, expect, it, vi } from 'vitest'
import {
AGENT_PROTOCOL_FAILURE_STDERR_PREFIX,
AGENT_PROTOCOL_LIMITS,
AGENT_PROTOCOL_VERSION,
encodeAgentFrame,
type AgentFrame,
type AttachPreface,
type AttachWelcome
} from '../../shared/agent-protocol'
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
import {
AgentAttachTransport,
AgentAttachTransportError
} from './agent-attach-transport'
class FakeChannel extends EventEmitter {
readonly stderr = new EventEmitter()
readonly writes: Buffer[] = []
readonly callbacks: Array<(error?: Error | null) => void> = []
destroyed = false
paused = false
autoComplete = true
onWrite?: (bytes: Buffer) => void
write(
bytes: Uint8Array,
callback?: (error?: Error | null) => void
): boolean {
const buffer = Buffer.from(bytes)
this.writes.push(buffer)
this.onWrite?.(buffer)
if (callback !== undefined) {
if (this.autoComplete) {
queueMicrotask(() => callback())
} else {
this.callbacks.push(callback)
}
}
return true
}
pause(): this {
this.paused = true
return this
}
resume(): this {
this.paused = false
return this
}
destroy(): this {
if (!this.destroyed) {
this.destroyed = true
this.emit('close')
}
return this
}
completeWrite(error?: Error | null): void {
this.callbacks.shift()?.(error)
}
remote(bytes: Uint8Array): void {
this.emit('data', Buffer.from(bytes))
}
}
const preface: AttachPreface = {
type: 'goodbuddy-agent-attach',
protocol: AGENT_PROTOCOL_VERSION,
goodBuddyVersion: '0.11.0',
controllerId: 'controller-1',
clientNonce: 'client-nonce-1',
hostRevision: 2,
hostKeyGeneration: 3
}
const welcome: AttachWelcome = {
type: 'goodbuddy-agent-welcome',
protocol: AGENT_PROTOCOL_VERSION,
connectionId: 'connection-1',
generation: 2,
installationId: 'agent-v1',
binaryDigest: `sha256:${'a'.repeat(64)}`,
daemonBootId: 'boot-1',
serverNonce: 'server-nonce-1'
}
function packet(value: unknown): Buffer {
const body = Buffer.from(JSON.stringify(value), 'utf8')
const bytes = Buffer.alloc(4 + body.byteLength)
bytes.writeUInt32BE(body.byteLength, 0)
body.copy(bytes, 4)
return bytes
}
function frame(
overrides: Partial<AgentFrame['header']> = {},
payload = Buffer.from('payload')
): AgentFrame {
return {
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: 'connection-1',
generation: 2,
channelId: 'channel-1',
channelEpoch: '1',
direction: 'agent-to-main',
sequence: '1',
kind: 'acp',
payloadLength: payload.byteLength,
...overrides
},
payload
}
}
function connect(
channel: FakeChannel,
options: { timeoutMs?: number } = {}
): Promise<AgentAttachTransport> {
return AgentAttachTransport.connect({
sshLease: {
openAgentAttach: vi.fn(async () => channel as unknown as ClientChannel)
},
installationId: verifyAgentInstallationId('agent-v1'),
preface,
timeoutMs: options.timeoutMs
})
}
describe('AgentAttachTransport', () => {
it('handles a split welcome and a coalesced first binary frame', async () => {
const channel = new FakeChannel()
const encodedFrame = encodeAgentFrame(frame())
channel.onWrite = () => {
const response = Buffer.concat([packet(welcome), encodedFrame])
queueMicrotask(() => {
channel.remote(response.subarray(0, 2))
channel.remote(response.subarray(2, 9))
channel.remote(response.subarray(9))
})
}
const transport = await connect(channel)
expect(transport.welcome).toEqual(welcome)
expect((await transport.receive()).payload).toEqual(
Buffer.from('payload')
)
transport.dispose()
})
it('rejects oversized welcome packets and attach timeouts', async () => {
const oversized = new FakeChannel()
oversized.onWrite = () => {
const length = Buffer.alloc(4)
length.writeUInt32BE(4_097)
queueMicrotask(() => oversized.remote(length))
}
await expect(connect(oversized)).rejects.toMatchObject({
code: 'malformed'
})
const stalled = new FakeChannel()
await expect(connect(stalled, { timeoutMs: 5 })).rejects.toMatchObject({
code: 'timeout'
})
expect(stalled.destroyed).toBe(true)
let observedSignal: AbortSignal | undefined
const bootstrap = AgentAttachTransport.connect({
sshLease: {
openAgentAttach: vi.fn(
async (_installationId, signal?: AbortSignal) => {
observedSignal = signal
await new Promise<void>((_resolve, reject) => {
signal?.addEventListener(
'abort',
() => reject(signal.reason),
{ once: true }
)
})
throw new Error('unreachable')
}
)
},
installationId: verifyAgentInstallationId('agent-v1'),
preface,
timeoutMs: 5
})
await expect(bootstrap).rejects.toMatchObject({
code: 'timeout'
})
expect(observedSignal?.aborted).toBe(true)
})
it('fails closed on a stale generation frame', async () => {
const channel = new FakeChannel()
channel.onWrite = () => {
queueMicrotask(() => channel.remote(packet(welcome)))
}
const transport = await connect(channel)
channel.remote(
encodeAgentFrame(frame({ generation: 1 }))
)
expect(() => transport.receive()).toThrow(
AgentAttachTransportError
)
expect(transport.closed).toBe(true)
})
it('extracts only a bounded Agent protocol failure category', async () => {
const channel = new FakeChannel()
channel.onWrite = () => {
queueMicrotask(() => channel.remote(packet(welcome)))
}
const transport = await connect(channel)
const closed = new Promise<AgentAttachTransportError>((resolve) => {
transport.onClose(resolve)
})
channel.stderr.emit(
'data',
`${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}dispatch/process\n`
)
channel.emit('close')
await expect(closed).resolves.toMatchObject({
code: 'closed',
diagnostic: 'dispatch/process'
})
})
it('does not copy remote stderr into attach diagnostics', async () => {
const channel = new FakeChannel()
channel.onWrite = () => {
queueMicrotask(() => {
channel.stderr.emit(
'data',
'secret-value host-00000000-0000-4000-8000-000000000001'
)
channel.emit('close')
})
}
await expect(connect(channel)).rejects.toMatchObject({
code: 'closed',
message:
'Agent attach closed during handshake with diagnostic output'
})
})
it('writes every frame in caller FIFO order', async () => {
const channel = new FakeChannel()
channel.onWrite = () => {
if (channel.writes.length === 1) {
queueMicrotask(() => channel.remote(packet(welcome)))
}
}
const transport = await connect(channel)
channel.autoComplete = false
const firstFrame = frame(
{ direction: 'main-to-agent', kind: 'blob', channelId: 'one' },
Buffer.from('one')
)
const secondFrame = frame(
{ direction: 'main-to-agent', kind: 'blob', channelId: 'two' },
Buffer.from('two')
)
const controlFrame = frame(
{ direction: 'main-to-agent', kind: 'control', channelId: 'three' },
Buffer.from('{}')
)
const first = transport.send(firstFrame)
const second = transport.send(secondFrame)
const control = transport.send(controlFrame)
expect(channel.writes).toHaveLength(2)
expect(channel.writes[1]).toEqual(Buffer.from(encodeAgentFrame(firstFrame)))
channel.completeWrite()
await vi.waitFor(() => expect(channel.writes).toHaveLength(3))
expect(channel.writes[2]).toEqual(Buffer.from(encodeAgentFrame(secondFrame)))
channel.completeWrite()
await vi.waitFor(() => expect(channel.writes).toHaveLength(4))
expect(channel.writes[3]).toEqual(Buffer.from(encodeAgentFrame(controlFrame)))
channel.completeWrite()
await Promise.all([first, second, control])
transport.dispose()
})
it('rejects an active write when the SSH transport fails', async () => {
const channel = new FakeChannel()
channel.onWrite = () => {
if (channel.writes.length === 1) {
queueMicrotask(() => channel.remote(packet(welcome)))
}
}
const transport = await connect(channel)
channel.autoComplete = false
const pending = transport.send(
frame(
{
direction: 'main-to-agent',
kind: 'blob'
},
Buffer.of(1)
)
)
channel.emit('error', new Error('SSH channel failed'))
await expect(pending).rejects.toMatchObject({ code: 'closed' })
expect(transport.closed).toBe(true)
channel.completeWrite()
})
it('normalizes an active SSH write callback failure', async () => {
const channel = new FakeChannel()
channel.onWrite = () => {
if (channel.writes.length === 1) {
queueMicrotask(() => channel.remote(packet(welcome)))
}
}
const transport = await connect(channel)
channel.autoComplete = false
const pending = transport.send(
frame(
{
direction: 'main-to-agent',
kind: 'blob'
},
Buffer.of(1)
)
)
channel.completeWrite(new Error('SSH write failed'))
await expect(pending).rejects.toMatchObject({
code: 'closed',
message: 'Failed to write an Agent frame'
})
expect(transport.closed).toBe(true)
})
it('writes one maximum-sized blob frame in one SSH write', async () => {
const channel = new FakeChannel()
channel.onWrite = () => {
if (channel.writes.length === 1) {
queueMicrotask(() => channel.remote(packet(welcome)))
}
}
const transport = await connect(channel)
const blob = frame(
{ direction: 'main-to-agent', kind: 'blob' },
Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes)
)
await transport.send(blob)
expect(channel.writes).toHaveLength(2)
expect(channel.writes[1]).toEqual(Buffer.from(encodeAgentFrame(blob)))
transport.dispose()
})
it('rejects an oversized blob before writing to SSH', async () => {
const channel = new FakeChannel()
channel.onWrite = () => {
if (channel.writes.length === 1) {
queueMicrotask(() => channel.remote(packet(welcome)))
}
}
const transport = await connect(channel)
await expect(
transport.send(
frame(
{ direction: 'main-to-agent', kind: 'blob' },
Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes + 1)
)
)
).rejects.toMatchObject({ code: 'malformed' })
expect(channel.writes).toHaveLength(1)
transport.dispose()
})
})
@@ -0,0 +1,643 @@
import type { ClientChannel } from 'ssh2'
import {
AGENT_PROTOCOL_LIMITS,
AGENT_PROTOCOL_FAILURE_STDERR_PREFIX,
agentProtocolFailureCategorySchema,
attachPrefaceSchema,
attachWelcomeSchema,
type AgentFrame,
type AttachPreface,
type AttachWelcome
} from '../../shared/agent-protocol'
import {
AGENT_FRAME_FIXED_HEADER_BYTES,
AgentFrameError,
MAXIMUM_ENCODED_AGENT_FRAME_BYTES,
decodeAgentFrame,
encodeAgentFrame,
inspectAgentFramePrefix
} from '../../shared/agent-protocol/frame'
import type { SshConnectionLease } from '../ssh/ssh-connection-pool'
import type { VerifiedAgentInstallationId } from '../ssh/ssh-agent-command'
const HANDSHAKE_MAXIMUM_BYTES = 4 * 1024
const MAXIMUM_INCOMING_ITEMS = 256
const MAXIMUM_PENDING_READS = 256
const MAXIMUM_OUTGOING_ITEMS = 512
const MAXIMUM_BUFFERED_BYTES =
AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes
const MAXIMUM_STDERR_BYTES = 64 * 1024
const DEFAULT_ATTACH_TIMEOUT_MS = 10_000
type OutgoingWrite = {
bytes: Uint8Array
resolve: () => void
reject: (error: unknown) => void
}
type FrameWaiter = {
resolve: (frame: AgentFrame) => void
reject: (error: unknown) => void
signal?: AbortSignal
abort?: () => void
}
export type AgentAttachTransportOptions = {
sshLease: Pick<SshConnectionLease, 'openAgentAttach'>
installationId: VerifiedAgentInstallationId
preface: AttachPreface
signal?: AbortSignal
timeoutMs?: number
}
export class AgentAttachTransportError extends Error {
constructor(
message: string,
readonly code:
| 'timeout'
| 'aborted'
| 'closed'
| 'stderr'
| 'malformed'
| 'overflow',
readonly cause?: unknown,
readonly diagnostic?: string
) {
super(message)
this.name = 'AgentAttachTransportError'
}
}
/**
* Owns one fixed SSH attach channel. The initial packet and every subsequent
* frame are length bounded before allocation. Ordering and backpressure are
* delegated to the SSH channel; this layer adds no scheduler or flow window.
*/
export class AgentAttachTransport {
readonly welcome: AttachWelcome
readonly #channel: ClientChannel
#input = Buffer.alloc(0)
#incoming: AgentFrame[] = []
#incomingBytes = 0
#waiters: FrameWaiter[] = []
#writes: OutgoingWrite[] = []
#outgoingBytes = 0
#writing = false
#activeWrite?: OutgoingWrite
#closed = false
#closeError?: AgentAttachTransportError
#stderr = Buffer.alloc(0)
#closeListeners = new Set<(error: AgentAttachTransportError) => void>()
private constructor(channel: ClientChannel, welcome: AttachWelcome) {
this.#channel = channel
this.welcome = welcome
this.#channel.on('data', this.#onData)
this.#channel.stderr.on('data', this.#onStderr)
this.#channel.once('error', this.#onError)
this.#channel.once('close', this.#onClose)
}
static async connect(
options: AgentAttachTransportOptions
): Promise<AgentAttachTransport> {
const preface = attachPrefaceSchema.parse(options.preface)
const timeoutMs = options.timeoutMs ?? DEFAULT_ATTACH_TIMEOUT_MS
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) {
throw new RangeError('Invalid Agent attach timeout')
}
options.signal?.throwIfAborted()
const controller = new AbortController()
const abort = (): void => controller.abort(options.signal?.reason)
options.signal?.addEventListener('abort', abort, { once: true })
const timer = setTimeout(() => {
controller.abort(
new AgentAttachTransportError(
'GoodBuddy Agent attach handshake timed out',
'timeout'
)
)
}, timeoutMs)
try {
const channel = await options.sshLease.openAgentAttach(
options.installationId,
controller.signal
)
const handshake = readWelcome(channel, preface, controller.signal)
const { welcome, trailingBytes, stderr } = await handshake
const transport = new AgentAttachTransport(channel, welcome)
transport.#stderr = Buffer.from(stderr)
if (trailingBytes.byteLength > 0) {
transport.#acceptBytes(trailingBytes)
}
return transport
} catch (error) {
if (controller.signal.aborted) {
const reason = controller.signal.reason
if (reason instanceof AgentAttachTransportError) {
throw reason
}
throw new AgentAttachTransportError(
'GoodBuddy Agent attach was aborted',
'aborted',
reason
)
}
throw error
} finally {
clearTimeout(timer)
options.signal?.removeEventListener('abort', abort)
}
}
get closed(): boolean {
return this.#closed
}
async send(frame: AgentFrame): Promise<void> {
this.#assertOpen()
let bytes: Uint8Array
try {
bytes = encodeAgentFrame(frame)
} catch (error) {
throw new AgentAttachTransportError(
'Refused malformed outgoing Agent frame',
'malformed',
error
)
}
await this.#enqueueWrite(bytes)
}
receive(signal?: AbortSignal): Promise<AgentFrame> {
this.#assertOpen()
signal?.throwIfAborted()
const frame = this.#incoming.shift()
if (frame !== undefined) {
this.#incomingBytes -= encodedFrameLength(frame)
this.#resumeInputIfPossible()
return Promise.resolve(frame)
}
if (this.#waiters.length >= MAXIMUM_PENDING_READS) {
return Promise.reject(
new AgentAttachTransportError(
'Too many pending Agent frame readers',
'overflow'
)
)
}
return new Promise<AgentFrame>((resolve, reject) => {
const waiter: FrameWaiter = { resolve, reject, signal }
if (signal !== undefined) {
waiter.abort = (): void => {
const index = this.#waiters.indexOf(waiter)
if (index >= 0) {
this.#waiters.splice(index, 1)
}
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'))
}
signal.addEventListener('abort', waiter.abort, { once: true })
}
this.#waiters.push(waiter)
})
}
onClose(
listener: (error: AgentAttachTransportError) => void
): () => void {
if (this.#closeError !== undefined) {
queueMicrotask(() => listener(this.#closeError!))
return () => undefined
}
this.#closeListeners.add(listener)
return () => this.#closeListeners.delete(listener)
}
dispose(): void {
this.#fail(
new AgentAttachTransportError(
'GoodBuddy Agent attach transport was disposed',
'closed'
)
)
}
readonly #onData = (chunk: Buffer | string): void => {
if (!this.#closed) {
this.#acceptBytes(Buffer.from(chunk))
}
}
readonly #onStderr = (chunk: Buffer | string): void => {
if (this.#closed) {
return
}
const bytes = Buffer.from(chunk)
if (this.#stderr.byteLength + bytes.byteLength > MAXIMUM_STDERR_BYTES) {
this.#fail(
new AgentAttachTransportError(
'GoodBuddy Agent attach stderr exceeded its limit',
'stderr'
)
)
return
}
this.#stderr = Buffer.concat([this.#stderr, bytes])
}
readonly #onError = (error: Error): void => {
this.#fail(
new AgentAttachTransportError(
'GoodBuddy Agent attach channel failed',
'closed',
error
)
)
}
readonly #onClose = (): void => {
const diagnostic = protocolFailureDiagnostic(this.#stderr)
this.#fail(
new AgentAttachTransportError(
this.#stderr.byteLength > 0
? 'GoodBuddy Agent attach closed with diagnostic output'
: 'GoodBuddy Agent attach channel closed',
'closed',
undefined,
diagnostic
)
)
}
#acceptBytes(chunk: Buffer): void {
if (
this.#input.byteLength + chunk.byteLength >
MAXIMUM_BUFFERED_BYTES + MAXIMUM_ENCODED_AGENT_FRAME_BYTES
) {
this.#fail(
new AgentAttachTransportError(
'Agent attach input buffer exceeded its limit',
'overflow'
)
)
return
}
this.#input = Buffer.concat([this.#input, chunk])
try {
while (
!this.#closed &&
this.#input.byteLength >= AGENT_FRAME_FIXED_HEADER_BYTES
) {
const frameLength =
inspectAgentFramePrefix(this.#input).encodedByteLength
if (this.#input.byteLength < frameLength) {
break
}
const encoded = this.#input.subarray(0, frameLength)
const frame = decodeAgentFrame(encoded, {
protocolMajor: this.welcome.protocol.major,
maximumProtocolMinor: this.welcome.protocol.minor,
connectionId: this.welcome.connectionId,
generation: this.welcome.generation
})
if (frame.header.direction !== 'agent-to-main') {
throw new AgentFrameError(
'Agent sent a frame in the wrong direction',
'invalid'
)
}
this.#input = this.#input.subarray(frameLength)
this.#enqueueIncoming(frame)
if (
this.#incoming.length >= MAXIMUM_INCOMING_ITEMS ||
this.#incomingBytes >= MAXIMUM_BUFFERED_BYTES
) {
break
}
}
} catch (error) {
this.#fail(
new AgentAttachTransportError(
'Received a malformed Agent frame',
'malformed',
error
)
)
}
}
#enqueueIncoming(frame: AgentFrame): void {
const waiter = this.#waiters.shift()
if (waiter !== undefined) {
if (waiter.abort !== undefined) {
waiter.signal?.removeEventListener('abort', waiter.abort)
}
waiter.resolve(frame)
return
}
const byteLength = encodedFrameLength(frame)
if (
this.#incoming.length >= MAXIMUM_INCOMING_ITEMS ||
this.#incomingBytes + byteLength > MAXIMUM_BUFFERED_BYTES
) {
this.#channel.pause()
throw new AgentAttachTransportError(
'Agent attach incoming queue is full',
'overflow'
)
}
this.#incoming.push(frame)
this.#incomingBytes += byteLength
if (
this.#incoming.length === MAXIMUM_INCOMING_ITEMS ||
this.#incomingBytes === MAXIMUM_BUFFERED_BYTES
) {
this.#channel.pause()
}
}
#resumeInputIfPossible(): void {
if (
!this.#closed &&
this.#incoming.length < MAXIMUM_INCOMING_ITEMS &&
this.#incomingBytes < MAXIMUM_BUFFERED_BYTES
) {
if (this.#input.byteLength > 0) {
this.#acceptBytes(Buffer.alloc(0))
}
if (
this.#incoming.length < MAXIMUM_INCOMING_ITEMS &&
this.#incomingBytes < MAXIMUM_BUFFERED_BYTES
) {
this.#channel.resume()
}
}
}
#pumpWrites(): void {
if (this.#writing || this.#closed) {
return
}
const write = this.#writes.shift()
if (write === undefined) {
return
}
this.#writing = true
this.#activeWrite = write
this.#channel.write(write.bytes, (error?: Error | null) => {
if (this.#activeWrite !== write) {
return
}
this.#activeWrite = undefined
this.#writing = false
this.#outgoingBytes -= write.bytes.byteLength
if (error !== undefined && error !== null) {
const transportError = new AgentAttachTransportError(
'Failed to write an Agent frame',
'closed',
error
)
write.reject(transportError)
this.#fail(transportError)
return
}
write.resolve()
this.#pumpWrites()
})
}
#enqueueWrite(bytes: Uint8Array): Promise<void> {
if (
this.#writes.length >= MAXIMUM_OUTGOING_ITEMS ||
this.#outgoingBytes + bytes.byteLength > MAXIMUM_BUFFERED_BYTES
) {
return Promise.reject(
new AgentAttachTransportError(
'Agent attach outgoing queue is full',
'overflow'
)
)
}
return new Promise<void>((resolve, reject) => {
const write = {
bytes,
resolve,
reject
}
this.#writes.push(write)
this.#outgoingBytes += bytes.byteLength
this.#pumpWrites()
})
}
#assertOpen(): void {
if (this.#closed) {
throw (
this.#closeError ??
new AgentAttachTransportError(
'GoodBuddy Agent attach transport is closed',
'closed'
)
)
}
}
#fail(error: AgentAttachTransportError): void {
if (this.#closed) {
return
}
this.#closed = true
this.#closeError = error
this.#channel.off('data', this.#onData)
this.#channel.stderr.off('data', this.#onStderr)
this.#channel.destroy()
const activeWrite = this.#activeWrite
this.#activeWrite = undefined
this.#writing = false
activeWrite?.reject(error)
for (const waiter of this.#waiters.splice(0)) {
if (waiter.abort !== undefined) {
waiter.signal?.removeEventListener('abort', waiter.abort)
}
waiter.reject(error)
}
for (const write of this.#writes.splice(0)) {
write.reject(error)
}
this.#incoming = []
this.#input = Buffer.alloc(0)
this.#incomingBytes = 0
this.#outgoingBytes = 0
for (const listener of this.#closeListeners) {
listener(error)
}
this.#closeListeners.clear()
}
}
function protocolFailureDiagnostic(
stderr: Uint8Array
): string | undefined {
for (const line of Buffer.from(stderr).toString('utf8').split(/\r?\n/u)) {
if (!line.startsWith(AGENT_PROTOCOL_FAILURE_STDERR_PREFIX)) {
continue
}
const category = agentProtocolFailureCategorySchema.safeParse(
line.slice(AGENT_PROTOCOL_FAILURE_STDERR_PREFIX.length)
)
if (category.success) {
return category.data
}
}
return undefined
}
async function readWelcome(
channel: ClientChannel,
preface: AttachPreface,
signal: AbortSignal
): Promise<{
welcome: AttachWelcome
trailingBytes: Buffer
stderr: Buffer
}> {
let input = Buffer.alloc(0)
let stderr = Buffer.alloc(0)
return await new Promise((resolve, reject) => {
let settled = false
const cleanup = (): void => {
channel.off('data', onData)
channel.stderr.off('data', onStderr)
channel.off('error', onError)
channel.off('close', onClose)
signal.removeEventListener('abort', onAbort)
}
const fail = (error: unknown): void => {
if (settled) {
return
}
settled = true
cleanup()
channel.destroy()
reject(error)
}
const onAbort = (): void => fail(signal.reason)
const onError = (error: Error): void => fail(error)
const onClose = (): void =>
fail(
new AgentAttachTransportError(
stderr.byteLength > 0
? 'Agent attach closed during handshake with diagnostic output'
: 'Agent attach closed during handshake',
'closed'
)
)
const onStderr = (chunk: Buffer | string): void => {
const bytes = Buffer.from(chunk)
if (stderr.byteLength + bytes.byteLength > MAXIMUM_STDERR_BYTES) {
fail(
new AgentAttachTransportError(
'Agent attach handshake stderr exceeded its limit',
'stderr'
)
)
} else {
stderr = Buffer.concat([stderr, bytes])
}
}
const onData = (chunk: Buffer | string): void => {
const bytes = Buffer.from(chunk)
if (
input.byteLength + bytes.byteLength >
4 +
HANDSHAKE_MAXIMUM_BYTES +
MAXIMUM_BUFFERED_BYTES +
MAXIMUM_ENCODED_AGENT_FRAME_BYTES
) {
fail(
new AgentAttachTransportError(
'Agent attach handshake input exceeded its limit',
'overflow'
)
)
return
}
input = Buffer.concat([input, bytes])
if (input.byteLength < 4) {
return
}
const length = input.readUInt32BE(0)
if (length < 2 || length > HANDSHAKE_MAXIMUM_BYTES) {
fail(
new AgentAttachTransportError(
'Agent attach welcome is oversized',
'malformed'
)
)
return
}
if (input.byteLength < 4 + length) {
return
}
try {
const value: unknown = JSON.parse(
input.subarray(4, 4 + length).toString('utf8')
)
const welcome = attachWelcomeSchema.parse(value)
if (
welcome.protocol.major !== preface.protocol.major ||
welcome.protocol.minor > preface.protocol.minor
) {
throw new Error('Agent attach protocol version is incompatible')
}
settled = true
cleanup()
resolve({
welcome,
trailingBytes: input.subarray(4 + length),
stderr
})
} catch (error) {
fail(
new AgentAttachTransportError(
'Agent attach welcome is malformed',
'malformed',
error
)
)
}
}
channel.on('data', onData)
channel.stderr.on('data', onStderr)
channel.once('error', onError)
channel.once('close', onClose)
signal.addEventListener('abort', onAbort, { once: true })
const contents = Buffer.from(JSON.stringify(preface), 'utf8')
if (contents.byteLength > HANDSHAKE_MAXIMUM_BYTES) {
fail(
new AgentAttachTransportError(
'Agent attach preface is oversized',
'malformed'
)
)
return
}
const packet = Buffer.allocUnsafe(contents.byteLength + 4)
packet.writeUInt32BE(contents.byteLength, 0)
contents.copy(packet, 4)
channel.write(packet, (error?: Error | null) => {
if (error !== undefined && error !== null) {
fail(error)
}
})
})
}
function encodedFrameLength(frame: AgentFrame): number {
return (
AGENT_FRAME_FIXED_HEADER_BYTES +
Buffer.byteLength(frame.header.connectionId) +
Buffer.byteLength(frame.header.channelId) +
frame.payload.byteLength
)
}
@@ -0,0 +1,439 @@
import {
createHash,
generateKeyPairSync,
sign,
type KeyObject
} from 'node:crypto'
import {
chmodSync,
mkdtempSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest'
import type {
AgentBundleManifest,
AgentReleaseKeyRegistry,
AgentRuntimeLock
} from '../../shared/agent-installation-contracts'
import {
agentManifestSignaturePayload,
canonicalAgentManifestBytes,
verifyAgentBundleDirectory,
verifyAgentManifestSignature,
type VerifiedAgentBundle
} from './agent-bundle-verifier'
type BuildAgentBundleModule = {
canonicalManifestBytes(
manifest: AgentBundleManifest
): Buffer
createManifest(
directory: string,
metadata: {
agentVersion: string
nodeVersion: string
zodVersion: string
koffiVersion: string
koffiNativePackage: string
arch: 'x64' | 'arm64'
protocol: { major: number; minor: number }
signingKeyId: string
}
): AgentBundleManifest
signaturePayload(manifestBytes: Buffer): Buffer
}
const require = createRequire(import.meta.url)
const buildAgentBundle = require(
'../../../build/agent-bundle.cjs'
) as BuildAgentBundleModule
let directory: string
let privateKey: KeyObject
let registry: AgentReleaseKeyRegistry
let runtimeLock: AgentRuntimeLock
function elf(architecture: 'x64' | 'arm64'): Buffer {
const contents = Buffer.alloc(64)
contents.set([0x7f, 0x45, 0x4c, 0x46])
contents[5] = 1
contents.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18)
return contents
}
function writeKoffiPayload(
bundleDirectory: string,
architecture: 'x64' | 'arm64'
): void {
const packageRoot = join(
bundleDirectory,
'lib',
'node_modules',
'koffi'
)
const nativePackage = `@koromix/koffi-linux-${architecture}`
const nativeRoot = join(
bundleDirectory,
'lib',
'node_modules',
...nativePackage.split('/')
)
mkdirSync(join(packageRoot, 'src', 'koffi', 'src'), {
recursive: true
})
mkdirSync(join(nativeRoot, `linux_${architecture}`), {
recursive: true
})
mkdirSync(join(nativeRoot, `musl_${architecture}`), {
recursive: true
})
const files = new Map<string, string | Buffer>([
[
join(packageRoot, 'package.json'),
JSON.stringify({
name: 'koffi',
version: '3.1.4',
type: 'module',
exports: { '.': { import: './index.js' } }
})
],
[
join(packageRoot, 'index.js'),
'export { default } from "./src/koffi/index.js";\n'
],
[
join(packageRoot, 'src', 'koffi', 'index.js'),
'export default {};\n'
],
[
join(packageRoot, 'src', 'koffi', 'src', 'static.js'),
'export function loadStatic() { return null }\n'
],
[
join(nativeRoot, 'package.json'),
JSON.stringify({
name: nativePackage,
version: '3.1.4',
main: './index.js',
os: ['linux'],
cpu: [architecture]
})
],
[
join(nativeRoot, 'index.js'),
`module.exports = require('./linux_${architecture}/koffi.node');\n`
],
[
join(nativeRoot, `linux_${architecture}`, 'koffi.node'),
elf(architecture)
],
[
join(nativeRoot, `musl_${architecture}`, 'koffi.node'),
elf(architecture)
]
])
for (const [filePath, contents] of files) {
writeFileSync(filePath, contents)
chmodSync(filePath, 0o644)
}
}
function writeBundle(
signingKeyId = 'generated-test-key'
): AgentBundleManifest {
mkdirSync(join(directory, 'lib'), { recursive: true })
mkdirSync(join(directory, 'licenses'), { recursive: true })
writeFileSync(join(directory, 'node'), elf('x64'))
writeFileSync(join(directory, 'goodbuddy-agent'), '#!/bin/sh\n')
writeFileSync(
join(directory, 'lib', 'agent.cjs'),
'module.exports = {}\n'
)
writeFileSync(
join(directory, 'licenses', 'GoodBuddy-0BSD.txt'),
'0BSD'
)
writeFileSync(
join(directory, 'licenses', 'Node.js-MIT.txt'),
'MIT'
)
writeFileSync(
join(directory, 'licenses', 'zod-MIT.txt'),
'MIT'
)
writeFileSync(
join(directory, 'licenses', 'koffi-MIT.txt'),
'MIT'
)
writeFileSync(
join(directory, 'licenses', 'koffi-native-MIT.txt'),
'MIT'
)
writeKoffiPayload(directory, 'x64')
for (const filePath of ['node', 'goodbuddy-agent']) {
chmodSync(join(directory, filePath), 0o755)
}
for (const filePath of [
'lib/agent.cjs',
'licenses/GoodBuddy-0BSD.txt',
'licenses/Node.js-MIT.txt',
'licenses/zod-MIT.txt',
'licenses/koffi-MIT.txt',
'licenses/koffi-native-MIT.txt'
]) {
chmodSync(join(directory, ...filePath.split('/')), 0o644)
}
const manifest = buildAgentBundle.createManifest(directory, {
agentVersion: '0.11.0',
nodeVersion: '24.19.0',
zodVersion: '4.4.3',
koffiVersion: '3.1.4',
koffiNativePackage: '@koromix/koffi-linux-x64',
arch: 'x64',
protocol: { major: 1, minor: 0 },
signingKeyId
})
const manifestBytes =
buildAgentBundle.canonicalManifestBytes(manifest)
writeFileSync(join(directory, 'manifest.json'), manifestBytes)
writeFileSync(
join(directory, 'manifest.sig'),
`${sign(
null,
buildAgentBundle.signaturePayload(manifestBytes),
privateKey
).toString('base64')}\n`
)
chmodSync(join(directory, 'manifest.json'), 0o644)
chmodSync(join(directory, 'manifest.sig'), 0o644)
return manifest
}
async function verifyBundle(): Promise<VerifiedAgentBundle> {
return verifyAgentBundleDirectory(directory, {
architecture: 'x64',
registry,
runtimeLock,
verificationEnvironment: 'test',
enforceFilesystemMode: process.platform !== 'win32'
})
}
beforeEach(() => {
directory = mkdtempSync(
join(tmpdir(), 'goodbuddy-runtime-agent-bundle-')
)
const generated = generateKeyPairSync('ed25519')
privateKey = generated.privateKey
registry = {
formatVersion: 1,
keys: [
{
keyId: 'generated-test-key',
publicKeySpkiBase64: generated.publicKey
.export({ format: 'der', type: 'spki' })
.toString('base64'),
environment: 'test'
}
],
revocations: []
}
const target = {
archive: 'node-v24.19.0-linux.tar.gz',
sha256: 'a'.repeat(64),
binaryPath: 'node/bin/node',
licensePath: 'node/LICENSE'
}
runtimeLock = {
formatVersion: 1,
agentVersion: '0.11.0',
protocol: { major: 1, minor: 0 },
node: {
version: '24.19.0',
source: 'https://nodejs.org/dist/v24.19.0/',
targets: {
'linux-x64': target,
'linux-arm64': target
}
},
koffi: { version: '3.1.4' }
}
})
afterEach(() => {
rmSync(directory, { recursive: true, force: true })
})
describe('runtime and build-time Agent verifier parity', () => {
it('uses the exact build-time canonical bytes and signature domain', async () => {
const manifest = writeBundle()
const manifestBytes = readFileSync(
join(directory, 'manifest.json')
)
expect(canonicalAgentManifestBytes(manifest)).toEqual(
buildAgentBundle.canonicalManifestBytes(manifest)
)
expect(agentManifestSignaturePayload(manifestBytes)).toEqual(
buildAgentBundle.signaturePayload(manifestBytes)
)
await expect(verifyBundle()).resolves.toMatchObject({
manifest: {
arch: 'x64',
protocol: { major: 1, minor: 0 }
}
})
})
it('rejects corrupt, undeclared, missing, and architecture-mismatched payloads', async () => {
writeBundle()
writeFileSync(join(directory, 'lib', 'agent.cjs'), 'corrupt')
await expect(verifyBundle()).rejects.toThrow(
/size mismatch|hash mismatch/u
)
rmSync(directory, { recursive: true, force: true })
directory = mkdtempSync(
join(tmpdir(), 'goodbuddy-runtime-agent-bundle-')
)
writeBundle()
writeFileSync(join(directory, 'undeclared.txt'), 'no')
await expect(verifyBundle()).rejects.toThrow(
'undeclared or missing files'
)
rmSync(join(directory, 'licenses', 'zod-MIT.txt'))
await expect(verifyBundle()).rejects.toThrow()
rmSync(directory, { recursive: true, force: true })
directory = mkdtempSync(
join(tmpdir(), 'goodbuddy-runtime-agent-bundle-')
)
writeBundle()
writeFileSync(join(directory, 'node'), elf('arm64'))
const manifest = JSON.parse(
readFileSync(join(directory, 'manifest.json'), 'utf8')
) as AgentBundleManifest
const nodeFile = manifest.files.find((file) => file.path === 'node')
if (!nodeFile) {
throw new Error('Fixture Node entry is missing')
}
nodeFile.sha256 = createHash('sha256')
.update(elf('arm64'))
.digest('hex')
const bytes = canonicalAgentManifestBytes(manifest)
writeFileSync(join(directory, 'manifest.json'), bytes)
writeFileSync(
join(directory, 'manifest.sig'),
`${sign(
null,
agentManifestSignaturePayload(bytes),
privateKey
).toString('base64')}\n`
)
await expect(verifyBundle()).rejects.toThrow(
'architecture mismatch'
)
})
it('refuses symlink payloads', async () => {
writeBundle()
const targetDirectory = join(directory, '.lib-target')
renameSync(
join(directory, 'lib'),
targetDirectory
)
symlinkSync(
targetDirectory,
join(directory, 'lib'),
process.platform === 'win32' ? 'junction' : 'dir'
)
await expect(verifyBundle()).rejects.toThrow(
/symlink|regular file/u
)
})
it('rejects non-canonical manifests and signature corruption', () => {
const manifest = writeBundle()
const canonical = canonicalAgentManifestBytes(manifest)
const nonCanonical = Buffer.from(JSON.stringify(manifest), 'utf8')
const nonCanonicalSignature = sign(
null,
agentManifestSignaturePayload(nonCanonical),
privateKey
)
expect(() =>
verifyAgentManifestSignature(
nonCanonical,
nonCanonicalSignature,
registry,
'test'
)
).toThrow('not in canonical deterministic form')
const signature = sign(
null,
agentManifestSignaturePayload(canonical),
privateKey
)
signature[0] = (signature[0] ?? 0) ^ 1
expect(() =>
verifyAgentManifestSignature(
canonical,
signature,
registry,
'test'
)
).toThrow('signature verification failed')
})
it('enforces key revocation and production keys', () => {
const manifest = writeBundle()
const manifestBytes = canonicalAgentManifestBytes(manifest)
const signature = sign(
null,
agentManifestSignaturePayload(manifestBytes),
privateKey
)
expect(() =>
verifyAgentManifestSignature(
manifestBytes,
signature,
{ ...registry, keys: [] }
)
).toThrow('production key registry is empty')
expect(() =>
verifyAgentManifestSignature(
manifestBytes,
signature,
{
...registry,
revocations: [
{
keyId: 'generated-test-key'
}
]
},
'test'
)
).toThrow('is revoked')
})
})
@@ -0,0 +1,639 @@
import {
createHash,
createPublicKey,
verify
} from 'node:crypto'
import { createReadStream } from 'node:fs'
import {
lstat,
open,
readFile,
readdir
} from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import {
agentBundleManifestSchema,
agentReleaseKeyRegistrySchema,
agentRuntimeLockSchema,
type AgentArchitecture,
type AgentBundleManifest,
type AgentReleaseKey,
type AgentReleaseKeyRegistry,
type AgentRuntimeLock
} from '../../shared/agent-installation-contracts'
import {
getBundledAgentDirectory,
type BundledAgentResourcePaths
} from './bundled-agent-resources'
const AGENT_MANIFEST_SIGNATURE_DOMAIN = Buffer.from(
'GoodBuddy Agent Bundle Manifest Signature v1\0',
'utf8'
)
const MANIFEST_FILE_NAME = 'manifest.json'
const SIGNATURE_FILE_NAME = 'manifest.sig'
const MAXIMUM_METADATA_BYTES = 1024 * 1024
export type AgentVerificationEnvironment = 'production' | 'test'
export type VerifiedAgentBundle = {
bundleDirectory: string
manifest: AgentBundleManifest
manifestSha256: string
}
export type VerifyAgentBundleOptions = {
architecture: AgentArchitecture
registry: AgentReleaseKeyRegistry
runtimeLock: AgentRuntimeLock
verificationEnvironment?: AgentVerificationEnvironment
enforceFilesystemMode?: boolean
filesystemPlatform?: NodeJS.Platform
}
export type VerifyBundledAgentOptions = {
verificationEnvironment?: AgentVerificationEnvironment
enforceFilesystemMode?: boolean
filesystemPlatform?: NodeJS.Platform
}
export async function readAgentReleaseKeyRegistry(
filePath: string
): Promise<AgentReleaseKeyRegistry> {
const value = await readJsonMetadata(
filePath,
'Agent trusted key registry'
)
return parseAgentReleaseKeyRegistry(value)
}
export async function readAgentRuntimeLock(
filePath: string
): Promise<AgentRuntimeLock> {
const value = await readJsonMetadata(filePath, 'Agent runtime lock')
return parseContract(
agentRuntimeLockSchema,
value,
'Agent runtime lock contract is invalid'
)
}
export async function verifyBundledAgentResources(
paths: BundledAgentResourcePaths,
architecture: AgentArchitecture,
options: VerifyBundledAgentOptions = {}
): Promise<VerifiedAgentBundle> {
const [registry, runtimeLock] = await Promise.all([
readAgentReleaseKeyRegistry(paths.keyRegistryPath),
readAgentRuntimeLock(paths.runtimeLockPath)
])
return verifyAgentBundleDirectory(
getBundledAgentDirectory(paths, architecture),
{
...options,
architecture,
registry,
runtimeLock
}
)
}
export async function verifyAgentBundleDirectory(
bundleDirectory: string,
options: VerifyAgentBundleOptions
): Promise<VerifiedAgentBundle> {
const registry = parseAgentReleaseKeyRegistry(options.registry)
const runtimeLock = parseContract(
agentRuntimeLockSchema,
options.runtimeLock,
'Agent runtime lock contract is invalid'
)
const verificationEnvironment =
options.verificationEnvironment ?? 'production'
assertProductionRegistryAvailable(registry, verificationEnvironment)
await assertDirectoryWithoutSymlink(
bundleDirectory,
'Agent bundle directory'
)
const manifestBytes = await readBoundedFile(
join(bundleDirectory, MANIFEST_FILE_NAME),
MAXIMUM_METADATA_BYTES,
'Agent manifest'
)
const signatureBytes = await readDetachedSignature(
join(bundleDirectory, SIGNATURE_FILE_NAME)
)
const manifest = verifyAgentManifestSignature(
manifestBytes,
signatureBytes,
registry,
verificationEnvironment
)
assertAgentManifestMatchesRuntimeLock(
manifest,
runtimeLock,
options.architecture
)
const declaredPaths = new Set(
manifest.files.map((file) => file.path)
)
for (const file of manifest.files) {
const absolutePath = join(
bundleDirectory,
...file.path.split('/')
)
const fileStat = await lstat(absolutePath)
if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
throw new Error(
`Agent payload is not a regular file: ${file.path}`
)
}
if (fileStat.size !== file.size) {
throw new Error(`Agent payload size mismatch: ${file.path}`)
}
if ((await sha256File(absolutePath)) !== file.sha256) {
throw new Error(`Agent payload hash mismatch: ${file.path}`)
}
if (
shouldEnforceFilesystemMode(options) &&
modeString(fileStat.mode) !== file.mode
) {
throw new Error(`Agent payload mode mismatch: ${file.path}`)
}
}
const actualPaths = new Set(
await listRegularBundleFiles(bundleDirectory)
)
const expectedPaths = new Set([
...declaredPaths,
MANIFEST_FILE_NAME,
SIGNATURE_FILE_NAME
])
if (
actualPaths.size !== expectedPaths.size ||
[...actualPaths].some((path) => !expectedPaths.has(path))
) {
throw new Error('Agent bundle contains undeclared or missing files')
}
if (shouldEnforceFilesystemMode(options)) {
for (const metadataName of [
MANIFEST_FILE_NAME,
SIGNATURE_FILE_NAME
]) {
const metadataStat = await lstat(
join(bundleDirectory, metadataName)
)
if (
!metadataStat.isFile() ||
metadataStat.isSymbolicLink() ||
modeString(metadataStat.mode) !== '0644'
) {
throw new Error(
`Agent payload mode mismatch: ${metadataName}`
)
}
}
}
await assertElfArchitecture(
join(bundleDirectory, manifest.entrypoint.runtimePath),
manifest.arch,
'Agent Node runtime'
)
const koffiPaths = requiredKoffiPayloadPaths(manifest.arch)
assertKoffiManifest(
manifest,
runtimeLock.koffi.version,
koffiPaths.required
)
for (const nativePath of koffiPaths.native) {
await assertElfArchitecture(
join(bundleDirectory, ...nativePath.split('/')),
manifest.arch,
'Agent Koffi native binding'
)
}
return {
bundleDirectory,
manifest,
manifestSha256: sha256Bytes(manifestBytes)
}
}
export function canonicalAgentManifestBytes(
manifest: AgentBundleManifest
): Buffer {
return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
}
export function agentManifestSignaturePayload(
manifestBytes: Uint8Array
): Buffer {
return Buffer.concat([
AGENT_MANIFEST_SIGNATURE_DOMAIN,
Buffer.from(manifestBytes)
])
}
export function verifyAgentManifestSignature(
manifestBytes: Buffer,
signatureBytes: Buffer,
registryInput: AgentReleaseKeyRegistry,
verificationEnvironment: AgentVerificationEnvironment = 'production'
): AgentBundleManifest {
const registry = parseAgentReleaseKeyRegistry(registryInput)
assertProductionRegistryAvailable(registry, verificationEnvironment)
let untrustedManifest: unknown
try {
untrustedManifest = JSON.parse(manifestBytes.toString('utf8'))
} catch (error) {
throw new Error('Agent manifest JSON is invalid', { cause: error })
}
const manifest = parseContract(
agentBundleManifestSchema,
untrustedManifest,
'Agent manifest contract is invalid'
)
if (!canonicalAgentManifestBytes(manifest).equals(manifestBytes)) {
throw new Error(
'Agent manifest is not in canonical deterministic form'
)
}
const trustedKey = trustedKeyForManifest(
manifest,
registry,
verificationEnvironment
)
const publicKey = importEd25519PublicKey(trustedKey)
if (
signatureBytes.length !== 64 ||
!verify(
null,
agentManifestSignaturePayload(manifestBytes),
publicKey,
signatureBytes
)
) {
throw new Error('Agent manifest signature verification failed')
}
return manifest
}
export function assertAgentManifestMatchesRuntimeLock(
manifest: AgentBundleManifest,
lock: AgentRuntimeLock,
architecture: AgentArchitecture
): void {
if (
manifest.agentVersion !== lock.agentVersion
) {
throw new Error(
'Agent manifest version does not match the runtime lock'
)
}
if (manifest.arch !== architecture) {
throw new Error(
'Agent manifest architecture does not match the selected bundle'
)
}
if (
manifest.protocol.major !== lock.protocol.major ||
manifest.protocol.minor !== lock.protocol.minor
) {
throw new Error(
'Agent manifest protocol does not match the runtime lock'
)
}
}
function requiredKoffiPayloadPaths(
architecture: AgentArchitecture
): {
required: string[]
native: string[]
} {
const packageRoot = 'lib/node_modules/koffi'
const nativePackage =
`@koromix/koffi-linux-${architecture}`
const nativeRoot = `lib/node_modules/${nativePackage}`
return {
required: [
`${packageRoot}/package.json`,
`${packageRoot}/index.js`,
`${packageRoot}/src/koffi/index.js`,
`${packageRoot}/src/koffi/src/static.js`,
`${nativeRoot}/package.json`,
`${nativeRoot}/index.js`,
`${nativeRoot}/linux_${architecture}/koffi.node`,
`${nativeRoot}/musl_${architecture}/koffi.node`
],
native: [
`${nativeRoot}/linux_${architecture}/koffi.node`,
`${nativeRoot}/musl_${architecture}/koffi.node`
]
}
}
function assertKoffiManifest(
manifest: AgentBundleManifest,
version: string,
requiredPaths: readonly string[]
): void {
const declaredPaths = new Set(
manifest.files.map((file) => file.path)
)
for (const path of requiredPaths) {
if (!declaredPaths.has(path)) {
throw new Error(`Agent Koffi payload is missing: ${path}`)
}
}
const nativePackage =
`@koromix/koffi-linux-${manifest.arch}`
const koffiLicense = manifest.licenses.find(
(license) => license.package === 'koffi'
)
const nativeLicense = manifest.licenses.find(
(license) => license.package === nativePackage
)
if (
koffiLicense?.version !== version ||
koffiLicense.spdx !== 'MIT' ||
koffiLicense.path !== 'licenses/koffi-MIT.txt' ||
nativeLicense?.version !== version ||
nativeLicense.spdx !== 'MIT' ||
nativeLicense.path !== 'licenses/koffi-native-MIT.txt'
) {
throw new Error(
'Agent Koffi dependencies do not match the runtime lock'
)
}
}
function trustedKeyForManifest(
manifest: AgentBundleManifest,
registry: AgentReleaseKeyRegistry,
verificationEnvironment: AgentVerificationEnvironment
): AgentReleaseKey {
const key = registry.keys.find(
(candidate) => candidate.keyId === manifest.signingKeyId
)
if (!key) {
throw new Error(
`Agent manifest uses an unknown signing key: ${manifest.signingKeyId}`
)
}
if (
verificationEnvironment === 'production' &&
key.environment !== 'production'
) {
throw new Error(
`Production Agent verification rejects non-production key: ${key.keyId}`
)
}
if (
registry.revocations.some(
(revocation) => revocation.keyId === key.keyId
)
) {
throw new Error(`Agent signing key is revoked: ${key.keyId}`)
}
return key
}
function assertProductionRegistryAvailable(
registry: AgentReleaseKeyRegistry,
verificationEnvironment: AgentVerificationEnvironment
): void {
if (
verificationEnvironment === 'production' &&
!registry.keys.some((key) => key.environment === 'production')
) {
throw new Error(
'Agent production key registry is empty; bundled Agent verification is unavailable'
)
}
}
function importEd25519PublicKey(key: AgentReleaseKey) {
let publicKey
try {
publicKey = createPublicKey({
key: Buffer.from(key.publicKeySpkiBase64, 'base64'),
format: 'der',
type: 'spki'
})
} catch (error) {
throw new Error(
`Agent trusted public key is invalid: ${key.keyId}`,
{ cause: error }
)
}
if (publicKey.asymmetricKeyType !== 'ed25519') {
throw new Error(
`Agent trusted public key is not Ed25519: ${key.keyId}`
)
}
return publicKey
}
function parseAgentReleaseKeyRegistry(
value: unknown
): AgentReleaseKeyRegistry {
const registry = parseContract(
agentReleaseKeyRegistrySchema,
value,
'Agent trusted key registry contract is invalid'
)
for (const key of registry.keys) {
importEd25519PublicKey(key)
}
return registry
}
async function readDetachedSignature(filePath: string): Promise<Buffer> {
const signatureText = (
await readBoundedFile(
filePath,
128,
'Agent detached signature'
)
).toString('utf8')
if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(signatureText)) {
throw new Error('Agent detached signature encoding is invalid')
}
return Buffer.from(signatureText.trim(), 'base64')
}
async function readJsonMetadata(
filePath: string,
description: string
): Promise<unknown> {
const contents = await readBoundedFile(
filePath,
MAXIMUM_METADATA_BYTES,
description
)
try {
return JSON.parse(contents.toString('utf8'))
} catch (error) {
throw new Error(`${description} is invalid JSON`, { cause: error })
}
}
async function readBoundedFile(
filePath: string,
maximumBytes: number,
description: string
): Promise<Buffer> {
const fileStat = await lstat(filePath)
if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
throw new Error(`${description} is not a regular file`)
}
if (fileStat.size > maximumBytes) {
throw new Error(`${description} exceeds its size limit`)
}
return readFile(filePath)
}
async function listRegularBundleFiles(
bundleDirectory: string
): Promise<string[]> {
const files: string[] = []
const pending = [bundleDirectory]
while (pending.length > 0) {
const current = pending.pop()
if (current === undefined) {
continue
}
const entries = await readdir(current, { withFileTypes: true })
for (const entry of entries) {
const absolutePath = join(current, entry.name)
if (entry.isSymbolicLink()) {
throw new Error(
`Agent bundle cannot contain a symlink: ${absolutePath}`
)
}
if (entry.isDirectory()) {
pending.push(absolutePath)
} else if (entry.isFile()) {
files.push(
relative(bundleDirectory, absolutePath)
.split(sep)
.join('/')
)
} else {
throw new Error(
`Agent bundle contains an unsupported file: ${absolutePath}`
)
}
}
}
return files
}
async function assertDirectoryWithoutSymlink(
directory: string,
description: string
): Promise<void> {
const directoryStat = await lstat(directory)
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
throw new Error(`${description} is not a regular directory`)
}
}
async function sha256File(filePath: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(filePath)) {
hash.update(chunk as Buffer)
}
return hash.digest('hex')
}
function sha256Bytes(contents: Uint8Array): string {
return createHash('sha256').update(contents).digest('hex')
}
function shouldEnforceFilesystemMode(
options: Pick<
VerifyAgentBundleOptions,
'enforceFilesystemMode' | 'filesystemPlatform'
>
): boolean {
return (
options.enforceFilesystemMode !== false &&
(options.filesystemPlatform ?? process.platform) !== 'win32'
)
}
function modeString(mode: number): string {
return (mode & 0o777).toString(8).padStart(4, '0')
}
async function assertElfArchitecture(
filePath: string,
expectedArchitecture: AgentArchitecture,
description: string
): Promise<void> {
const handle = await open(filePath, 'r')
try {
const header = Buffer.alloc(64)
const { bytesRead } = await handle.read(header, 0, header.length, 0)
const actualArchitecture = detectElfArchitecture(
header.subarray(0, bytesRead)
)
if (actualArchitecture !== expectedArchitecture) {
throw new Error(
`${description} architecture mismatch: expected ${expectedArchitecture}, received ${actualArchitecture ?? 'unknown'}`
)
}
} finally {
await handle.close()
}
}
function detectElfArchitecture(
buffer: Buffer
): AgentArchitecture | undefined {
if (
buffer.length < 20 ||
buffer[0] !== 0x7f ||
buffer.toString('ascii', 1, 4) !== 'ELF' ||
(buffer[5] !== 1 && buffer[5] !== 2)
) {
return undefined
}
const machine =
buffer[5] === 2
? buffer.readUInt16BE(18)
: buffer.readUInt16LE(18)
if (machine === 62) {
return 'x64'
}
if (machine === 183) {
return 'arm64'
}
return undefined
}
function parseContract<T>(
schema: { safeParse(value: unknown): {
success: true
data: T
} | {
success: false
error: { message: string }
} },
value: unknown,
message: string
): T {
const result = schema.safeParse(value)
if (!result.success) {
throw new Error(`${message}: ${result.error.message}`)
}
return result.data
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,569 @@
import { describe, expect, it, vi } from 'vitest'
import {
AGENT_PROTOCOL_LIMITS,
AGENT_PROTOCOL_VERSION,
type AgentFrame,
type AttachWelcome
} from '../../shared/agent-protocol'
import type { AgentAttachTransport } from './agent-attach-transport'
import {
AgentProtocolClient,
AgentProtocolClientError
} from './agent-protocol-client'
class FakeTransport {
readonly welcome: AttachWelcome = {
type: 'goodbuddy-agent-welcome',
protocol: AGENT_PROTOCOL_VERSION,
connectionId: 'connection-1',
generation: 3,
installationId: 'agent-v1',
binaryDigest: `sha256:${'a'.repeat(64)}`,
daemonBootId: 'boot-1',
serverNonce: 'server-nonce'
}
readonly sent: AgentFrame[] = []
#frames: AgentFrame[] = []
#readers: Array<{
resolve: (frame: AgentFrame) => void
reject: (error: unknown) => void
}> = []
#close?: (error: Error) => void
send(frame: AgentFrame): Promise<void> {
this.sent.push(frame)
return Promise.resolve()
}
receive(): Promise<AgentFrame> {
const frame = this.#frames.shift()
if (frame !== undefined) {
return Promise.resolve(frame)
}
return new Promise((resolve, reject) => {
this.#readers.push({ resolve, reject })
})
}
remote(frame: AgentFrame): void {
const reader = this.#readers.shift()
if (reader !== undefined) {
reader.resolve(frame)
} else {
this.#frames.push(frame)
}
}
onClose(listener: (error: Error) => void): () => void {
this.#close = listener
return () => {
this.#close = undefined
}
}
dispose(): void {
const error = new Error('disposed')
this.#close?.(error)
for (const reader of this.#readers.splice(0)) {
reader.reject(error)
}
}
}
function inbound(
outbound: AgentFrame,
sequence: string,
kind: AgentFrame['header']['kind'],
value: unknown
): AgentFrame {
const payload =
value instanceof Uint8Array
? value
: Buffer.from(JSON.stringify(value), 'utf8')
return {
header: {
...outbound.header,
direction: 'agent-to-main',
sequence,
kind,
payloadLength: payload.byteLength
},
payload
}
}
async function waitForSent(
transport: FakeTransport,
count: number
): Promise<void> {
await vi.waitFor(() => expect(transport.sent.length).toBeGreaterThanOrEqual(count))
}
describe('AgentProtocolClient', () => {
it('strictly resolves a typed response only after matching channel close', async () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const result = client.request('agent/status', {})
await waitForSent(transport, 1)
const requestFrame = transport.sent[0]!
const request = JSON.parse(
Buffer.from(requestFrame.payload).toString('utf8')
) as { id: string }
transport.remote(
inbound(requestFrame, '1', 'control', {
jsonrpc: '2.0',
id: request.id,
result: {
state: 'ready',
installationId: 'agent-v1',
binaryDigest: `sha256:${'a'.repeat(64)}`,
daemonBootId: 'boot-1',
agentVersion: '1.0.0',
protocol: AGENT_PROTOCOL_VERSION,
platform: 'linux',
architecture: 'x64',
supervisor: 'detached-on-demand',
remoteUserIdentity: 'user-1',
draining: false
}
})
)
let settled = false
void result.finally(() => {
settled = true
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(settled).toBe(false)
transport.remote(
inbound(requestFrame, '2', 'control', {
jsonrpc: '2.0',
method: 'channel/close',
params: {
channelId: requestFrame.header.channelId,
channelEpoch: requestFrame.header.channelEpoch
}
})
)
await expect(result).resolves.toMatchObject({ state: 'ready' })
client.dispose()
})
it('bounds abandoned waiters within request capacity', async () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
for (let index = 0; index < 256; index += 1) {
const controller = new AbortController()
const request = client.request('agent/status', {}, {
signal: controller.signal
})
controller.abort(new Error('cancelled waiter'))
await expect(request).rejects.toThrow('cancelled waiter')
}
await expect(
client.request('agent/status', {})
).rejects.toMatchObject({
code: 'capacity'
})
client.dispose()
})
it('retains response sequence state when cancellation races channel close', async () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const closed = vi.fn()
client.onClose(closed)
const controller = new AbortController()
const result = client.request('agent/status', {}, {
signal: controller.signal
})
await waitForSent(transport, 1)
const requestFrame = transport.sent[0]!
const request = JSON.parse(
Buffer.from(requestFrame.payload).toString('utf8')
) as { id: string }
transport.remote(
inbound(requestFrame, '1', 'control', {
jsonrpc: '2.0',
id: request.id,
result: {
state: 'ready',
installationId: 'agent-v1',
binaryDigest: `sha256:${'a'.repeat(64)}`,
daemonBootId: 'boot-1',
agentVersion: '1.0.0',
protocol: AGENT_PROTOCOL_VERSION,
platform: 'linux',
architecture: 'x64',
supervisor: 'detached-on-demand',
remoteUserIdentity: 'user-1',
draining: false
}
})
)
await new Promise<void>((resolve) => setTimeout(resolve, 0))
controller.abort(new Error('cancelled after response'))
await expect(result).rejects.toThrow('cancelled after response')
transport.remote(
inbound(requestFrame, '2', 'control', {
jsonrpc: '2.0',
method: 'channel/close',
params: {
channelId: requestFrame.header.channelId,
channelEpoch: requestFrame.header.channelEpoch
}
})
)
await new Promise<void>((resolve) => setTimeout(resolve, 0))
expect(closed).not.toHaveBeenCalled()
const nextRequest = client.request('agent/status', {})
void nextRequest.catch(() => undefined)
client.dispose()
})
it('acknowledges durable ACP output only after consumption', async () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const channel = client.registerBinaryChannel({
channelId: 'binding-1',
channelEpoch: '9',
kind: 'acp'
})
const payload = Buffer.from('runtime event')
transport.remote({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: 'connection-1',
generation: 3,
channelId: 'binding-1',
channelEpoch: '9',
direction: 'agent-to-main',
sequence: '1',
kind: 'acp',
payloadLength: payload.byteLength
},
payload
})
const received = await channel.receive()
expect(transport.sent).toHaveLength(0)
await received.consume()
expect(transport.sent).toHaveLength(1)
expect(transport.sent[0]!.header.kind).toBe('ack')
expect(transport.sent[0]!.header.sequence).toBe('1')
expect(
JSON.parse(Buffer.from(transport.sent[0]!.payload).toString('utf8'))
).toEqual({
acknowledgedSequence: '1'
})
await channel.send(Buffer.from('next input'))
expect(transport.sent[1]!.header.kind).toBe('acp')
expect(transport.sent[1]!.header.sequence).toBe('1')
const nextPayload = Buffer.from('next runtime event')
transport.remote({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: 'connection-1',
generation: 3,
channelId: 'binding-1',
channelEpoch: '9',
direction: 'agent-to-main',
sequence: '2',
kind: 'acp',
payloadLength: nextPayload.byteLength
},
payload: nextPayload
})
await expect(channel.receive()).resolves.toMatchObject({
sequence: '2',
payload: nextPayload
})
client.dispose()
})
it('seeds resumed ACP data sequences from authoritative cursors', async () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const channel = client.registerBinaryChannel({
channelId: 'binding-resumed',
channelEpoch: '12',
kind: 'acp',
nextInboundSequence: '7',
nextOutboundSequence: '5'
})
await channel.send(Buffer.from('new-only-input'))
expect(transport.sent[0]!.header.sequence).toBe('5')
const payload = Buffer.from('replayed-output')
transport.remote({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: 'connection-1',
generation: 3,
channelId: 'binding-resumed',
channelEpoch: '12',
direction: 'agent-to-main',
sequence: '7',
kind: 'acp',
payloadLength: payload.byteLength
},
payload
})
await expect(channel.receive()).resolves.toMatchObject({
sequence: '7',
payload
})
client.dispose()
})
it('notifies binary channel close listeners exactly once', () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const channel = client.registerBinaryChannel({
channelId: 'binding-close',
channelEpoch: '11',
kind: 'acp'
})
const closed = vi.fn()
channel.onClose(closed)
channel.close()
channel.close()
expect(closed).toHaveBeenCalledOnce()
client.dispose()
})
it('allocates unique Main-owned blob channels and closes with a matching notification', async () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const first = client.allocateBinaryChannel({ kind: 'blob' })
const second = client.allocateBinaryChannel({ kind: 'blob' })
expect(first.channelId).not.toBe(second.channelId)
expect(first.channelEpoch).not.toBe(second.channelEpoch)
await first.closeWithNotification()
expect(transport.sent).toHaveLength(1)
expect(transport.sent[0]!.header).toMatchObject({
channelId: first.channelId,
channelEpoch: first.channelEpoch,
kind: 'control',
direction: 'main-to-agent'
})
expect(
JSON.parse(Buffer.from(transport.sent[0]!.payload).toString('utf8'))
).toEqual({
jsonrpc: '2.0',
method: 'channel/close',
params: {
channelId: first.channelId,
channelEpoch: first.channelEpoch
}
})
expect(() =>
client.registerBinaryChannel({
channelId: first.channelId,
channelEpoch: first.channelEpoch,
kind: 'blob'
})
).toThrow()
second.close()
client.dispose()
})
it('sends one bounded blob message as one protocol frame', async () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const channel = client.allocateBinaryChannel({ kind: 'blob' })
const payload = Buffer.alloc(
AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes
)
await channel.send(payload)
expect(transport.sent).toHaveLength(1)
expect(transport.sent[0]!.header).toMatchObject({
kind: 'blob',
sequence: '1'
})
expect(transport.sent[0]!.payload.byteLength).toBe(
payload.byteLength
)
await expect(
channel.send(
Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes + 1)
)
).rejects.toMatchObject({ code: 'protocol' })
const controller = new AbortController()
controller.abort(new Error('cancelled'))
await expect(
channel.send(Buffer.of(1), controller.signal)
).rejects.toThrow(/cancelled/iu)
expect(transport.sent).toHaveLength(1)
channel.close()
client.dispose()
})
it('receives sequential blob messages without transport ACK traffic', async () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const channel = client.allocateBinaryChannel({ kind: 'blob' })
for (let index = 1; index <= 3; index += 1) {
const payload = Buffer.alloc(128 * 1024, index)
transport.remote({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: 'connection-1',
generation: 3,
channelId: channel.channelId,
channelEpoch: channel.channelEpoch,
direction: 'agent-to-main',
sequence: String(index),
kind: 'blob',
payloadLength: payload.byteLength
},
payload
})
const frame = await channel.receive()
expect(frame.payload[0]).toBe(index)
await frame.consume()
}
expect(transport.sent).toHaveLength(0)
channel.close()
client.dispose()
})
it.each([
{
label: 'unknown Agent-first channel',
frame: {
channelId: 'agent-first',
channelEpoch: '90',
generation: 3,
kind: 'blob' as const
}
},
{
label: 'cross-kind frame',
frame: {
channelId: 'allocated',
channelEpoch: 'allocated',
generation: 3,
kind: 'acp' as const
}
},
{
label: 'stale epoch',
frame: {
channelId: 'allocated',
channelEpoch: '91',
generation: 3,
kind: 'blob' as const
}
},
{
label: 'stale generation',
frame: {
channelId: 'allocated',
channelEpoch: 'allocated',
generation: 2,
kind: 'blob' as const
}
}
])('rejects a $label', async ({ frame }) => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const closed = new Promise<AgentProtocolClientError>((resolve) => {
client.onClose(resolve)
})
const allocated = client.allocateBinaryChannel({ kind: 'blob' })
const payload = Buffer.from('invalid')
transport.remote({
header: {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: 'connection-1',
generation: frame.generation,
channelId:
frame.channelId === 'allocated'
? allocated.channelId
: frame.channelId,
channelEpoch:
frame.channelEpoch === 'allocated'
? allocated.channelEpoch
: frame.channelEpoch,
direction: 'agent-to-main',
sequence: '1',
kind: frame.kind,
payloadLength: payload.byteLength
},
payload
})
await expect(closed).resolves.toBeInstanceOf(
AgentProtocolClientError
)
expect(() => client.request('agent/status', {})).toThrow(
AgentProtocolClientError
)
})
it('relies on SSH transport backpressure instead of peer credit', async () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const channel = client.registerBinaryChannel({
channelId: 'binding-2',
channelEpoch: '10',
kind: 'acp'
})
await channel.send(Buffer.alloc(300 * 1024))
expect(transport.sent[0]!.payload).toHaveLength(300 * 1024)
client.dispose()
})
it('fails closed on a mismatched response ID', async () => {
const transport = new FakeTransport()
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const request = client.request('agent/status', {})
await waitForSent(transport, 1)
transport.remote(
inbound(transport.sent[0]!, '1', 'control', {
jsonrpc: '2.0',
id: 'wrong-request',
result: null
})
)
await expect(request).rejects.toBeInstanceOf(
AgentProtocolClientError
)
expect(() => client.request('agent/status', {})).toThrow(
AgentProtocolClientError
)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
getBundledAgentDirectory,
resolveBundledAgentResourcePaths
} from './bundled-agent-resources'
describe('bundled Agent resource paths', () => {
it('resolves development resources from injected application paths', () => {
const paths = resolveBundledAgentResourcePaths({
appPath: join('workspace', 'goodbuddy'),
resourcesPath: join('electron', 'resources'),
packaged: false
})
expect(paths).toEqual({
keyRegistryPath: join(
'workspace',
'goodbuddy',
'resources',
'agent-release-keys.json'
),
runtimeLockPath: join(
'workspace',
'goodbuddy',
'agent-runtime-lock.json'
),
bundleDirectories: {
x64: join(
'workspace',
'goodbuddy',
'.agent-resources',
'linux-x64'
),
arm64: join(
'workspace',
'goodbuddy',
'.agent-resources',
'linux-arm64'
)
}
})
})
it('resolves packaged metadata and both Agent bundles outside ASAR', () => {
const paths = resolveBundledAgentResourcePaths({
appPath: join('installed', 'resources', 'app.asar'),
resourcesPath: join('installed', 'resources'),
packaged: true
})
expect(paths.keyRegistryPath).toBe(
join('installed', 'resources', 'agent-release-keys.json')
)
expect(paths.runtimeLockPath).toBe(
join('installed', 'resources', 'agent-runtime-lock.json')
)
expect(getBundledAgentDirectory(paths, 'x64')).toBe(
join('installed', 'resources', 'agents', 'linux-x64')
)
expect(getBundledAgentDirectory(paths, 'arm64')).toBe(
join('installed', 'resources', 'agents', 'linux-arm64')
)
})
})
@@ -0,0 +1,43 @@
import { join } from 'node:path'
import type { AgentArchitecture } from '../../shared/agent-installation-contracts'
export type BundledAgentResourcePaths = {
keyRegistryPath: string
runtimeLockPath: string
bundleDirectories: Record<AgentArchitecture, string>
}
export type BundledAgentResourceEnvironment = {
appPath: string
resourcesPath: string
packaged: boolean
}
export function resolveBundledAgentResourcePaths(
environment: BundledAgentResourceEnvironment
): BundledAgentResourcePaths {
const metadataRoot = environment.packaged
? environment.resourcesPath
: environment.appPath
const bundleRoot = environment.packaged
? join(environment.resourcesPath, 'agents')
: join(environment.appPath, '.agent-resources')
return {
keyRegistryPath: environment.packaged
? join(metadataRoot, 'agent-release-keys.json')
: join(metadataRoot, 'resources', 'agent-release-keys.json'),
runtimeLockPath: join(metadataRoot, 'agent-runtime-lock.json'),
bundleDirectories: {
x64: join(bundleRoot, 'linux-x64'),
arm64: join(bundleRoot, 'linux-arm64')
}
}
}
export function getBundledAgentDirectory(
paths: BundledAgentResourcePaths,
architecture: AgentArchitecture
): string {
return paths.bundleDirectories[architecture]
}
@@ -0,0 +1,79 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
getBundledRemoteRuntimeRoot,
resolveBundledRemoteRuntimeResourcePaths
} from './bundled-remote-runtime-resources'
describe('bundled remote Runtime resource paths', () => {
it('resolves development metadata and architecture roots', () => {
const paths = resolveBundledRemoteRuntimeResourcePaths({
appPath: join('workspace', 'goodbuddy'),
resourcesPath: join('electron', 'resources'),
packaged: false
})
expect(paths).toEqual({
keyRegistryPath: join(
'workspace',
'goodbuddy',
'resources',
'agent-release-keys.json'
),
runtimeLockPath: join(
'workspace',
'goodbuddy',
'remote-runtime-lock.json'
),
runtimeRoots: {
x64: join(
'workspace',
'goodbuddy',
'.remote-runtime-resources',
'linux-x64',
'opencode'
),
arm64: join(
'workspace',
'goodbuddy',
'.remote-runtime-resources',
'linux-arm64',
'opencode'
)
}
})
})
it('resolves packaged metadata and Runtime roots outside ASAR', () => {
const paths = resolveBundledRemoteRuntimeResourcePaths({
appPath: join('installed', 'resources', 'app.asar'),
resourcesPath: join('installed', 'resources'),
packaged: true
})
expect(paths.keyRegistryPath).toBe(
join('installed', 'resources', 'agent-release-keys.json')
)
expect(paths.runtimeLockPath).toBe(
join('installed', 'resources', 'remote-runtime-lock.json')
)
expect(getBundledRemoteRuntimeRoot(paths, 'x64')).toBe(
join(
'installed',
'resources',
'remote-runtimes',
'linux-x64',
'opencode'
)
)
expect(getBundledRemoteRuntimeRoot(paths, 'arm64')).toBe(
join(
'installed',
'resources',
'remote-runtimes',
'linux-arm64',
'opencode'
)
)
})
})
@@ -0,0 +1,78 @@
import { join } from 'node:path'
import type { AgentArchitecture } from '../../shared/agent-installation-contracts'
export type BundledRemoteRuntimeResourcePaths = {
keyRegistryPath: string
runtimeLockPath: string
runtimeRoots: Record<AgentArchitecture, string>
}
export type BundledRemoteRuntimeResourceEnvironment = {
appPath: string
resourcesPath: string
packaged: boolean
}
export function resolveBundledRemoteRuntimeResourcePaths(
environment: BundledRemoteRuntimeResourceEnvironment
): BundledRemoteRuntimeResourcePaths {
if (environment.packaged) {
return {
keyRegistryPath: join(
environment.resourcesPath,
'agent-release-keys.json'
),
runtimeLockPath: join(
environment.resourcesPath,
'remote-runtime-lock.json'
),
runtimeRoots: {
x64: join(
environment.resourcesPath,
'remote-runtimes',
'linux-x64',
'opencode'
),
arm64: join(
environment.resourcesPath,
'remote-runtimes',
'linux-arm64',
'opencode'
)
}
}
}
return {
keyRegistryPath: join(
environment.appPath,
'resources',
'agent-release-keys.json'
),
runtimeLockPath: join(
environment.appPath,
'remote-runtime-lock.json'
),
runtimeRoots: {
x64: join(
environment.appPath,
'.remote-runtime-resources',
'linux-x64',
'opencode'
),
arm64: join(
environment.appPath,
'.remote-runtime-resources',
'linux-arm64',
'opencode'
)
}
}
}
export function getBundledRemoteRuntimeRoot(
paths: BundledRemoteRuntimeResourcePaths,
architecture: AgentArchitecture
): string {
return paths.runtimeRoots[architecture]
}
@@ -0,0 +1,241 @@
import { describe, expect, it } from 'vitest'
import {
ControllerStateStore,
type ControllerConnectionState,
type ControllerStateFile,
type PersistedControllerState
} from './controller-state-store'
class MemoryStateFile implements ControllerStateFile {
value?: unknown
writes = 0
async read(): Promise<unknown | undefined> {
return structuredClone(this.value)
}
async write(value: PersistedControllerState): Promise<void> {
this.value = structuredClone(value)
this.writes += 1
}
}
const connection: ControllerConnectionState = {
cacheKey: 'a'.repeat(64),
hostId: 'host-1',
installationId: 'agent-v1',
protocolMajor: 1,
previousConnectionId: 'connection-1',
previousGeneration: 2,
daemonBootId: 'boot-1',
capabilityGeneration: 3,
acpBindings: [
{
bindingId: 'binding-1',
channelId: 'binding-1',
channelEpoch: '7',
cursors: {
lastOutboundJournaledSequence: '4',
lastOutboundDeliveredSequence: '4',
lastInboundJournaledSequence: '6',
lastMainAckSequence: '5'
}
}
]
}
describe('ControllerStateStore', () => {
it('creates one stable controller ID and persists only active recovery state', async () => {
const file = new MemoryStateFile()
const first = new ControllerStateStore(file)
const controllerId = await first.getControllerId()
await first.updateConnection(connection)
first.dispose()
const second = new ControllerStateStore(file)
expect(await second.getControllerId()).toBe(controllerId)
expect(await second.getConnection(connection.cacheKey)).toEqual(
connection
)
const serialized = JSON.stringify(file.value)
expect(serialized).not.toMatch(
/password|secret|token|payload|result|hostname/iu
)
})
it('isolates host invalidation from other cached identities', async () => {
const file = new MemoryStateFile()
const store = new ControllerStateStore(file)
await store.updateConnection(connection)
await store.updateConnection({
...connection,
cacheKey: 'b'.repeat(64),
hostId: 'host-2',
previousConnectionId: 'connection-2'
})
await store.invalidateHost('host-1')
expect(await store.getConnection('a'.repeat(64))).toBeUndefined()
expect(await store.getConnection('b'.repeat(64))).toMatchObject({
hostId: 'host-2'
})
})
it('reads legacy synchronization fields but omits them from new writes', async () => {
const file = new MemoryStateFile()
file.value = {
version: 1,
controllerId: 'controller-1',
connections: [
{
...connection,
eventCursors: [
{
streamId: 'run-1',
streamEpoch: '1',
acknowledgedSequence: '8'
}
],
nonterminalOperationIds: ['operation-1'],
acpBindingIds: ['binding-1']
}
]
}
const store = new ControllerStateStore(file)
await expect(store.getConnection(connection.cacheKey)).resolves.toEqual(
connection
)
await store.updateConnection(connection)
expect(file.value).toEqual({
version: 1,
controllerId: 'controller-1',
connections: [connection]
})
})
it('rejects duplicate ACP bindings and unknown persisted fields', async () => {
const file = new MemoryStateFile()
const store = new ControllerStateStore(file)
expect(() =>
store.updateConnection({
...connection,
acpBindings: [
connection.acpBindings[0]!,
connection.acpBindings[0]!
]
})
).toThrow(/unique/iu)
file.value = {
version: 1,
controllerId: 'controller-1',
connections: [],
password: 'must-not-load'
}
const malformed = new ControllerStateStore(file)
await expect(malformed.getControllerId()).rejects.toThrow()
})
it('atomically advances and removes one ACP recovery binding', async () => {
const file = new MemoryStateFile()
const store = new ControllerStateStore(file)
await store.updateConnection(connection)
await store.updateAcpBinding(
connection.cacheKey,
'binding-1',
{
...connection.acpBindings[0]!,
cursors: {
lastOutboundJournaledSequence: '8',
lastOutboundDeliveredSequence: '8',
lastInboundJournaledSequence: '9',
lastMainAckSequence: '9'
}
}
)
await expect(
store.getConnection(connection.cacheKey)
).resolves.toMatchObject({
acpBindings: [
{
bindingId: 'binding-1',
cursors: {
lastOutboundJournaledSequence: '8',
lastMainAckSequence: '9'
}
}
]
})
await store.updateAcpBinding(
connection.cacheKey,
'binding-1',
undefined
)
await expect(
store.getConnection(connection.cacheKey)
).resolves.toMatchObject({
acpBindings: []
})
})
it('coalesces many ACP cursor advances into one bounded write', async () => {
const file = new MemoryStateFile()
const store = new ControllerStateStore(file)
await store.updateConnection(connection)
const writesBeforeCursors = file.writes
for (let sequence = 10; sequence <= 100; sequence += 1) {
const value = String(sequence)
await store.updateAcpBinding(connection.cacheKey, 'binding-1', {
...connection.acpBindings[0]!,
cursors: {
lastOutboundJournaledSequence: value,
lastOutboundDeliveredSequence: value,
lastInboundJournaledSequence: value,
lastMainAckSequence: value
}
})
}
expect(file.writes).toBe(writesBeforeCursors)
await store.flush()
expect(file.writes).toBe(writesBeforeCursors + 1)
expect(
(
await store.getConnection(connection.cacheKey)
)?.acpBindings[0]?.cursors.lastMainAckSequence
).toBe('100')
})
it('enforces cursor monotonicity against pending in-memory state', async () => {
const file = new MemoryStateFile()
const store = new ControllerStateStore(file)
await store.updateConnection(connection)
await store.updateAcpBinding(connection.cacheKey, 'binding-1', {
...connection.acpBindings[0]!,
cursors: {
lastOutboundJournaledSequence: '12',
lastOutboundDeliveredSequence: '12',
lastInboundJournaledSequence: '12',
lastMainAckSequence: '12'
}
})
await expect(
store.updateAcpBinding(connection.cacheKey, 'binding-1', {
...connection.acpBindings[0]!,
cursors: {
lastOutboundJournaledSequence: '11',
lastOutboundDeliveredSequence: '11',
lastInboundJournaledSequence: '12',
lastMainAckSequence: '12'
}
})
).rejects.toThrow(/cannot regress/iu)
await store.flush()
expect(
(
await store.getConnection(connection.cacheKey)
)?.acpBindings[0]?.cursors.lastOutboundJournaledSequence
).toBe('12')
})
})
@@ -0,0 +1,432 @@
import { randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import {
acpBindingCursorsSchema,
agentIdentifierSchema,
agentSequenceSchema,
positiveAgentSequenceSchema
} from '../../shared/agent-protocol'
import {
isMissingFileError,
writeJsonFileAtomically
} from '../settings-file-utils'
const MAXIMUM_CONNECTION_RECORDS = 128
const MAXIMUM_EVENT_CURSORS = 1_024
const MAXIMUM_NONTERMINAL_OPERATIONS = 4_096
const MAXIMUM_ACP_BINDINGS = 128
const ACP_BINDING_WRITE_DELAY_MS = 25
const eventCursorSchema = z
.object({
streamId: agentIdentifierSchema,
streamEpoch: positiveAgentSequenceSchema,
acknowledgedSequence: agentSequenceSchema
})
.strict()
const acpRecoveryBindingSchema = z
.object({
bindingId: agentIdentifierSchema,
channelId: agentIdentifierSchema,
channelEpoch: positiveAgentSequenceSchema,
cursors: acpBindingCursorsSchema
})
.strict()
const connectionStateSchema = z
.object({
cacheKey: z.string().regex(/^[a-f0-9]{64}$/u),
hostId: agentIdentifierSchema,
installationId: agentIdentifierSchema,
protocolMajor: z.number().int().min(0).max(65_535),
previousConnectionId: agentIdentifierSchema,
previousGeneration: z.number().int().min(1).max(0xffff_ffff),
daemonBootId: agentIdentifierSchema,
capabilityGeneration: z.number().int().min(1).max(0xffff_ffff),
acpBindings: z
.array(acpRecoveryBindingSchema)
.max(MAXIMUM_ACP_BINDINGS)
.default([])
})
.strict()
.superRefine((state, context) => {
const bindingIds = new Set<string>()
for (let index = 0; index < state.acpBindings.length; index += 1) {
const bindingId = state.acpBindings[index]!.bindingId
if (bindingIds.has(bindingId)) {
context.addIssue({
code: 'custom',
path: ['acpBindings', index, 'bindingId'],
message: 'ACP recovery binding identities must be unique'
})
}
bindingIds.add(bindingId)
}
})
const compatibleConnectionStateSchema = z
.object({
eventCursors: z
.array(eventCursorSchema)
.max(MAXIMUM_EVENT_CURSORS)
.optional(),
nonterminalOperationIds: z
.array(agentIdentifierSchema)
.max(MAXIMUM_NONTERMINAL_OPERATIONS)
.optional(),
acpBindingIds: z
.array(agentIdentifierSchema)
.max(MAXIMUM_ACP_BINDINGS)
.optional()
})
.passthrough()
.transform((state) => {
const migrated = { ...state }
delete migrated.eventCursors
delete migrated.nonterminalOperationIds
delete migrated.acpBindingIds
return connectionStateSchema.parse(migrated)
})
const persistedControllerStateSchema = z
.object({
version: z.literal(1),
controllerId: agentIdentifierSchema,
connections: z
.array(connectionStateSchema)
.max(MAXIMUM_CONNECTION_RECORDS)
})
.strict()
.superRefine((state, context) => {
const keys = new Set<string>()
for (let index = 0; index < state.connections.length; index += 1) {
const key = state.connections[index]!.cacheKey
if (keys.has(key)) {
context.addIssue({
code: 'custom',
path: ['connections', index, 'cacheKey'],
message: 'Controller connection cache keys must be unique'
})
}
keys.add(key)
}
})
const persistedControllerStateReadSchema = z
.object({
version: z.literal(1),
controllerId: agentIdentifierSchema,
connections: z
.array(compatibleConnectionStateSchema)
.max(MAXIMUM_CONNECTION_RECORDS)
})
.strict()
.transform((state) => persistedControllerStateSchema.parse(state))
export type ControllerAcpRecoveryBinding = z.infer<
typeof acpRecoveryBindingSchema
>
export type ControllerConnectionState = z.infer<
typeof connectionStateSchema
>
export type PersistedControllerState = z.infer<
typeof persistedControllerStateSchema
>
export interface ControllerStateFile {
read(): Promise<unknown | undefined>
write(value: PersistedControllerState): Promise<void>
}
export class JsonControllerStateFile implements ControllerStateFile {
constructor(readonly filePath: string) {}
async read(): Promise<unknown | undefined> {
try {
return JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
} catch (error) {
if (isMissingFileError(error)) {
return undefined
}
throw error
}
}
async write(value: PersistedControllerState): Promise<void> {
await writeJsonFileAtomically(this.filePath, value)
}
}
/**
* The persisted shape deliberately excludes SSH targets, credentials,
* operation payloads/results, event payloads, capabilities, and tokens.
*/
export class ControllerStateStore {
readonly #file: ControllerStateFile
#state?: PersistedControllerState
#load?: Promise<PersistedControllerState>
#updates: Promise<void> = Promise.resolve()
#acpBindingWriteTimer?: ReturnType<typeof setTimeout>
#acpBindingsDirty = false
#disposed = false
constructor(file: string | ControllerStateFile) {
this.#file =
typeof file === 'string' ? new JsonControllerStateFile(file) : file
}
async getControllerId(): Promise<string> {
return (await this.#ensureLoaded()).controllerId
}
async getConnection(
cacheKey: string
): Promise<ControllerConnectionState | undefined> {
assertCacheKey(cacheKey)
const state = await this.#ensureLoaded()
const connection = state.connections.find(
(candidate) => candidate.cacheKey === cacheKey
)
return connection === undefined
? undefined
: structuredClone(connection)
}
updateConnection(connection: ControllerConnectionState): Promise<void> {
const parsed = connectionStateSchema.parse(connection)
return this.#queue(async () => {
const state = structuredClone(await this.#ensureLoaded())
const index = state.connections.findIndex(
(candidate) => candidate.cacheKey === parsed.cacheKey
)
if (index >= 0) {
state.connections[index] = parsed
} else {
if (state.connections.length >= MAXIMUM_CONNECTION_RECORDS) {
throw new Error('Controller connection state limit reached')
}
state.connections.push(parsed)
}
await this.#writeState(state)
})
}
updateConnectionPreservingRecovery(
connection: ControllerConnectionState
): Promise<void> {
const parsed = connectionStateSchema.parse(connection)
return this.#queue(async () => {
const state = structuredClone(await this.#ensureLoaded())
const index = state.connections.findIndex(
(candidate) => candidate.cacheKey === parsed.cacheKey
)
if (index < 0) {
throw new Error('Controller connection state is unavailable')
}
const current = state.connections[index]!
state.connections[index] = connectionStateSchema.parse({
...parsed,
acpBindings: current.acpBindings
})
await this.#writeState(state)
})
}
updateAcpBinding(
cacheKey: string,
bindingId: string,
binding: ControllerAcpRecoveryBinding | undefined
): Promise<void> {
assertCacheKey(cacheKey)
const parsedBindingId = agentIdentifierSchema.parse(bindingId)
const parsed =
binding === undefined
? undefined
: acpRecoveryBindingSchema.parse(binding)
if (parsed !== undefined && parsed.bindingId !== parsedBindingId) {
throw new Error('Controller ACP recovery binding identity mismatch')
}
return this.#queue(async () => {
const state = await this.#ensureLoaded()
const connection = state.connections.find(
(candidate) => candidate.cacheKey === cacheKey
)
if (connection === undefined) {
throw new Error('Controller connection state is unavailable')
}
const previous = connection.acpBindings.find(
(candidate) => candidate.bindingId === parsedBindingId
)
if (
parsed !== undefined &&
previous !== undefined &&
(
parsed.channelId !== previous.channelId ||
parsed.channelEpoch !== previous.channelEpoch ||
Object.keys(parsed.cursors).some(
(key) =>
BigInt(
parsed.cursors[
key as keyof typeof parsed.cursors
]
) <
BigInt(
previous.cursors[
key as keyof typeof previous.cursors
]
)
)
)
) {
throw new Error(
'Controller ACP recovery identity or cursors cannot regress'
)
}
connection.acpBindings = connection.acpBindings.filter(
(candidate) => candidate.bindingId !== parsedBindingId
)
if (parsed !== undefined) {
if (connection.acpBindings.length >= MAXIMUM_ACP_BINDINGS) {
throw new Error('Controller ACP recovery state limit reached')
}
connection.acpBindings.push(parsed)
}
this.#acpBindingsDirty = true
this.#scheduleAcpBindingWrite()
})
}
removeConnection(cacheKey: string): Promise<void> {
assertCacheKey(cacheKey)
return this.#queue(async () => {
const state = structuredClone(await this.#ensureLoaded())
const next = state.connections.filter(
(connection) => connection.cacheKey !== cacheKey
)
if (next.length === state.connections.length) {
return
}
state.connections = next
await this.#writeState(state)
})
}
invalidateHost(hostId: string): Promise<void> {
const parsedHostId = agentIdentifierSchema.parse(hostId)
return this.#queue(async () => {
const state = structuredClone(await this.#ensureLoaded())
const next = state.connections.filter(
(connection) => connection.hostId !== parsedHostId
)
if (next.length === state.connections.length) {
return
}
state.connections = next
await this.#writeState(state)
})
}
async flush(): Promise<void> {
await this.#enqueue(async () => {
await this.#writePendingAcpBindings()
})
}
dispose(): void {
if (this.#acpBindingsDirty) {
clearTimeout(this.#acpBindingWriteTimer)
this.#acpBindingWriteTimer = undefined
void this.#enqueue(async () => {
await this.#writePendingAcpBindings()
}).catch(() => undefined)
}
this.#disposed = true
}
async #ensureLoaded(): Promise<PersistedControllerState> {
if (this.#state !== undefined) {
return this.#state
}
if (this.#load !== undefined) {
return await this.#load
}
this.#load = this.#loadState()
try {
return await this.#load
} finally {
this.#load = undefined
}
}
async #loadState(): Promise<PersistedControllerState> {
const value = await this.#file.read()
const state =
value === undefined
? {
version: 1 as const,
controllerId: randomUUID(),
connections: []
}
: persistedControllerStateReadSchema.parse(value)
if (value === undefined) {
await this.#file.write(state)
}
this.#state = state
return state
}
#queue<T>(operation: () => Promise<T>): Promise<T> {
if (this.#disposed) {
return Promise.reject(new Error('Controller state store is closed'))
}
return this.#enqueue(operation)
}
#enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.#updates.then(operation, operation)
this.#updates = result.then(
() => undefined,
() => undefined
)
return result
}
#scheduleAcpBindingWrite(): void {
if (this.#acpBindingWriteTimer !== undefined) {
return
}
this.#acpBindingWriteTimer = setTimeout(() => {
this.#acpBindingWriteTimer = undefined
void this.#queue(async () => {
await this.#writePendingAcpBindings()
}).catch(() => undefined)
}, ACP_BINDING_WRITE_DELAY_MS)
}
async #writePendingAcpBindings(): Promise<void> {
if (!this.#acpBindingsDirty) {
return
}
const state = await this.#ensureLoaded()
await this.#file.write(state)
this.#acpBindingsDirty = false
}
async #writeState(state: PersistedControllerState): Promise<void> {
await this.#file.write(state)
this.#state = state
this.#acpBindingsDirty = false
if (this.#acpBindingWriteTimer !== undefined) {
clearTimeout(this.#acpBindingWriteTimer)
this.#acpBindingWriteTimer = undefined
}
}
}
function assertCacheKey(value: string): void {
if (!/^[a-f0-9]{64}$/u.test(value)) {
throw new Error('Invalid controller state cache key')
}
}
+97
View File
@@ -0,0 +1,97 @@
export {
agentManifestSignaturePayload,
canonicalAgentManifestBytes,
readAgentReleaseKeyRegistry,
readAgentRuntimeLock,
verifyAgentBundleDirectory,
verifyAgentManifestSignature,
verifyBundledAgentResources
} from './agent-bundle-verifier'
export type {
AgentVerificationEnvironment,
VerifiedAgentBundle,
VerifyAgentBundleOptions,
VerifyBundledAgentOptions
} from './agent-bundle-verifier'
export {
getBundledAgentDirectory,
resolveBundledAgentResourcePaths
} from './bundled-agent-resources'
export type {
BundledAgentResourceEnvironment,
BundledAgentResourcePaths
} from './bundled-agent-resources'
export {
AgentAttachTransport,
AgentAttachTransportError
} from './agent-attach-transport'
export type { AgentAttachTransportOptions } from './agent-attach-transport'
export {
AGENT_PROTOCOL_METHODS,
AgentProtocolBinaryChannel,
AgentProtocolClient,
AgentProtocolClientError,
AgentRpcError
} from './agent-protocol-client'
export type {
AgentConsumedFrame,
AgentProtocolMethod,
AgentProtocolParams,
AgentProtocolRequestOptions,
AgentProtocolResult
} from './agent-protocol-client'
export {
ControllerStateStore,
JsonControllerStateFile
} from './controller-state-store'
export type {
ControllerConnectionState,
ControllerStateFile,
PersistedControllerState
} from './controller-state-store'
export {
RemoteAgentConnectionError,
RemoteAgentConnectionManager
} from './remote-agent-connection-manager'
export type {
RemoteAgentConnection,
RemoteAgentConnectionIdentity,
RemoteAgentConnectionState,
RemoteAgentInstallationIdentity,
RemoteAgentSshPool,
RemoteAgentTargetResolver
} from './remote-agent-connection-manager'
export {
ProtocolRemoteWorkspaceTransport,
ProtocolRemoteWorkspaceTransportError
} from './protocol-remote-workspace-transport'
export type {
DurableRemoteWorkspaceValidation,
RemoteWorkspaceInstallationIdentityResolver,
RemoteWorkspaceInstallationResolution
} from './protocol-remote-workspace-transport'
export {
AgentInstallationError,
AgentInstallationManager
} from './agent-installation-manager'
export type {
AgentInstallationBundleLoader,
AgentInstallationIdentity,
AgentInstallationPhase,
AgentInstallationRequestOptions,
AgentInstallationTargetResolver
} from './agent-installation-manager'
export { RemoteAgentServices } from './remote-agent-services'
export type { RemoteAgentServicesOptions } from './remote-agent-services'
export {
RemoteProjectSaveService,
UnavailableRemoteProjectRuntimeValidator
} from './remote-project-save-service'
export type {
RemoteProjectSaveOwner,
RemoteProjectSaveServiceOptions,
RemoteProjectRuntimeValidationEvidence,
RemoteProjectRuntimeValidationInput,
RemoteProjectRuntimeValidationLease,
RemoteProjectRuntimeValidator
} from './remote-project-save-service'
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import {
MainModelBridgeDispatchError,
dispatchFailureCode,
dispatchFailureIsUncertain,
validateMainModelBridgeDispatchResponse
} from './main-model-bridge-dispatcher'
describe('Main model bridge dispatcher boundary', () => {
it('treats unclassified dispatch failures as outcome-unknown', () => {
expect(dispatchFailureIsUncertain(new Error('secret'))).toBe(true)
expect(dispatchFailureCode(new Error('secret'))).toBe(
'dispatch-failed'
)
})
it('permits only an explicit pre-dispatch failure to stay definitive', () => {
const error = new MainModelBridgeDispatchError(
'policy-rejected',
{ outcomeUnknown: false, postDispatch: false }
)
expect(dispatchFailureIsUncertain(error)).toBe(false)
expect(dispatchFailureCode(error)).toBe('policy-rejected')
expect(
dispatchFailureIsUncertain(
new MainModelBridgeDispatchError('network-failed', {
outcomeUnknown: true,
postDispatch: true
})
)
).toBe(true)
})
it('strictly validates gateway responses', () => {
expect(
validateMainModelBridgeDispatchResponse({
status: 200,
headers: {},
bodyBase64: ''
})
).toEqual({
status: 200,
headers: {},
bodyBase64: ''
})
expect(() =>
validateMainModelBridgeDispatchResponse({
status: 99,
headers: {},
bodyBase64: ''
})
).toThrow()
})
})
@@ -0,0 +1,85 @@
import type {
ModelBridgeIdentity,
ModelBridgePolicy
} from '../../shared/model-bridge-contracts'
import {
remoteModelGatewayResponseSchema,
type RemoteModelGatewayRequest,
type RemoteModelGatewayResponse
} from '../../shared/remote-model-gateway-contracts'
export type MainModelBridgeDispatchInput = {
identity: ModelBridgeIdentity
policy: ModelBridgePolicy
request: RemoteModelGatewayRequest
requestDigest: string
}
export type MainModelBridgeDispatch = (
input: MainModelBridgeDispatchInput,
signal: AbortSignal
) => Promise<RemoteModelGatewayResponse>
export type MainModelBridgeDelivered = (
identity: ModelBridgeIdentity,
requestDigest: string
) => void | Promise<void>
export type MainModelBridgeFinalizePrompt = (
identity: Pick<
ModelBridgeIdentity,
'bindingId' | 'promptOperationId'
>
) => void | Promise<void>
export type MainModelBridgePoison = (
bindingId: string,
promptOperationId: string,
reason: string
) => void | Promise<void>
/**
* Dispatch implementations may explicitly identify a failure that happened
* before any provider side effect. All unclassified dispatch failures are
* conservatively treated as outcome-unknown by the session.
*/
export class MainModelBridgeDispatchError extends Error {
constructor(
readonly code: string,
options: {
outcomeUnknown: boolean
postDispatch: boolean
cause?: unknown
}
) {
super('Remote model dispatch failed', {
cause: options.cause
})
this.name = 'MainModelBridgeDispatchError'
this.outcomeUnknown = options.outcomeUnknown
this.postDispatch = options.postDispatch
}
readonly outcomeUnknown: boolean
readonly postDispatch: boolean
}
export function validateMainModelBridgeDispatchResponse(
value: unknown
): RemoteModelGatewayResponse {
return remoteModelGatewayResponseSchema.parse(value)
}
export function dispatchFailureIsUncertain(error: unknown): boolean {
return !(
error instanceof MainModelBridgeDispatchError &&
!error.outcomeUnknown &&
!error.postDispatch
)
}
export function dispatchFailureCode(error: unknown): string {
return error instanceof MainModelBridgeDispatchError
? error.code
: 'dispatch-failed'
}
@@ -0,0 +1,572 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest'
import { AGENT_PROTOCOL_LIMITS } from '../../shared/agent-protocol'
import {
REMOTE_MODEL_GATEWAY_LIMITS
} from '../../shared/remote-model-gateway-contracts'
import {
createModelBridgeRequestMessage,
decodeModelBridgeMessage,
encodeModelBridgeMessage,
modelBridgeDeliveryAckMessageSchema,
type ModelBridgeIdentity,
type ModelBridgePolicy
} from '../../shared/model-bridge-contracts'
import type { RuntimeProtocolBinaryFrame } from './protocol-remote-runtime-channel'
import {
MainModelBridgeDispatchError
} from './main-model-bridge-dispatcher'
import {
MainModelBridgeSession
} from './main-model-bridge-session'
const profileDigest = `sha256:${'a'.repeat(64)}`
const policy: ModelBridgePolicy = {
protocol: 'openai-responses',
model: 'gpt-test',
modelProfileDigest: profileDigest,
supportsImageInput: false
}
class FakeBlobChannel {
readonly channelId = `model-${randomUUID()}`
readonly channelEpoch = '19'
readonly sent: Uint8Array[] = []
consumed = 0
closed = false
closeNotifications = 0
#frames: RuntimeProtocolBinaryFrame[] = []
#waiters: Array<{
resolve: (frame: RuntimeProtocolBinaryFrame) => void
reject: (error: unknown) => void
signal?: AbortSignal
abort?: () => void
}> = []
#listeners = new Set<() => void>()
send(payload: Uint8Array, signal?: AbortSignal): Promise<void> {
signal?.throwIfAborted()
this.sent.push(new Uint8Array(payload))
return Promise.resolve()
}
receive(signal?: AbortSignal): Promise<RuntimeProtocolBinaryFrame> {
signal?.throwIfAborted()
const frame = this.#frames.shift()
if (frame !== undefined) {
return Promise.resolve(frame)
}
return new Promise((resolve, reject) => {
const waiter = { resolve, reject, signal }
if (signal !== undefined) {
const abort = (): void => {
const index = this.#waiters.indexOf(waiter)
if (index >= 0) {
this.#waiters.splice(index, 1)
}
reject(signal.reason)
}
Object.assign(waiter, { abort })
signal.addEventListener('abort', abort, { once: true })
}
this.#waiters.push(waiter)
})
}
push(payload: Uint8Array): void {
let consumed = false
const frame: RuntimeProtocolBinaryFrame = {
payload: new Uint8Array(payload),
sequence: String(this.consumed + this.#frames.length + 1),
consume: async () => {
if (!consumed) {
consumed = true
this.consumed += 1
}
}
}
const waiter = this.#waiters.shift()
if (waiter === undefined) {
this.#frames.push(frame)
return
}
if (waiter.abort !== undefined) {
waiter.signal?.removeEventListener('abort', waiter.abort)
}
waiter.resolve(frame)
}
close(): void {
if (this.closed) {
return
}
this.closed = true
for (const waiter of this.#waiters.splice(0)) {
waiter.reject(new Error('transport closed'))
}
for (const listener of this.#listeners) {
listener()
}
}
async closeWithNotification(): Promise<void> {
this.closeNotifications += 1
this.close()
}
onClose(listener: () => void): () => void {
this.#listeners.add(listener)
return () => this.#listeners.delete(listener)
}
}
function identity(roundIndex: number): ModelBridgeIdentity {
return {
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
requestId: `request-${roundIndex}`,
roundIndex,
modelProfileDigest: profileDigest,
messageId: `message-${roundIndex}`
}
}
function request(bodyBytes = 1) {
return {
method: 'POST' as const,
path: '/v1/responses' as const,
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.alloc(bodyBytes, 7).toString('base64')
}
}
const response = {
status: 200,
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.from('{"ok":true}').toString('base64')
}
async function pushRequest(
channel: FakeBlobChannel,
roundIndex: number,
options: {
bodyBytes?: number
requestPolicy?: ModelBridgePolicy
} = {}
) {
const message = await createModelBridgeRequestMessage({
identity: {
...identity(roundIndex),
modelProfileDigest:
options.requestPolicy?.modelProfileDigest ?? profileDigest
},
policy: options.requestPolicy ?? policy,
request: request(options.bodyBytes)
})
const payload = await encodeModelBridgeMessage(message)
channel.push(payload)
return { message, payload }
}
async function sentMessage(
channel: FakeBlobChannel,
offset = 0
) {
await vi.waitFor(() =>
expect(channel.sent.length).toBeGreaterThan(offset)
)
return {
message: await decodeModelBridgeMessage(channel.sent[offset]!),
nextOffset: offset + 1
}
}
async function acknowledge(
channel: FakeBlobChannel,
requestIdentity: ModelBridgeIdentity,
requestDigest: string
): Promise<void> {
const acknowledgment = modelBridgeDeliveryAckMessageSchema.parse({
protocol: 'goodbuddy-model-bridge-v1',
kind: 'response-delivered',
identity: requestIdentity,
requestDigest
})
channel.push(await encodeModelBridgeMessage(acknowledgment))
}
function fixture(
overrides: Partial<
ConstructorParameters<typeof MainModelBridgeSession>[0]
> & { channel?: FakeBlobChannel } = {}
) {
const channel = overrides.channel ?? new FakeBlobChannel()
const dispatch = vi.fn(async () => response)
const onDelivered = vi.fn(async () => undefined)
const finalizePrompt = vi.fn(async () => undefined)
const poison = vi.fn(async () => undefined)
const session = new MainModelBridgeSession({
identity: {
bindingId: 'binding-1',
promptOperationId: 'prompt-1'
},
policy,
channel,
isCurrentGeneration: () => true,
dispatch,
onDelivered,
finalizePrompt,
poison,
requestTimeoutMs: 1_000,
closeTimeoutMs: 100,
...overrides
})
return {
channel,
dispatch,
onDelivered,
finalizePrompt,
poison,
session
}
}
describe('MainModelBridgeSession', () => {
it('reports provider uncertainty only after a model request begins', async () => {
const dispatch = vi.fn(
() => new Promise<typeof response>(() => undefined)
)
const bridge = fixture({ dispatch })
expect(
bridge.session.providerDeliveryMayBeUncertain
).toBe(false)
await pushRequest(bridge.channel, 0)
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
expect(
bridge.session.providerDeliveryMayBeUncertain
).toBe(true)
await bridge.session.close('prompt-cancelled')
})
it('accepts one bounded request message and records delivery', async () => {
const bridge = fixture()
const sent = await pushRequest(bridge.channel, 0, {
bodyBytes: 600 * 1024
})
expect(sent.payload.byteLength).toBeGreaterThan(600 * 1024)
expect(sent.payload.byteLength).toBeLessThanOrEqual(
AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes
)
const outgoing = await sentMessage(bridge.channel)
expect(outgoing.message).toMatchObject({
kind: 'response',
identity: sent.message.identity,
requestDigest: sent.message.requestDigest,
response
})
expect(bridge.channel.consumed).toBe(1)
await acknowledge(
bridge.channel,
sent.message.identity,
sent.message.requestDigest
)
await vi.waitFor(() =>
expect(bridge.onDelivered).toHaveBeenCalledOnce()
)
await expect(bridge.session.close()).resolves.toEqual({
clean: true,
poisoned: false
})
expect(bridge.finalizePrompt).toHaveBeenCalledOnce()
expect(bridge.poison).not.toHaveBeenCalled()
expect(bridge.channel.closeNotifications).toBe(1)
})
it('sends one bounded response message and records its delivery ACK', async () => {
const largeResponse = {
...response,
bodyBase64: Buffer.alloc(600 * 1024, 3).toString('base64')
}
const bridge = fixture({
dispatch: vi.fn(async () => largeResponse)
})
const sent = await pushRequest(bridge.channel, 0)
const outgoing = await sentMessage(bridge.channel)
expect(outgoing.nextOffset).toBe(1)
expect(outgoing.message).toMatchObject({
kind: 'response',
response: largeResponse
})
await acknowledge(
bridge.channel,
sent.message.identity,
sent.message.requestDigest
)
await vi.waitFor(() =>
expect(bridge.onDelivered).toHaveBeenCalledOnce()
)
await expect(bridge.session.close()).resolves.toEqual({
clean: true,
poisoned: false
})
})
it('delivers a maximum-sized response in one bounded message', async () => {
const channel = new FakeBlobChannel()
const largeResponse = {
...response,
bodyBase64: Buffer.alloc(
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes,
3
).toString('base64')
}
const bridge = fixture({
channel,
dispatch: vi.fn(async () => largeResponse),
requestTimeoutMs: 10_000
})
const requestMessage = await pushRequest(channel, 0)
const outgoing = await sentMessage(channel)
expect(outgoing.nextOffset).toBe(1)
expect(channel.sent).toHaveLength(1)
expect(channel.sent[0]!.byteLength).toBeLessThanOrEqual(
AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes
)
expect(outgoing.message).toMatchObject({
kind: 'response',
response: largeResponse
})
await acknowledge(
channel,
requestMessage.message.identity,
requestMessage.message.requestDigest
)
await vi.waitFor(() =>
expect(bridge.onDelivered).toHaveBeenCalledOnce()
)
expect(bridge.poison).not.toHaveBeenCalled()
await expect(bridge.session.close()).resolves.toEqual({
clean: true,
poisoned: false
})
})
it('reports prompt authority finalization failure as poisoned', async () => {
const bridge = fixture({
finalizePrompt: async () => {
throw new Error('SQLite unavailable')
}
})
await expect(bridge.session.close()).resolves.toEqual({
clean: false,
poisoned: true
})
expect(bridge.poison).toHaveBeenCalledWith(
'binding-1',
'prompt-1',
'model-prompt-finalization-failed'
)
})
it('reports prompt authority finalization timeout as poisoned', async () => {
const bridge = fixture({
finalizePrompt: async () => await new Promise(() => undefined),
closeTimeoutMs: 10
})
await expect(bridge.session.close()).resolves.toEqual({
clean: false,
poisoned: true
})
expect(bridge.poison).toHaveBeenCalledWith(
'binding-1',
'prompt-1',
'model-prompt-finalization-failed'
)
})
it('enforces exact policy before dispatch with a definitive error', async () => {
const bridge = fixture()
const mismatched = {
...policy,
model: 'another-model',
modelProfileDigest: `sha256:${'b'.repeat(64)}`
}
await pushRequest(bridge.channel, 0, {
requestPolicy: mismatched
})
const outgoing = await sentMessage(bridge.channel)
expect(outgoing.message).toMatchObject({
kind: 'error',
error: {
poisoned: false,
outcomeUnknown: false,
retryable: false
}
})
expect(bridge.dispatch).not.toHaveBeenCalled()
expect(bridge.poison).not.toHaveBeenCalled()
await bridge.session.close()
})
it('rejects an exact binding or prompt identity mismatch before dispatch', async () => {
const bridge = fixture()
const message = await createModelBridgeRequestMessage({
identity: {
...identity(0),
bindingId: 'different-binding'
},
policy,
request: request()
})
bridge.channel.push(await encodeModelBridgeMessage(message))
const outgoing = await sentMessage(bridge.channel)
expect(outgoing.message).toMatchObject({
kind: 'error',
error: {
code: 'request-identity-mismatch',
poisoned: false
}
})
expect(bridge.dispatch).not.toHaveBeenCalled()
expect(bridge.poison).not.toHaveBeenCalled()
await bridge.session.close()
})
it('supports sequential rounds only after each delivery ACK', async () => {
const bridge = fixture()
let offset = 0
for (let round = 0; round < 2; round += 1) {
const sent = await pushRequest(bridge.channel, round)
const outgoing = await sentMessage(bridge.channel, offset)
offset = outgoing.nextOffset
await acknowledge(
bridge.channel,
sent.message.identity,
sent.message.requestDigest
)
}
await vi.waitFor(() =>
expect(bridge.onDelivered).toHaveBeenCalledTimes(2)
)
expect(bridge.dispatch).toHaveBeenCalledTimes(2)
await bridge.session.close()
})
it('poisons once when another request arrives before delivery ACK', async () => {
const bridge = fixture()
await pushRequest(bridge.channel, 0)
await sentMessage(bridge.channel)
await pushRequest(bridge.channel, 1)
await expect(bridge.session.closed).resolves.toEqual({
clean: false,
poisoned: true
})
expect(bridge.dispatch).toHaveBeenCalledOnce()
expect(bridge.poison).toHaveBeenCalledOnce()
})
it('poisons once when transport closes after response and before ACK', async () => {
const bridge = fixture()
await pushRequest(bridge.channel, 0)
await sentMessage(bridge.channel)
bridge.channel.close()
await expect(bridge.session.closed).resolves.toEqual({
clean: false,
poisoned: true
})
expect(bridge.poison).toHaveBeenCalledOnce()
})
it('allows an in-flight delivery ACK to finish during clean prompt close', async () => {
const bridge = fixture()
const sent = await pushRequest(bridge.channel, 0)
await sentMessage(bridge.channel)
const closing = bridge.session.close('prompt-completed')
await acknowledge(
bridge.channel,
sent.message.identity,
sent.message.requestDigest
)
await expect(closing).resolves.toEqual({
clean: true,
poisoned: false
})
expect(bridge.onDelivered).toHaveBeenCalledOnce()
expect(bridge.poison).not.toHaveBeenCalled()
})
it('keeps an explicitly pre-dispatch failure definitive without consuming the round', async () => {
const definitive = new MainModelBridgeDispatchError(
'policy-rejected',
{ outcomeUnknown: false, postDispatch: false }
)
const dispatch = vi
.fn()
.mockRejectedValueOnce(definitive)
.mockResolvedValueOnce(response)
const bridge = fixture({ dispatch })
await pushRequest(bridge.channel, 0)
const first = await sentMessage(bridge.channel)
expect(first.message).toMatchObject({
kind: 'error',
error: { poisoned: false, outcomeUnknown: false }
})
const secondRequest = await pushRequest(bridge.channel, 0)
const second = await sentMessage(
bridge.channel,
first.nextOffset
)
expect(second.message.kind).toBe('response')
await acknowledge(
bridge.channel,
secondRequest.message.identity,
secondRequest.message.requestDigest
)
await vi.waitFor(() =>
expect(bridge.onDelivered).toHaveBeenCalledOnce()
)
expect(dispatch).toHaveBeenCalledTimes(2)
expect(bridge.poison).not.toHaveBeenCalled()
await bridge.session.close()
})
it('times out an uncooperative dispatch, poisons, and never retries', async () => {
const dispatch = vi.fn(
() => new Promise<typeof response>(() => undefined)
)
const bridge = fixture({
dispatch,
requestTimeoutMs: 10
})
await pushRequest(bridge.channel, 0)
await expect(bridge.session.closed).resolves.toEqual({
clean: false,
poisoned: true
})
expect(dispatch).toHaveBeenCalledOnce()
expect(bridge.poison).toHaveBeenCalledOnce()
})
it('cancels an active dispatch by poisoning once without retry', async () => {
const dispatch = vi.fn(
() => new Promise<typeof response>(() => undefined)
)
const bridge = fixture({ dispatch })
await pushRequest(bridge.channel, 0)
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce())
await expect(
bridge.session.close('prompt-cancelled')
).resolves.toEqual({
clean: false,
poisoned: true
})
expect(dispatch).toHaveBeenCalledOnce()
expect(bridge.poison).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,582 @@
import { isDeepStrictEqual } from 'node:util'
import {
MODEL_BRIDGE_LIMITS,
MODEL_BRIDGE_PROTOCOL,
decodeModelBridgeMessage,
encodeModelBridgeMessage,
modelBridgeErrorMessageSchema,
modelBridgePolicySchema,
modelBridgeResponseMessageSchema,
type ModelBridgeIdentity,
type ModelBridgePolicy,
type ModelBridgeRequestMessage
} from '../../shared/model-bridge-contracts'
import type { RuntimeProtocolBinaryChannel } from './protocol-remote-runtime-channel'
import {
dispatchFailureCode,
dispatchFailureIsUncertain,
validateMainModelBridgeDispatchResponse,
type MainModelBridgeDelivered,
type MainModelBridgeDispatch,
type MainModelBridgeFinalizePrompt,
type MainModelBridgePoison
} from './main-model-bridge-dispatcher'
const DEFAULT_REQUEST_TIMEOUT_MS = 180_000
const DEFAULT_CLOSE_TIMEOUT_MS = 2_000
export type MainModelBridgeSessionIdentity = {
bindingId: string
promptOperationId: string
}
export type MainModelBridgeSessionOptions = {
identity: MainModelBridgeSessionIdentity
policy: ModelBridgePolicy
channel: RuntimeProtocolBinaryChannel
isCurrentGeneration(): boolean
dispatch: MainModelBridgeDispatch
onDelivered: MainModelBridgeDelivered
finalizePrompt: MainModelBridgeFinalizePrompt
poison: MainModelBridgePoison
requestTimeoutMs?: number
closeTimeoutMs?: number
}
export type MainModelBridgeSessionCloseResult = {
clean: boolean
poisoned: boolean
}
type RequestPhase =
| 'receiving'
| 'dispatching'
| 'sending-response'
| 'awaiting-delivery'
type ActiveExchange = {
request: ModelBridgeRequestMessage
phase: RequestPhase
}
export class MainModelBridgeSession {
readonly version = MODEL_BRIDGE_PROTOCOL
readonly channelId: string
readonly channelEpoch: string
readonly policy: ModelBridgePolicy
readonly closed: Promise<MainModelBridgeSessionCloseResult>
readonly #identity: MainModelBridgeSessionIdentity
readonly #channel: RuntimeProtocolBinaryChannel
readonly #isCurrentGeneration: () => boolean
readonly #dispatch: MainModelBridgeDispatch
readonly #onDelivered: MainModelBridgeDelivered
readonly #finalizePrompt: MainModelBridgeFinalizePrompt
readonly #poisonCallback: MainModelBridgePoison
readonly #requestTimeoutMs: number
readonly #closeTimeoutMs: number
readonly #lifetime = new AbortController()
readonly #closedResolve: (
result: MainModelBridgeSessionCloseResult
) => void
#nextRoundIndex = 0
#active?: ActiveExchange
#receivingRequest = false
#poisoned = false
#closing = false
#closePromise?: Promise<MainModelBridgeSessionCloseResult>
#poisonPromise?: Promise<void>
#idleWaiters = new Set<() => void>()
#unsubscribeClose: () => void = () => undefined
constructor(options: MainModelBridgeSessionOptions) {
this.#identity = options.identity
this.policy = modelBridgePolicySchema.parse(options.policy)
this.#channel = options.channel
this.channelId = options.channel.channelId
this.channelEpoch = options.channel.channelEpoch
this.#isCurrentGeneration = options.isCurrentGeneration
this.#dispatch = options.dispatch
this.#onDelivered = options.onDelivered
this.#finalizePrompt = options.finalizePrompt
this.#poisonCallback = options.poison
this.#requestTimeoutMs = boundedTimeout(
options.requestTimeoutMs,
DEFAULT_REQUEST_TIMEOUT_MS
)
this.#closeTimeoutMs = boundedTimeout(
options.closeTimeoutMs,
DEFAULT_CLOSE_TIMEOUT_MS
)
let resolveClosed: (
result: MainModelBridgeSessionCloseResult
) => void = () => undefined
this.closed = new Promise((resolve) => {
resolveClosed = resolve
})
this.#closedResolve = resolveClosed
this.#unsubscribeClose = this.#channel.onClose(() => {
void this.#closeFromTransport()
})
void this.#run()
}
get preparation(): {
version: typeof MODEL_BRIDGE_PROTOCOL
channelId: string
channelEpoch: string
policy: ModelBridgePolicy
} {
return {
version: this.version,
channelId: this.channelId,
channelEpoch: this.channelEpoch,
policy: this.policy
}
}
get providerDeliveryMayBeUncertain(): boolean {
return this.#active !== undefined || this.#receivingRequest
}
async close(reason = 'prompt-completed'): Promise<MainModelBridgeSessionCloseResult> {
if (
reason === 'prompt-completed' &&
this.#active?.phase === 'awaiting-delivery'
) {
await this.#waitForIdle()
}
return await this.#beginClose(true, reason)
}
async #run(): Promise<void> {
try {
while (!this.#closing) {
this.#assertCurrent()
const message = await this.#receiveMessage()
if (message.kind !== 'request') {
throw new Error(
'Model bridge received a message without an active request'
)
}
const request = message
const preDispatchError = this.#validateRequest(request)
if (preDispatchError !== undefined) {
await this.#sendError(
request.identity,
request.requestDigest,
preDispatchError,
false
)
continue
}
this.#active = { request, phase: 'dispatching' }
const controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort(
new DOMException(
'Remote model bridge request timed out',
'TimeoutError'
)
)
}, this.#requestTimeoutMs)
timeout.unref?.()
const abort = (): void => controller.abort(this.#lifetime.signal.reason)
this.#lifetime.signal.addEventListener('abort', abort, {
once: true
})
try {
let response: unknown
try {
response = await raceWithAbort(
this.#dispatch(
{
identity: request.identity,
policy: request.policy,
request: request.request,
requestDigest: request.requestDigest
},
controller.signal
),
controller.signal
)
} catch (error) {
const uncertain =
controller.signal.aborted ||
dispatchFailureIsUncertain(error)
if (uncertain) {
await this.#poisonAndWait(
'model-dispatch-outcome-unknown'
)
}
await this.#sendError(
request.identity,
request.requestDigest,
dispatchFailureCode(error),
uncertain
)
this.#clearActive()
if (uncertain) {
await this.#beginClose(true, 'model-dispatch-outcome-unknown')
return
}
continue
}
let parsedResponse
try {
parsedResponse =
validateMainModelBridgeDispatchResponse(response)
} catch {
await this.#poisonAndWait('model-response-invalid')
await this.#sendError(
request.identity,
request.requestDigest,
'response-invalid',
true
)
await this.#beginClose(true, 'model-response-invalid')
return
}
this.#active.phase = 'sending-response'
const responseMessage = modelBridgeResponseMessageSchema.parse({
protocol: MODEL_BRIDGE_PROTOCOL,
kind: 'response',
identity: request.identity,
requestDigest: request.requestDigest,
response: parsedResponse
})
try {
await this.#sendMessage(responseMessage, controller.signal)
} catch {
await this.#poisonAndWait(
'model-response-send-failed'
)
await this.#beginClose(false, 'model-response-send-failed')
return
}
this.#active.phase = 'awaiting-delivery'
const acknowledgment = await this.#receiveMessage(
request.identity,
request.requestDigest
)
if (acknowledgment.kind !== 'response-delivered') {
await this.#poisonAndWait(
'model-delivery-ack-invalid'
)
await this.#beginClose(true, 'model-delivery-ack-invalid')
return
}
await raceWithAbort(
Promise.resolve(
this.#onDelivered(
request.identity,
request.requestDigest
)
),
controller.signal
)
this.#nextRoundIndex += 1
this.#clearActive()
} finally {
clearTimeout(timeout)
this.#lifetime.signal.removeEventListener('abort', abort)
}
}
} catch {
if (!this.#closing) {
if (this.#active !== undefined) {
await this.#poisonAndWait(
this.#active.phase === 'awaiting-delivery'
? 'model-delivery-ack-missing'
: 'model-bridge-transport-lost'
)
}
await this.#beginClose(false, 'model-bridge-transport-lost')
}
}
}
async #receiveMessage(
expectedIdentity?: ModelBridgeIdentity,
expectedRequestDigest?: string
) {
const frame = await this.#channel.receive(this.#lifetime.signal)
if (expectedIdentity === undefined) {
this.#receivingRequest = true
}
try {
this.#assertCurrent()
const message = await decodeModelBridgeMessage(frame.payload, {
expectedIdentity,
expectedRequestDigest,
maximumMessageBytes: MODEL_BRIDGE_LIMITS.maximumMessageBytes
})
await frame.consume()
if (expectedIdentity === undefined) {
this.#receivingRequest = false
}
return message
} catch (error) {
await frame.consume().catch(() => undefined)
throw error
}
}
#validateRequest(request: ModelBridgeRequestMessage): string | undefined {
if (
request.identity.bindingId !== this.#identity.bindingId ||
request.identity.promptOperationId !==
this.#identity.promptOperationId ||
request.identity.roundIndex !== this.#nextRoundIndex ||
request.identity.modelProfileDigest !==
this.policy.modelProfileDigest ||
!isDeepStrictEqual(request.policy, this.policy)
) {
return 'request-identity-mismatch'
}
return undefined
}
async #sendError(
identity: ModelBridgeIdentity,
requestDigest: string,
code: string,
uncertain: boolean
): Promise<void> {
const message = modelBridgeErrorMessageSchema.parse({
protocol: MODEL_BRIDGE_PROTOCOL,
kind: 'error',
identity,
requestDigest,
error: {
code: safeErrorCode(code),
message: uncertain
? 'Remote model request outcome is unknown'
: 'Remote model request was rejected',
retryable: false,
poisoned: uncertain,
outcomeUnknown: uncertain
}
})
await this.#sendMessage(message, this.#lifetime.signal)
}
async #sendMessage(
message: unknown,
signal: AbortSignal
): Promise<void> {
const payload = await encodeModelBridgeMessage(message)
this.#assertCurrent()
await this.#channel.send(payload, signal)
}
#assertCurrent(): void {
if (
this.#closing ||
this.#lifetime.signal.aborted ||
!this.#isCurrentGeneration()
) {
throw new Error('Model bridge transport generation is stale')
}
}
#poison(reason: string): Promise<void> {
if (this.#poisonPromise !== undefined) {
return this.#poisonPromise
}
this.#poisoned = true
this.#poisonPromise = Promise.resolve()
.then(() =>
this.#poisonCallback(
this.#identity.bindingId,
this.#identity.promptOperationId,
reason
)
)
.then(() => undefined)
.catch(() => undefined)
return this.#poisonPromise
}
async #poisonAndWait(reason: string): Promise<void> {
await settleWithin(
this.#poison(reason),
this.#closeTimeoutMs
).catch(() => undefined)
}
#closeFromTransport(): Promise<MainModelBridgeSessionCloseResult> {
return this.#beginClose(false, 'model-bridge-transport-lost')
}
#beginClose(
notifyPeer: boolean,
reason: string
): Promise<MainModelBridgeSessionCloseResult> {
if (this.#closePromise !== undefined) {
return this.#closePromise
}
this.#closing = true
if (this.#active !== undefined || this.#receivingRequest) {
void this.#poison(reason)
}
this.#lifetime.abort(
new DOMException('Model bridge closed', 'AbortError')
)
this.#closePromise = Promise.resolve().then(async () => {
try {
if (
!this.#poisoned &&
this.#active === undefined &&
!this.#receivingRequest
) {
try {
await settleWithin(
Promise.resolve(
this.#finalizePrompt(this.#identity)
),
this.#closeTimeoutMs
)
} catch {
await this.#poisonAndWait(
'model-prompt-finalization-failed'
)
}
}
if (notifyPeer) {
const closable = this.#channel as RuntimeProtocolBinaryChannel & {
closeWithNotification?: () => Promise<void>
}
if (closable.closeWithNotification !== undefined) {
await settleWithin(
closable.closeWithNotification(),
this.#closeTimeoutMs
)
} else {
this.#channel.close()
}
} else {
this.#channel.close()
}
} catch {
this.#channel.close()
if (this.#active !== undefined) {
await this.#poisonAndWait('model-bridge-close-failed')
}
}
if (this.#poisonPromise !== undefined) {
await settleWithin(
this.#poisonPromise,
this.#closeTimeoutMs
).catch(() => undefined)
}
this.#unsubscribeClose()
const result = {
clean: !this.#poisoned,
poisoned: this.#poisoned
}
this.#closedResolve(result)
return result
})
return this.#closePromise
}
#clearActive(): void {
this.#active = undefined
for (const resolve of this.#idleWaiters) {
resolve()
}
this.#idleWaiters.clear()
}
async #waitForIdle(): Promise<void> {
if (this.#active === undefined) {
return
}
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
new Promise<void>((resolve) => {
this.#idleWaiters.add(resolve)
}),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, this.#closeTimeoutMs)
timer.unref?.()
})
])
} finally {
if (timer !== undefined) {
clearTimeout(timer)
}
this.#idleWaiters.clear()
}
}
}
function safeErrorCode(value: string): string {
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value)
? value
: 'dispatch-failed'
}
function boundedTimeout(
value: number | undefined,
fallback: number
): number {
const timeout = value ?? fallback
if (
!Number.isSafeInteger(timeout) ||
timeout < 1 ||
timeout > 300_000
) {
throw new RangeError('Model bridge timeout is invalid')
}
return timeout
}
async function settleWithin(
promise: Promise<void>,
timeoutMs: number
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error('Model bridge close timed out')),
timeoutMs
)
timer.unref?.()
})
])
} finally {
if (timer !== undefined) {
clearTimeout(timer)
}
}
}
async function raceWithAbort<T>(
promise: Promise<T>,
signal: AbortSignal
): Promise<T> {
signal.throwIfAborted()
let abort = (): void => undefined
const aborted = new Promise<never>((_, reject) => {
abort = (): void => {
reject(
signal.reason ??
new DOMException('Model bridge aborted', 'AbortError')
)
}
signal.addEventListener('abort', abort, { once: true })
})
try {
return await Promise.race([promise, aborted])
} finally {
signal.removeEventListener('abort', abort)
}
}
@@ -0,0 +1,330 @@
import { describe, expect, it, vi } from 'vitest'
import {
runtimeSessionBindingSchema,
type RuntimeSessionBinding
} from '../../shared/remote-agent-contracts'
import type { RemoteModelGateway } from '../agent/remote-model-gateway'
import { RemoteModelGatewayError } from '../agent/remote-model-gateway'
import { ModelCallCapacityError } from '../agent/model-call-operation-store'
import { MemoryRuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import {
createManagedModelBridge,
reconcileStartupModelCalls
} from './managed-model-bridge'
import { MainModelBridgeDispatchError } from './main-model-bridge-dispatcher'
const digest = (character: string): string =>
`sha256:${character.repeat(64)}`
const profile: ResolvedModelProfile = {
id: '00000000-0000-4000-8000-000000000001',
name: 'Private profile',
baseUrl: 'https://provider.example/v1',
modelName: 'private-model',
protocol: 'openai-responses',
authentication: 'api-key',
supportsImageInput: false,
apiKey: 'main-only-secret'
}
const response = {
status: 200,
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.from('{}').toString('base64')
}
function bridgeFixture(options?: {
bindingStore?: MemoryRuntimeSessionBindingStore
}) {
const bindingStore =
options?.bindingStore ??
new MemoryRuntimeSessionBindingStore()
const dispatch = vi.fn(async () => response)
const markResponseDelivered = vi.fn()
const finalizePrompt = vi.fn()
const gateway = {
dispatch,
markResponseDelivered,
finalizePrompt
} as unknown as RemoteModelGateway
const bridge = createManagedModelBridge({
profile,
gateway,
bindingStore
})
return {
bindingStore,
dispatch,
markResponseDelivered,
finalizePrompt,
bridge
}
}
function binding(
policy: ReturnType<typeof bridgeFixture>['bridge']['policy'],
state: RuntimeSessionBinding['state'] = 'prompt-running'
): RuntimeSessionBinding {
return runtimeSessionBindingSchema.parse({
bindingId: 'binding-1',
controllerId: 'controller-1',
controllerGeneration: 2,
conversationId: 'conversation-1',
hostId: 'host-1',
hostRevision: 1,
hostKeyGeneration: 1,
workspaceIdentity: 'workspace-1',
agentInstallationId: 'agent-1',
daemonBootIdAtOpen: 'boot-1',
runtimeId: 'opencode',
runtimeBundleDigest: digest('a'),
runtimeAdapterDigest: digest('b'),
modelBridgeVersion: 'goodbuddy-model-bridge-v1',
modelBridgePolicy: policy,
acpSessionId: 'session-1',
acpCapabilitiesDigest: digest('c'),
state,
activePromptOperationId:
state === 'prompt-running' || state === 'outcome-unknown'
? 'prompt-1'
: undefined,
channelEpoch: '1',
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
})
}
function dispatchInput(
policy: ReturnType<typeof bridgeFixture>['bridge']['policy']
) {
return {
identity: {
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
requestId: 'local-model-request-1',
roundIndex: 0,
modelProfileDigest: policy.modelProfileDigest,
messageId: 'message-1'
},
policy,
request: {
method: 'POST' as const,
path: '/v1/responses' as const,
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.from(
JSON.stringify({ model: 'private-model' })
).toString('base64')
},
requestDigest: digest('e')
}
}
describe('managed model bridge composition', () => {
it('does not impose call-count or token quotas', () => {
const fixture = bridgeFixture()
expect(fixture.bridge.policy).toEqual({
protocol: 'openai-responses',
model: 'private-model',
modelProfileDigest: expect.stringMatching(
/^sha256:[a-f0-9]{64}$/u
),
supportsImageInput: false
})
})
it('keeps credentials in Main and maps exact dispatch plus delivery identity', async () => {
const fixture = bridgeFixture()
await fixture.bindingStore.put(binding(fixture.bridge.policy))
const input = dispatchInput(fixture.bridge.policy)
await expect(
fixture.bridge.channel.dispatch(
input,
new AbortController().signal
)
).resolves.toEqual(response)
expect(fixture.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 'local-model-request-1',
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0,
roundIndex: 0,
modelProfile: expect.objectContaining({
apiKey: 'main-only-secret'
})
}),
input.request,
expect.any(AbortSignal)
)
expect(JSON.stringify(fixture.bridge.policy)).not.toContain(
'main-only-secret'
)
expect(JSON.stringify(fixture.bridge.policy)).not.toContain(
'provider.example'
)
await fixture.bridge.channel.onDelivered(
input.identity,
input.requestDigest
)
expect(fixture.markResponseDelivered).toHaveBeenCalledWith({
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
roundIndex: 0
})
await fixture.bridge.channel.finalizePrompt(input.identity)
expect(fixture.finalizePrompt).toHaveBeenCalledWith({
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0
})
})
it('classifies only known pre-dispatch gateway failures as definitive', async () => {
const fixture = bridgeFixture()
await fixture.bindingStore.put(binding(fixture.bridge.policy))
vi.mocked(fixture.dispatch).mockRejectedValueOnce(
new RemoteModelGatewayError(
'request-policy-mismatch',
'safe failure'
)
)
await expect(
fixture.bridge.channel.dispatch(
dispatchInput(fixture.bridge.policy),
new AbortController().signal
)
).rejects.toMatchObject({
code: 'request-policy-mismatch',
outcomeUnknown: false,
postDispatch: false
})
vi.mocked(fixture.dispatch).mockRejectedValueOnce(
new ModelCallCapacityError()
)
await expect(
fixture.bridge.channel.dispatch(
dispatchInput(fixture.bridge.policy),
new AbortController().signal
)
).rejects.toMatchObject({
code: 'model-ledger-capacity',
outcomeUnknown: false,
postDispatch: false
})
vi.mocked(fixture.dispatch).mockRejectedValueOnce(
new RemoteModelGatewayError(
'network-error',
'must not escape'
)
)
await expect(
fixture.bridge.channel.dispatch(
dispatchInput(fixture.bridge.policy),
new AbortController().signal
)
).rejects.toBeInstanceOf(MainModelBridgeDispatchError)
await expect(
fixture.bridge.channel.dispatch(
{
...dispatchInput(fixture.bridge.policy),
policy: {
...fixture.bridge.policy,
model: 'different-model'
}
},
new AbortController().signal
)
).rejects.toMatchObject({
code: 'model-policy-mismatch',
outcomeUnknown: false
})
})
it('durably poisons the exact active binding', async () => {
const fixture = bridgeFixture()
await fixture.bindingStore.put(binding(fixture.bridge.policy))
await fixture.bridge.channel.poison(
'binding-1',
'prompt-1',
'delivery-unknown'
)
await expect(
fixture.bindingStore.getById('binding-1')
).resolves.toMatchObject({ state: 'outcome-unknown' })
})
it('paginates startup uncertainty and poisons only matching live prompts', async () => {
const fixture = bridgeFixture()
await fixture.bindingStore.put(binding(fixture.bridge.policy))
const firstCursor = {
updatedAt: 10,
callOperationId: 'call-1'
}
const listStartupRecords = vi
.fn()
.mockReturnValueOnce({
records: [
{
status: 'completed',
identity: {
bindingId: 'binding-1',
promptOperationId: 'prompt-1'
}
}
],
nextCursor: firstCursor
})
.mockReturnValueOnce({ records: [] })
await reconcileStartupModelCalls({
gatewayStore: { listStartupRecords },
bindingStore: fixture.bindingStore
})
expect(listStartupRecords).toHaveBeenNthCalledWith(2, {
cursor: firstCursor,
limit: 100
})
await expect(
fixture.bindingStore.getById('binding-1')
).resolves.toMatchObject({ state: 'outcome-unknown' })
})
it('marks a prepared but undispatched startup call as interrupted', async () => {
const fixture = bridgeFixture()
await fixture.bindingStore.put(binding(fixture.bridge.policy))
await reconcileStartupModelCalls({
gatewayStore: {
listStartupRecords: () => ({
records: [
{
status: 'prepared',
identity: {
bindingId: 'binding-1',
promptOperationId: 'prompt-1'
}
}
]
})
},
bindingStore: fixture.bindingStore
})
await expect(
fixture.bindingStore.getById('binding-1')
).resolves.toMatchObject({
state: 'interrupted',
activePromptOperationId: undefined
})
})
})
@@ -0,0 +1,247 @@
import { isDeepStrictEqual } from 'node:util'
import {
modelBridgePolicySchema,
type ModelBridgePolicy
} from '../../shared/model-bridge-contracts'
import { runtimeSessionBindingSchema } from '../../shared/remote-agent-contracts'
import {
RemoteModelGateway,
RemoteModelGatewayError,
createResolvedModelProfileDigest
} from '../agent/remote-model-gateway'
import { ModelCallCapacityError } from '../agent/model-call-operation-store'
import type { RuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import {
MainModelBridgeDispatchError,
type MainModelBridgeDispatchInput
} from './main-model-bridge-dispatcher'
import type { ProtocolRemoteRuntimeChannelOptions } from './protocol-remote-runtime-channel'
const PRE_DISPATCH_GATEWAY_ERRORS = new Set([
'invalid-provider-endpoint',
'missing-provider-credential',
'model-profile-digest-mismatch',
'protocol-path-mismatch',
'request-policy-mismatch'
])
export type ManagedModelBridge = {
policy: ModelBridgePolicy
channel: NonNullable<ProtocolRemoteRuntimeChannelOptions['modelBridge']>
}
export function createManagedModelBridge(options: {
profile: ResolvedModelProfile
gateway: RemoteModelGateway
bindingStore: RuntimeSessionBindingStore
}): ManagedModelBridge {
const profile = structuredClone(options.profile)
if (
profile.protocol === 'openai-images-generations' ||
(profile.authentication === 'api-key' && !profile.apiKey)
) {
throw new Error(
'Managed remote OpenCode requires a usable text model profile'
)
}
const policy = modelBridgePolicySchema.parse({
protocol: profile.protocol,
model: profile.modelName,
modelProfileDigest: createResolvedModelProfileDigest(profile),
supportsImageInput: profile.supportsImageInput === true
})
const assertActiveBinding = async (
input: Pick<
MainModelBridgeDispatchInput['identity'],
'bindingId' | 'promptOperationId'
>
) => {
const binding = await options.bindingStore.getById(input.bindingId)
if (
binding === undefined ||
binding.state !== 'prompt-running' ||
binding.activePromptOperationId !== input.promptOperationId ||
binding.modelBridgeVersion !== 'goodbuddy-model-bridge-v1' ||
!isDeepStrictEqual(binding.modelBridgePolicy, policy)
) {
throw new MainModelBridgeDispatchError(
'binding-policy-mismatch',
{ outcomeUnknown: false, postDispatch: false }
)
}
return binding
}
return {
policy,
channel: {
dispatch: async (input, signal) => {
const binding = await assertActiveBinding(input.identity)
if (!isDeepStrictEqual(input.policy, policy)) {
throw new MainModelBridgeDispatchError(
'model-policy-mismatch',
{ outcomeUnknown: false, postDispatch: false }
)
}
try {
return await options.gateway.dispatch(
{
requestId: input.identity.requestId,
bindingId: input.identity.bindingId,
promptOperationId:
input.identity.promptOperationId,
promptSequence: binding.promptSequence,
roundIndex: input.identity.roundIndex,
modelProfileDigest: policy.modelProfileDigest,
modelProfile: profile
},
input.request,
signal
)
} catch (error) {
if (
error instanceof MainModelBridgeDispatchError
) {
throw error
}
if (
error instanceof RemoteModelGatewayError &&
PRE_DISPATCH_GATEWAY_ERRORS.has(error.code)
) {
throw new MainModelBridgeDispatchError(error.code, {
outcomeUnknown: false,
postDispatch: false,
cause: error
})
}
if (error instanceof ModelCallCapacityError) {
throw new MainModelBridgeDispatchError(
'model-ledger-capacity',
{
outcomeUnknown: false,
postDispatch: false,
cause: error
}
)
}
throw new MainModelBridgeDispatchError(
error instanceof RemoteModelGatewayError
? error.code
: 'dispatch-failed',
{
outcomeUnknown: true,
postDispatch: true,
cause: error
}
)
}
},
onDelivered: async (identity) => {
await assertActiveBinding(identity)
options.gateway.markResponseDelivered({
bindingId: identity.bindingId,
promptOperationId: identity.promptOperationId,
roundIndex: identity.roundIndex
})
},
finalizePrompt: async (identity) => {
const binding = await assertActiveBinding(identity)
options.gateway.finalizePrompt({
bindingId: identity.bindingId,
promptOperationId: identity.promptOperationId,
promptSequence: binding.promptSequence
})
},
poison: async (
bindingId,
promptOperationId
) => {
const binding =
await options.bindingStore.getById(bindingId)
if (
binding === undefined ||
binding.state === 'closed' ||
binding.state === 'outcome-unknown' ||
binding.activePromptOperationId !== promptOperationId
) {
return
}
await options.bindingStore.put(
runtimeSessionBindingSchema.parse({
...binding,
state: 'outcome-unknown'
})
)
}
}
}
}
export async function reconcileStartupModelCalls(options: {
gatewayStore: {
listStartupRecords(input?: {
cursor?: {
updatedAt: number
callOperationId: string
}
limit?: number
}): {
records: Array<{
status:
| 'prepared'
| 'dispatched'
| 'completed'
| 'failed-definitive'
| 'outcome-unknown'
identity: {
bindingId: string
promptOperationId: string
}
}>
nextCursor?: {
updatedAt: number
callOperationId: string
}
}
}
bindingStore: RuntimeSessionBindingStore
}): Promise<void> {
let cursor:
| { updatedAt: number; callOperationId: string }
| undefined
do {
const page = options.gatewayStore.listStartupRecords({
...(cursor === undefined ? {} : { cursor }),
limit: 100
})
for (const record of page.records) {
const binding = await options.bindingStore.getById(
record.identity.bindingId
)
if (
binding === undefined ||
binding.state === 'closed' ||
binding.state === 'outcome-unknown' ||
binding.activePromptOperationId !==
record.identity.promptOperationId
) {
continue
}
await options.bindingStore.put(
runtimeSessionBindingSchema.parse({
...binding,
state:
record.status === 'prepared'
? 'interrupted'
: 'outcome-unknown',
...(record.status === 'prepared'
? { activePromptOperationId: undefined }
: {})
})
)
}
cursor = page.nextCursor
} while (cursor !== undefined)
}
@@ -0,0 +1,485 @@
import { describe, expect, it, vi } from 'vitest'
const remoteHarness = vi.hoisted(() => ({
instances: [] as Array<{
options: {
identity: {
controllerGeneration: number
daemonBootIdAtOpen: string
}
channelFactory: (bindingId: string) => Promise<unknown>
}
dispose: ReturnType<typeof vi.fn>
runCalls: number
}>,
runGate: undefined as Promise<void> | undefined
}))
vi.mock('../agent/acp-remote-runtime', () => ({
AcpRemoteRuntime: class {
readonly requiresToolApproval = false
readonly supportsScopedDataTools = false
readonly capability = 'chat'
readonly runtimeId = 'opencode'
readonly supportsToolExecution = true
readonly dispose = vi.fn(async () => undefined)
runCalls = 0
constructor(
readonly options: {
identity: {
controllerGeneration: number
daemonBootIdAtOpen: string
}
channelFactory: (bindingId: string) => Promise<unknown>
}
) {
remoteHarness.instances.push(this)
}
async getStatus() {
return {
id: 'opencode',
label: 'OpenCode(远程托管)',
available: true
}
}
async *run(request: { requestId: string }) {
this.runCalls += 1
yield {
requestId: request.requestId,
type: 'status',
message: 'running'
}
await remoteHarness.runGate
yield {
requestId: request.requestId,
type: 'done',
sessionId: 'session-1'
}
}
async releaseConversation() {}
async beginDrain() {}
async waitForDrain() {}
async forceShutdown() {
await this.dispose()
}
}
}))
import {
MemoryRuntimeSessionBindingStore
} from '../agent/runtime-session-binding-store'
import type { SshExecutionSpaceDescriptor } from '../execution-space'
import {
createManagedRemoteAcpRuntime,
type ManagedRemoteAcpRuntimeOptions
} from './managed-remote-acp-runtime'
const digest = (value: string): string =>
`sha256:${value.repeat(64)}`
function descriptor(): SshExecutionSpaceDescriptor {
return {
kind: 'ssh',
hostId: 'host-1',
remoteRootPath: '/srv/project',
validation: {
hostRevision: 2,
hostKeyGeneration: 3,
remoteUsername: 'alice',
workspaceIdentity: 'workspace-1',
agentProtocolMajor: 1,
agentInstallationIdAtValidation: 'agent-1',
agentBinaryDigestAtValidation: digest('a'),
agentVersionAtValidation: '1.0.0',
agentArchitectureAtValidation: 'x64',
validatedAt: '2030-01-01T00:00:00.000Z'
},
runtimeValidation: {
runtimeSelectionKey: 'opencode:default',
runtimeBundleDigest: digest('b'),
runtimeAdapterDigest: digest('c'),
agentInstallationIdAtValidation: 'agent-1',
validatedAt: '2030-01-01T00:00:00.000Z',
workMode: 'ask'
},
cacheIdentity: 'cache-1',
routeIdentity: 'route-1',
workspaceAccess: {
getIdentity: vi.fn(),
listDirectory: vi.fn(),
stat: vi.fn(),
readText: vi.fn(),
writeTextAtomic: vi.fn(),
search: vi.fn(),
getChanges: vi.fn(),
dispose: vi.fn()
}
}
}
function harness(
overrides: Partial<ManagedRemoteAcpRuntimeOptions> = {}
) {
remoteHarness.instances.length = 0
remoteHarness.runGate = undefined
const releaseConnection = vi.fn()
const workspaceAccesses: Array<{
getIdentity: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
}> = []
const connection = {
identity: {
cacheKey: 'connection-cache',
hostId: 'host-1',
hostRevision: 2,
hostKeyGeneration: 3,
remoteUsername: 'alice',
installationId: 'agent-1',
binaryDigest: digest('a'),
protocolMajor: 1,
protocolMinor: 0
},
status: {
state: 'ready',
draining: false,
installationId: 'agent-1',
binaryDigest: digest('a'),
agentVersion: '1.0.0',
protocol: { major: 1, minor: 0 },
daemonBootId: 'boot-1',
platform: 'linux',
architecture: 'x64',
supervisor: 'detached-on-demand'
},
capabilities: capabilities(),
client: { generation: 5 },
state: 'ready',
refreshCapabilities: vi.fn(async () => capabilities()),
reconnect: vi.fn(async () => undefined),
release: releaseConnection
}
const options = {
executionSpace: descriptor(),
selection: { provider: 'opencode' as const },
agentServices: {
installationManager: {
ensureInstalled: vi.fn(async () => ({
installationId: 'agent-1',
binaryDigest: digest('a'),
agentVersion: '1.0.0',
protocol: { major: 1, minor: 0 },
platform: 'linux' as const,
architecture: 'x64' as const,
supervisor: 'detached-on-demand' as const
}))
},
connectionManager: {
acquire: vi.fn(async () => connection)
},
controllerState: {
getControllerId: vi.fn(async () => 'controller-1')
}
},
runtimeInstallationManager: {
ensureInstalled: vi.fn(async () => ({
runtimeId: 'opencode',
runtimeVersion: '1.18.9',
bundleDigest: digest('b'),
manifestDigest: digest('e'),
runtimeAdapterDigest: digest('c'),
acpCapabilitiesDigest: digest('f'),
platform: 'linux' as const,
architecture: 'x64' as const
}))
},
workspaceAccessFactory: {
create: vi.fn(() => {
const access = {
getIdentity: vi.fn(async () => ({
kind: 'remote' as const,
id: 'host-1:workspace-1',
canonicalDisplayPath: '/srv/project',
capabilities: {
read: true,
write: true,
search: true,
changes: true
}
})),
listDirectory: vi.fn(),
stat: vi.fn(),
readText: vi.fn(),
writeTextAtomic: vi.fn(),
search: vi.fn(),
getChanges: vi.fn(),
dispose: vi.fn(async () => undefined)
}
workspaceAccesses.push(access)
return access
})
},
bindingStore: new MemoryRuntimeSessionBindingStore(),
modelBridge: {
policy: {
protocol: 'openai-responses' as const,
model: 'private-model',
modelProfileDigest: digest('9'),
supportsImageInput: false
},
channel: {
dispatch: vi.fn(),
onDelivered: vi.fn(),
finalizePrompt: vi.fn(),
poison: vi.fn()
}
},
...overrides
} as unknown as ManagedRemoteAcpRuntimeOptions
return {
options,
connection,
releaseConnection,
workspaceAccesses
}
}
describe('createManagedRemoteAcpRuntime', () => {
it('binds exact persisted evidence and owns its trust and connection leases', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
await expect(runtime.getStatus()).resolves.toMatchObject({
id: 'opencode',
available: true
})
expect(
fixture.options.workspaceAccessFactory.create
).not.toHaveBeenCalled()
expect(
fixture.options.agentServices.connectionManager.acquire
).toHaveBeenCalledWith('host-1', expect.objectContaining({
requiredCapabilities: [
{ name: 'runtime/acp', exactVersion: 3, critical: true },
{
name: 'runtime/model-bridge',
exactVersion: 1,
critical: true
}
]
}))
await runtime.dispose()
await runtime.dispose()
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
})
it('recreates the Runtime and reopens the Workspace after reconnect identity changes', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
await collect(runtime.run(request('request-1'), new AbortController().signal))
expect(remoteHarness.instances).toHaveLength(1)
expect(remoteHarness.instances[0]!.options.identity).toMatchObject({
controllerGeneration: 5,
daemonBootIdAtOpen: 'boot-1'
})
expect(fixture.workspaceAccesses).toHaveLength(1)
fixture.connection.state = 'offline'
vi.mocked(fixture.connection.reconnect).mockImplementationOnce(async () => {
fixture.connection.client.generation = 6
fixture.connection.status.daemonBootId = 'boot-2'
fixture.connection.state = 'ready'
})
await collect(runtime.run(request('request-2'), new AbortController().signal))
expect(fixture.connection.reconnect).toHaveBeenCalledOnce()
expect(remoteHarness.instances).toHaveLength(2)
expect(remoteHarness.instances[0]!.dispose).toHaveBeenCalledOnce()
expect(remoteHarness.instances[1]!.options.identity).toMatchObject({
controllerGeneration: 6,
daemonBootIdAtOpen: 'boot-2'
})
expect(fixture.workspaceAccesses).toHaveLength(2)
expect(
fixture.workspaceAccesses[0]!.dispose
).toHaveBeenCalledOnce()
await runtime.dispose()
expect(fixture.workspaceAccesses[1]!.dispose).toHaveBeenCalledOnce()
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
})
it('rejects a stale channel factory lease after a transport change', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
const staleFactory =
remoteHarness.instances[0]!.options.channelFactory
fixture.connection.client.generation = 6
fixture.connection.status.daemonBootId = 'boot-2'
await expect(staleFactory('binding-1')).rejects.toThrow(
/transport lease is stale/iu
)
expect(fixture.workspaceAccesses).toHaveLength(1)
await runtime.dispose()
})
it('recreates the Runtime when only the daemon boot identity changes', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
await collect(runtime.run(request('request-1'), new AbortController().signal))
fixture.connection.status.daemonBootId = 'boot-2'
await collect(runtime.run(request('request-2'), new AbortController().signal))
expect(remoteHarness.instances).toHaveLength(2)
expect(remoteHarness.instances[1]!.options.identity).toMatchObject({
controllerGeneration: 5,
daemonBootIdAtOpen: 'boot-2'
})
expect(fixture.workspaceAccesses).toHaveLength(2)
await runtime.dispose()
})
it('fails closed on reconnect during an active request without replaying it', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
let releaseGate = (): void => undefined
remoteHarness.runGate = new Promise<void>((resolve) => {
releaseGate = resolve
})
const first = runtime.run(
request('request-1'),
new AbortController().signal
)
await expect(first.next()).resolves.toMatchObject({
value: { type: 'status' },
done: false
})
fixture.connection.client.generation = 6
fixture.connection.status.daemonBootId = 'boot-2'
await expect(
runtime
.run(request('request-2'), new AbortController().signal)
.next()
).rejects.toThrow(/another Runtime request was active/iu)
expect(remoteHarness.instances).toHaveLength(1)
expect(remoteHarness.instances[0]!.runCalls).toBe(1)
releaseGate()
await collect(first)
await runtime.dispose()
})
it('releases exact trust without connecting when Runtime evidence changed', async () => {
const fixture = harness()
vi.mocked(
fixture.options.runtimeInstallationManager.ensureInstalled
).mockResolvedValueOnce({
runtimeId: 'opencode',
runtimeVersion: '1.18.9',
bundleDigest: digest('9'),
manifestDigest: digest('e'),
runtimeAdapterDigest: digest('c'),
acpCapabilitiesDigest: digest('f'),
platform: 'linux',
architecture: 'x64'
})
await expect(
createManagedRemoteAcpRuntime(fixture.options)
).rejects.toThrow(/does not match project validation/iu)
expect(
fixture.options.agentServices.connectionManager.acquire
).not.toHaveBeenCalled()
})
it('releases both leases when the live Agent identity is stale', async () => {
const fixture = harness()
fixture.connection.status.agentVersion = '2.0.0'
await expect(
createManagedRemoteAcpRuntime(fixture.options)
).rejects.toThrow(/connection does not match/iu)
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
})
it('rejects the obsolete Runtime ACP v1 capability', async () => {
const fixture = harness()
vi.mocked(
fixture.connection.refreshCapabilities
).mockResolvedValueOnce({
...capabilities(),
capabilities: [
{ name: 'runtime/acp', version: 1, critical: true }
]
})
await expect(
createManagedRemoteAcpRuntime(fixture.options)
).rejects.toThrow(/does not advertise/iu)
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
})
})
function capabilities() {
return {
generation: 7,
capabilities: [
{ name: 'runtime/acp', version: 3, critical: true },
{
name: 'runtime/model-bridge',
version: 1,
critical: true
}
],
runtimes: [
{
runtimeId: 'opencode',
version: '1.18.9',
bundleDigest: digest('b'),
acpCapabilitiesDigest: digest('f'),
sessionLoad: true,
sessionResume: true
}
]
}
}
function request(requestId: string) {
return {
requestId,
conversationId: 'conversation-1',
prompt: 'hello',
workMode: 'ask' as const
}
}
async function collect<T>(
values: AsyncGenerator<T, void, void>
): Promise<T[]> {
const collected: T[] = []
for await (const value of values) {
collected.push(value)
}
return collected
}
@@ -0,0 +1,704 @@
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import {
AcpRemoteRuntime,
type AcpRemoteRuntimeOptions
} from '../agent/acp-remote-runtime'
import type {
AgentExecutionRequest,
AgentRuntime,
RuntimeAuthorizer,
RuntimeEvent
} from '../agent/runtime'
import type { RuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
import type { SshExecutionSpaceDescriptor } from '../execution-space'
import type { RemoteWorkspaceProjectBinding } from '../workspace'
import { createProtocolRemoteRuntimeChannel } from './protocol-remote-runtime-channel'
import type { RemoteAgentServices } from './remote-agent-services'
import type { ManagedRemoteWorkspaceAccessFactory } from './managed-remote-workspace-access-factory'
import type { ManagedModelBridge } from './managed-model-bridge'
import type { RemoteRuntimeInstallationManager } from './remote-runtime-installation-manager'
const RUNTIME_ACP_CAPABILITY = {
name: 'runtime/acp',
exactVersion: 3,
critical: true
} as const
const RUNTIME_MODEL_BRIDGE_CAPABILITY = {
name: 'runtime/model-bridge',
exactVersion: 1,
critical: true
} as const
type ManagedRemoteTransportIdentity = {
controllerGeneration: number
daemonBootId: string
}
export type ManagedRemoteAcpRuntimeOptions = {
executionSpace: SshExecutionSpaceDescriptor
selection: AgentRuntimeSelection
agentServices: Pick<
RemoteAgentServices,
'installationManager' | 'connectionManager' | 'controllerState'
>
runtimeInstallationManager: Pick<
RemoteRuntimeInstallationManager,
'ensureInstalled'
>
workspaceAccessFactory: Pick<
ManagedRemoteWorkspaceAccessFactory,
'create'
>
bindingStore: RuntimeSessionBindingStore
modelBridge: ManagedModelBridge
usage?: {
provider: string
model: string
}
}
/**
* Acquires every production lease needed by one validated remote Runtime.
* The returned Runtime owns those leases and releases them only after all ACP
* conversation channels have been closed.
*/
export async function createManagedRemoteAcpRuntime(
options: ManagedRemoteAcpRuntimeOptions
): Promise<AgentRuntime> {
const descriptor = options.executionSpace
const validation = descriptor.validation
const runtimeValidation = descriptor.runtimeValidation
if (
options.selection.provider !== 'opencode' ||
validation === undefined ||
runtimeValidation === undefined
) {
throw new Error(
'Managed remote execution requires validated OpenCode project evidence'
)
}
const workspaceBinding = workspaceProjectBinding(descriptor)
let connection:
| Awaited<
ReturnType<
RemoteAgentServices['connectionManager']['acquire']
>
>
| undefined
let retained = false
try {
const agent =
await options.agentServices.installationManager.ensureInstalled(
descriptor.hostId
)
assertAgentMatches(workspaceBinding, agent)
const runtime =
await options.runtimeInstallationManager.ensureInstalled(
descriptor.hostId
)
assertRuntimeMatches(runtimeValidation, agent.architecture, runtime)
const activeConnection =
await options.agentServices.connectionManager.acquire(
descriptor.hostId,
{
...agent,
requiredCapabilities: [
RUNTIME_ACP_CAPABILITY,
RUNTIME_MODEL_BRIDGE_CAPABILITY
]
}
)
connection = activeConnection
assertConnectionMatches(workspaceBinding, activeConnection)
const initialTransport = {
controllerGeneration: activeConnection.client.generation,
daemonBootId: activeConnection.status.daemonBootId
}
const capabilities = await activeConnection.refreshCapabilities()
assertConnectionMatches(workspaceBinding, activeConnection)
assertSameTransport(
initialTransport,
{
controllerGeneration: activeConnection.client.generation,
daemonBootId: activeConnection.status.daemonBootId
},
'Remote Agent transport changed while creating the Runtime'
)
assertRuntimeAdvertised(runtime, capabilities)
const controllerId =
await options.agentServices.controllerState.getControllerId()
assertSameTransport(
initialTransport,
{
controllerGeneration: activeConnection.client.generation,
daemonBootId: activeConnection.status.daemonBootId
},
'Remote Agent transport changed while creating the Runtime'
)
const resources = new ManagedRemoteRuntimeResources(
options.workspaceAccessFactory,
workspaceBinding,
activeConnection,
runtime,
options.modelBridge
)
const createRemote = (
transport: ManagedRemoteTransportIdentity
): AcpRemoteRuntime => {
const remoteOptions: AcpRemoteRuntimeOptions = {
runtimeId: 'opencode',
label: 'OpenCode(远程托管)',
workspacePath: descriptor.remoteRootPath,
identity: {
controllerId,
controllerGeneration: transport.controllerGeneration,
hostId: workspaceBinding.hostId,
hostRevision: workspaceBinding.hostRevision,
hostKeyGeneration: workspaceBinding.hostKeyGeneration,
workspaceIdentity: workspaceBinding.workspaceIdentity,
agentInstallationId: workspaceBinding.agentInstallationId,
daemonBootIdAtOpen: transport.daemonBootId,
runtimeBundleDigest:
runtimeValidation.runtimeBundleDigest,
runtimeAdapterDigest:
runtimeValidation.runtimeAdapterDigest,
},
channelFactory: async (bindingId) =>
resources.openChannel(transport, bindingId),
bindingStore: options.bindingStore,
modelBridgePolicy: options.modelBridge.policy,
assertHostCurrent: (identity) => {
if (
identity.controllerId !== controllerId ||
identity.hostId !== workspaceBinding.hostId ||
identity.hostRevision !== workspaceBinding.hostRevision ||
identity.hostKeyGeneration !==
workspaceBinding.hostKeyGeneration ||
identity.agentInstallationId !==
workspaceBinding.agentInstallationId
) {
throw new Error(
'Remote Runtime Host identity no longer matches its project'
)
}
},
...(options.usage
? {
usage: {
runtime: 'opencode',
provider: options.usage.provider,
model: options.usage.model
}
}
: {})
}
return new AcpRemoteRuntime(remoteOptions)
}
const transport = initialTransport
const remote = createRemote(transport)
retained = true
return new ManagedRemoteAcpRuntime(
remote,
transport,
createRemote,
resources
)
} finally {
if (!retained) {
connection?.release()
}
}
}
class ManagedRemoteRuntimeResources {
#workspace:
| {
transport: ManagedRemoteTransportIdentity
access: ReturnType<ManagedRemoteWorkspaceAccessFactory['create']>
}
| undefined
#workspaceTail: Promise<void> = Promise.resolve()
#disposePromise?: Promise<void>
constructor(
private readonly workspaceFactory:
Pick<ManagedRemoteWorkspaceAccessFactory, 'create'>,
private readonly workspaceBinding: RemoteWorkspaceProjectBinding,
private readonly connection: NonNullable<
Awaited<
ReturnType<
RemoteAgentServices['connectionManager']['acquire']
>
>
>,
private readonly runtime: Awaited<
ReturnType<RemoteRuntimeInstallationManager['ensureInstalled']>
>,
private readonly modelBridge: ManagedModelBridge
) {}
currentTransportIdentity(): ManagedRemoteTransportIdentity {
return {
controllerGeneration: this.connection.client.generation,
daemonBootId: this.connection.status.daemonBootId
}
}
async prepareTransport(): Promise<ManagedRemoteTransportIdentity> {
if (this.connection.state !== 'ready') {
await this.connection.reconnect()
}
assertConnectionMatches(this.workspaceBinding, this.connection)
const beforeRefresh = this.currentTransportIdentity()
const capabilities = await this.connection.refreshCapabilities()
assertConnectionMatches(this.workspaceBinding, this.connection)
assertSameTransport(
beforeRefresh,
this.currentTransportIdentity(),
'Remote Agent transport changed while refreshing capabilities'
)
assertRuntimeAdvertised(this.runtime, capabilities)
await this.ensureWorkspace(beforeRefresh)
assertSameTransport(
beforeRefresh,
this.currentTransportIdentity(),
'Remote Agent transport changed while opening the Workspace'
)
return beforeRefresh
}
async openChannel(
expected: ManagedRemoteTransportIdentity,
bindingId: string
) {
const transport = await this.prepareTransport()
assertSameTransport(
expected,
transport,
'Remote Runtime transport lease is stale'
)
const channel = await createProtocolRemoteRuntimeChannel({
connection: this.connection,
openIdentity: {
bindingId,
runtimeId: 'opencode',
runtimeBundleDigest: this.runtime.bundleDigest,
workspaceIdentity: this.workspaceBinding.workspaceIdentity
},
modelBridge: this.modelBridge.channel
})
try {
assertSameTransport(
expected,
this.currentTransportIdentity(),
'Remote Agent transport changed while opening an ACP channel'
)
return channel
} catch (error) {
await channel.close().catch(() => undefined)
throw error
}
}
ensureWorkspace(
transport: ManagedRemoteTransportIdentity
): Promise<void> {
const operation = this.#workspaceTail.then(async () => {
if (
this.#workspace !== undefined &&
sameTransport(this.#workspace.transport, transport)
) {
await this.#workspace.access.getIdentity()
assertSameTransport(
transport,
this.currentTransportIdentity(),
'Remote Agent transport changed while checking the Workspace'
)
return
}
const previous = this.#workspace
this.#workspace = undefined
if (previous !== undefined) {
await previous.access.dispose().catch(() => undefined)
}
const access = this.workspaceFactory.create(
this.workspaceBinding
)
try {
const identity = await access.getIdentity()
if (
identity.kind !== 'remote' ||
identity.canonicalDisplayPath !==
this.workspaceBinding.remoteRootPath ||
identity.id !==
`${this.workspaceBinding.hostId}:${this.workspaceBinding.workspaceIdentity}`
) {
throw new Error(
'Live remote Workspace does not match persisted validation'
)
}
assertSameTransport(
transport,
this.currentTransportIdentity(),
'Remote Agent transport changed while opening the Workspace'
)
this.#workspace = { transport, access }
} catch (error) {
await access.dispose().catch(() => undefined)
throw error
}
})
this.#workspaceTail = operation.catch(() => undefined)
return operation
}
dispose(): Promise<void> {
this.#disposePromise ??= this.#dispose()
return this.#disposePromise
}
async #dispose(): Promise<void> {
await this.#workspaceTail
const errors: unknown[] = []
try {
await this.#workspace?.access.dispose()
} catch (error) {
errors.push(error)
}
try {
this.connection.release()
} catch (error) {
errors.push(error)
}
if (errors.length > 0) {
throw new AggregateError(
errors,
'Remote Runtime resources failed to release'
)
}
}
}
class ManagedRemoteAcpRuntime implements AgentRuntime {
readonly requiresToolApproval = false
readonly supportsScopedDataTools = false
readonly capability = 'chat' as const
readonly runtimeId = 'opencode' as const
#disposePromise?: Promise<void>
#transitionTail: Promise<void> = Promise.resolve()
#activeRuns = 0
#draining = false
#disposed = false
constructor(
private remote: AcpRemoteRuntime,
private transport: ManagedRemoteTransportIdentity,
private readonly createRemote: (
transport: ManagedRemoteTransportIdentity
) => AcpRemoteRuntime,
private readonly resources: ManagedRemoteRuntimeResources
) {}
get supportsToolExecution(): boolean {
return this.remote.supportsToolExecution
}
getStatus() {
return this.remote.getStatus()
}
run(
request: AgentExecutionRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void> {
return this.#run(request, signal, authorize)
}
async *#run(
request: AgentExecutionRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void> {
const remote = await this.#acquireRun()
let completed = false
try {
yield* remote.run(request, signal, authorize)
completed = true
} finally {
this.#activeRuns -= 1
if (completed) {
try {
const current = this.resources.currentTransportIdentity()
if (
current.daemonBootId === this.transport.daemonBootId &&
current.controllerGeneration >
this.transport.controllerGeneration
) {
this.transport = current
}
} catch {
// A later run will perform the authoritative transport transition.
}
}
}
}
async #acquireRun(): Promise<AcpRemoteRuntime> {
let acquired: AcpRemoteRuntime | undefined
const operation = this.#transitionTail.then(async () => {
if (this.#disposed) {
throw new Error('远端 Runtime 已关闭')
}
if (this.#draining) {
throw new Error('远端 Runtime 正在退役')
}
const currentTransport = await this.resources.prepareTransport()
if (this.#disposed) {
throw new Error('远端 Runtime 已关闭')
}
if (this.#draining) {
throw new Error('远端 Runtime 正在退役')
}
if (!sameTransport(this.transport, currentTransport)) {
if (this.#activeRuns > 0) {
throw new Error(
'Remote Agent transport changed while another Runtime request was active'
)
}
await this.remote.dispose()
if (this.#disposed) {
throw new Error('远端 Runtime 已关闭')
}
if (this.#draining) {
throw new Error('远端 Runtime 正在退役')
}
this.remote = this.createRemote(currentTransport)
this.transport = currentTransport
}
this.#activeRuns += 1
acquired = this.remote
})
this.#transitionTail = operation.catch(() => undefined)
await operation
return acquired!
}
releaseConversation(conversationId: string): Promise<void> {
const operation = this.#transitionTail.then(() =>
this.remote.releaseConversation(conversationId)
)
this.#transitionTail = operation.catch(() => undefined)
return operation
}
beginDrain(): Promise<void> {
this.#draining = true
const operation = this.#transitionTail.then(() =>
this.remote.beginDrain()
)
this.#transitionTail = operation.catch(() => undefined)
return operation
}
waitForDrain(): Promise<void> {
return this.#transitionTail.then(() => this.remote.waitForDrain())
}
forceShutdown(): Promise<void> {
this.#disposed = true
return this.#transitionTail
.then(() => this.remote.forceShutdown())
.finally(() => this.resources.dispose())
}
dispose(): Promise<void> {
this.#disposed = true
this.#disposePromise ??= this.#dispose()
return this.#disposePromise
}
async #dispose(): Promise<void> {
await this.#transitionTail
const results = await Promise.allSettled([
this.remote.dispose(),
this.resources.dispose()
])
const errors = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
)
if (errors.length > 0) {
throw new AggregateError(
errors,
'Managed remote Runtime failed to dispose'
)
}
}
}
function sameTransport(
expected: ManagedRemoteTransportIdentity,
actual: ManagedRemoteTransportIdentity
): boolean {
return (
expected.controllerGeneration === actual.controllerGeneration &&
expected.daemonBootId === actual.daemonBootId
)
}
function assertSameTransport(
expected: ManagedRemoteTransportIdentity,
actual: ManagedRemoteTransportIdentity,
message: string
): void {
if (!sameTransport(expected, actual)) {
throw new Error(message)
}
}
function workspaceProjectBinding(
descriptor: SshExecutionSpaceDescriptor
): RemoteWorkspaceProjectBinding {
const validation = descriptor.validation
const runtime = descriptor.runtimeValidation
if (
validation === undefined ||
runtime === undefined ||
validation.agentInstallationIdAtValidation !==
runtime.agentInstallationIdAtValidation
) {
throw new Error('Remote project validation evidence is inconsistent')
}
return {
hostId: descriptor.hostId,
hostRevision: validation.hostRevision,
hostKeyGeneration: validation.hostKeyGeneration,
remoteUsername: validation.remoteUsername,
remoteRootPath: descriptor.remoteRootPath,
workspaceIdentity: validation.workspaceIdentity,
agentInstallationId:
validation.agentInstallationIdAtValidation,
agentBinaryDigest:
validation.agentBinaryDigestAtValidation,
agentVersion: validation.agentVersionAtValidation,
agentArchitecture:
validation.agentArchitectureAtValidation,
agentProtocolMajor: validation.agentProtocolMajor
}
}
function assertAgentMatches(
binding: RemoteWorkspaceProjectBinding,
agent: Awaited<
ReturnType<RemoteAgentServices['installationManager']['ensureInstalled']>
>
): void {
if (
agent.installationId !== binding.agentInstallationId ||
agent.binaryDigest !== binding.agentBinaryDigest ||
agent.agentVersion !== binding.agentVersion ||
agent.architecture !== binding.agentArchitecture ||
agent.protocol.major !== binding.agentProtocolMajor
) {
throw new Error(
'Installed remote Agent does not match project validation'
)
}
}
function assertRuntimeMatches(
validation: NonNullable<
SshExecutionSpaceDescriptor['runtimeValidation']
>,
architecture: 'x64' | 'arm64',
runtime: Awaited<
ReturnType<RemoteRuntimeInstallationManager['ensureInstalled']>
>
): void {
if (
runtime.runtimeId !== 'opencode' ||
runtime.architecture !== architecture ||
runtime.bundleDigest !== validation.runtimeBundleDigest ||
runtime.runtimeAdapterDigest !==
validation.runtimeAdapterDigest
) {
throw new Error(
'Installed OpenCode Runtime does not match project validation'
)
}
}
function assertConnectionMatches(
binding: RemoteWorkspaceProjectBinding,
connection: Awaited<
ReturnType<RemoteAgentServices['connectionManager']['acquire']>
>
): void {
if (
connection.state !== 'ready' ||
connection.identity.hostId !== binding.hostId ||
connection.identity.hostRevision !== binding.hostRevision ||
connection.identity.hostKeyGeneration !==
binding.hostKeyGeneration ||
connection.identity.remoteUsername !== binding.remoteUsername ||
connection.identity.installationId !==
binding.agentInstallationId ||
connection.identity.binaryDigest !== binding.agentBinaryDigest ||
connection.identity.protocolMajor !== binding.agentProtocolMajor ||
connection.status.state !== 'ready' ||
connection.status.draining ||
connection.status.installationId !== binding.agentInstallationId ||
connection.status.binaryDigest !== binding.agentBinaryDigest ||
connection.status.agentVersion !== binding.agentVersion ||
connection.status.architecture !== binding.agentArchitecture
) {
throw new Error(
'Remote Agent connection does not match project validation'
)
}
}
function assertRuntimeAdvertised(
runtime: Awaited<
ReturnType<RemoteRuntimeInstallationManager['ensureInstalled']>
>,
capabilities: Awaited<
ReturnType<
RemoteAgentServices['connectionManager']['acquire']
>
>['capabilities']
): void {
const capability = capabilities.capabilities.find(
(entry) => entry.name === RUNTIME_ACP_CAPABILITY.name
)
const modelBridgeCapability = capabilities.capabilities.find(
(entry) => entry.name === RUNTIME_MODEL_BRIDGE_CAPABILITY.name
)
const advertised = capabilities.runtimes.filter(
(entry) => entry.runtimeId === 'opencode'
)
if (
capability === undefined ||
capability.version !==
RUNTIME_ACP_CAPABILITY.exactVersion ||
!capability.critical ||
modelBridgeCapability === undefined ||
modelBridgeCapability.version !==
RUNTIME_MODEL_BRIDGE_CAPABILITY.exactVersion ||
!modelBridgeCapability.critical ||
advertised.length !== 1 ||
advertised[0]!.version !== runtime.runtimeVersion ||
advertised[0]!.bundleDigest !== runtime.bundleDigest ||
advertised[0]!.acpCapabilitiesDigest !==
runtime.acpCapabilitiesDigest ||
!advertised[0]!.sessionLoad ||
!advertised[0]!.sessionResume
) {
throw new Error(
'Remote Agent does not advertise the validated OpenCode Runtime'
)
}
}
@@ -0,0 +1,247 @@
import { join } from 'node:path'
import { SqliteRuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
import { ModelCallOperationStore } from '../agent/model-call-operation-store'
import { RemoteModelGateway } from '../agent/remote-model-gateway'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import type { SshHostStore } from '../ssh/ssh-host-store'
import {
resolveBundledRemoteRuntimeResourcePaths,
type BundledRemoteRuntimeResourcePaths
} from './bundled-remote-runtime-resources'
import { ManagedRemoteProjectRuntimeValidator } from './managed-remote-project-runtime-validator'
import {
createManagedRemoteAcpRuntime,
type ManagedRemoteAcpRuntimeOptions
} from './managed-remote-acp-runtime'
import { ManagedRemoteWorkspaceAccessFactory } from './managed-remote-workspace-access-factory'
import type { RemoteAgentServices } from './remote-agent-services'
import { RemoteProjectSaveService } from './remote-project-save-service'
import {
RemoteRuntimeInstallationManager,
type RemoteRuntimeActivator
} from './remote-runtime-installation-manager'
import {
createRemoteRuntimeResourceLoader,
loadRemoteRuntimeVerificationMetadata
} from './remote-runtime-resource-loader'
import {
createManagedModelBridge,
reconcileStartupModelCalls
} from './managed-model-bridge'
const RUNTIME_BINDING_DATABASE_NAME = 'remote-runtime-bindings.sqlite'
const MODEL_CALL_DATABASE_NAME = 'remote-model-calls-v2.sqlite'
export type ManagedRemoteExecutionServicesOptions = {
sshHostStore: SshHostStore
agentServices: Pick<
RemoteAgentServices,
| 'targetResolver'
| 'sshPool'
| 'installationManager'
| 'connectionManager'
| 'controllerState'
>
userDataPath: string
appPath: string
resourcesPath: string
packaged: boolean
resolveModelProfile(
selection: AgentRuntimeSelection
): Promise<ResolvedModelProfile | undefined>
resolveRuntimeSelection(
selection: AgentRuntimeSelection
): Promise<AgentRuntimeSelection>
}
/**
* Main-only lifecycle owner for verified remote execution primitives.
* Construction performs no SSH or provider work.
*/
export class ManagedRemoteExecutionServices {
readonly runtimeResourcePaths: BundledRemoteRuntimeResourcePaths
readonly runtimeInstallationManager: RemoteRuntimeInstallationManager
readonly runtimeValidator: ManagedRemoteProjectRuntimeValidator
readonly workspaceAccessFactory: ManagedRemoteWorkspaceAccessFactory
readonly bindingStore: SqliteRuntimeSessionBindingStore
readonly modelCallStore: ModelCallOperationStore
readonly modelGateway: RemoteModelGateway
#disposePromise?: Promise<void>
readonly #readiness: Promise<void>
readonly #resolveRuntimeSelection: ManagedRemoteExecutionServicesOptions['resolveRuntimeSelection']
readonly #sshHosts: SshHostStore
readonly #agentServices:
ManagedRemoteExecutionServicesOptions['agentServices']
constructor(options: ManagedRemoteExecutionServicesOptions) {
this.#agentServices = options.agentServices
this.#resolveRuntimeSelection = options.resolveRuntimeSelection
this.#sshHosts = options.sshHostStore
this.runtimeResourcePaths =
resolveBundledRemoteRuntimeResourcePaths({
appPath: options.appPath,
resourcesPath: options.resourcesPath,
packaged: options.packaged
})
const activate: RemoteRuntimeActivator = async (
lease,
runtimeId,
bundleDigest,
architecture,
signal
) => {
const agent =
await options.agentServices.installationManager.ensureInstalled(
lease.identity.hostId,
{ signal }
)
signal.throwIfAborted()
const current = await options.agentServices.targetResolver.resolve(
lease.identity.hostId
)
signal.throwIfAborted()
if (
runtimeId !== 'opencode' ||
current.host.id !== lease.identity.hostId ||
current.hostRevision !== lease.identity.hostRevision ||
current.hostKeyGeneration !==
lease.identity.hostKeyGeneration ||
agent.architecture !== architecture
) {
throw new Error(
'Remote Agent or SSH host identity changed before Runtime activation'
)
}
const result = await lease.runAgentRuntimeAction(
agent.installationId,
{
kind: 'runtime-activate',
runtimeId,
bundleDigest,
architecture
},
signal
)
if (result.exitCode !== 0) {
throw new Error('Remote Runtime activation failed')
}
}
this.runtimeInstallationManager =
new RemoteRuntimeInstallationManager({
resolver: options.agentServices.targetResolver,
sshPool: options.agentServices.sshPool,
loadVerifiedBundle: createRemoteRuntimeResourceLoader(
this.runtimeResourcePaths
),
loadVerificationMetadata: () =>
loadRemoteRuntimeVerificationMetadata(
this.runtimeResourcePaths
),
activate
})
this.runtimeValidator = new ManagedRemoteProjectRuntimeValidator({
installationManager: this.runtimeInstallationManager,
resolveModelProfile: options.resolveModelProfile
})
this.workspaceAccessFactory =
new ManagedRemoteWorkspaceAccessFactory({
installationManager:
options.agentServices.installationManager,
connectionManager: options.agentServices.connectionManager
})
this.bindingStore = new SqliteRuntimeSessionBindingStore(
join(options.userDataPath, RUNTIME_BINDING_DATABASE_NAME)
)
this.modelCallStore = new ModelCallOperationStore(
join(options.userDataPath, MODEL_CALL_DATABASE_NAME)
)
this.modelGateway = new RemoteModelGateway({
store: this.modelCallStore
})
this.#readiness = reconcileStartupModelCalls({
gatewayStore: this.modelCallStore,
bindingStore: this.bindingStore
})
}
createProjectSaveService(
options: Pick<
ConstructorParameters<typeof RemoteProjectSaveService>[0],
'database' | 'notify'
>
): RemoteProjectSaveService {
return new RemoteProjectSaveService({
database: options.database,
sshHosts: this.#sshHosts,
installationManager:
this.#agentServices.installationManager,
connectionManager: this.#agentServices.connectionManager,
resolveRuntimeSelection: this.#resolveRuntimeSelection,
runtimeValidator: this.runtimeValidator,
...(options.notify ? { notify: options.notify } : {})
})
}
async initialize(): Promise<void> {
await this.#readiness
}
async createRuntime(
options: Pick<
ManagedRemoteAcpRuntimeOptions,
'executionSpace' | 'selection' | 'usage'
> & {
modelProfile: ResolvedModelProfile
}
) {
await this.#readiness
const modelBridge = createManagedModelBridge({
profile: options.modelProfile,
gateway: this.modelGateway,
bindingStore: this.bindingStore
})
return createManagedRemoteAcpRuntime({
...options,
modelBridge,
agentServices: {
installationManager:
this.#agentServices.installationManager,
connectionManager: this.#agentServices.connectionManager,
controllerState: this.#agentServices.controllerState
},
runtimeInstallationManager:
this.runtimeInstallationManager,
workspaceAccessFactory: this.workspaceAccessFactory,
bindingStore: this.bindingStore
})
}
dispose(): Promise<void> {
this.#disposePromise ??= this.#dispose()
return this.#disposePromise
}
async #dispose(): Promise<void> {
const errors: unknown[] = []
const settle = async (
operation: () => void | Promise<void>
): Promise<void> => {
try {
await operation()
} catch (error) {
errors.push(error)
}
}
await settle(() => this.runtimeInstallationManager.dispose())
await settle(() => this.bindingStore.dispose())
await settle(() => this.modelCallStore.dispose())
if (errors.length > 0) {
throw new AggregateError(
errors,
'Managed remote execution services failed to dispose completely'
)
}
}
}
@@ -0,0 +1,401 @@
import { describe, expect, it, vi } from 'vitest'
import type {
DaemonCapabilities,
DaemonStatus
} from '../../shared/agent-protocol'
import type { RemoteAgentConnection } from './remote-agent-connection-manager'
import { ManagedRemoteProjectRuntimeValidator } from './managed-remote-project-runtime-validator'
import type { RemoteProjectRuntimeValidationInput } from './remote-project-save-service'
import type { RemoteRuntimeInstallationIdentity } from './remote-runtime-installation-manager'
import type { ResolvedModelProfile } from '../runtime-settings-store'
const digest = (character: string): string =>
`sha256:${character.repeat(64)}`
const installation: RemoteRuntimeInstallationIdentity = {
runtimeId: 'opencode',
runtimeVersion: '1.2.3',
bundleDigest: digest('b'),
manifestDigest: digest('c'),
runtimeAdapterDigest: digest('d'),
acpCapabilitiesDigest: digest('f'),
platform: 'linux',
architecture: 'x64'
}
const modelProfile: ResolvedModelProfile = {
id: '00000000-0000-4000-8000-000000000001',
name: 'Private model',
baseUrl: 'https://provider.example/v1',
modelName: 'private-model',
protocol: 'openai-responses',
authentication: 'api-key',
apiKey: 'main-only-secret'
}
function advertisedCapabilities(): DaemonCapabilities {
return {
generation: 2,
capabilities: [
{ name: 'workspace/read', version: 1, critical: true },
{ name: 'runtime/acp', version: 3, critical: true },
{
name: 'runtime/model-bridge',
version: 1,
critical: true
}
],
runtimes: [
{
runtimeId: 'opencode',
version: installation.runtimeVersion,
bundleDigest: installation.bundleDigest,
acpCapabilitiesDigest:
installation.acpCapabilitiesDigest,
sessionLoad: true,
sessionResume: true
}
]
}
}
function harness(options: {
selection?: RemoteProjectRuntimeValidationInput['selection']
workMode?: 'ask' | 'execute'
architecture?: 'x64' | 'arm64'
installed?: RemoteRuntimeInstallationIdentity
refreshed?: DaemonCapabilities
modelProfile?: ResolvedModelProfile | null
} = {}) {
const calls: string[] = []
const selection =
options.selection ??
({
provider: 'opencode'
} as const)
const workMode = options.workMode ?? 'ask'
const architecture = options.architecture ?? 'x64'
const installed = options.installed ?? installation
let state: RemoteAgentConnection['state'] = 'ready'
let capabilities: DaemonCapabilities = {
generation: 1,
capabilities: [
{ name: 'workspace/read', version: 1, critical: true }
],
runtimes: []
}
const status: DaemonStatus = {
state: 'ready',
installationId: 'agent-installation',
binaryDigest: digest('a'),
daemonBootId: 'daemon-boot',
agentVersion: '2.0.0',
protocol: { major: 1, minor: 0 },
platform: 'linux',
architecture,
supervisor: 'detached-on-demand',
remoteUserIdentity: 'remote-user',
draining: false
}
const refreshCapabilities = vi.fn(async () => {
calls.push('refresh')
capabilities = structuredClone(
options.refreshed ?? advertisedCapabilities()
)
return structuredClone(capabilities)
})
const connection = {
identity: {
cacheKey: 'cache-key',
hostId: 'host-1',
hostRevision: 3,
hostKeyGeneration: 2,
remoteUsername: 'builder',
installationId: status.installationId,
binaryDigest: status.binaryDigest,
protocolMajor: 1,
protocolMinor: 0
},
status,
get capabilities() {
return capabilities
},
client: {},
get state() {
return state
},
refreshCapabilities,
reconnect: vi.fn(),
release: vi.fn()
} as unknown as RemoteAgentConnection
const ensureInstalled = vi.fn(async () => {
calls.push('install')
return installed
})
const resolveModelProfile = vi.fn(async () => {
calls.push('model')
return options.modelProfile === null
? undefined
: options.modelProfile ?? modelProfile
})
const validator = new ManagedRemoteProjectRuntimeValidator({
installationManager: { ensureInstalled },
resolveModelProfile,
now: () => new Date('2030-01-01T00:00:00.000Z')
})
const signal = new AbortController().signal
const input: RemoteProjectRuntimeValidationInput = {
selection,
runtimeSelectionKey: `${selection.provider}:${
'profileId' in selection
? selection.profileId ?? 'platform'
: 'default'
}`,
workMode,
host: {
hostId: 'host-1',
hostRevision: 3,
hostKeyGeneration: 2,
remoteUsername: 'builder'
},
agent: {
installationId: status.installationId,
binaryDigest: status.binaryDigest,
version: status.agentVersion,
architecture,
protocolMajor: 1
},
workspace: {
canonicalRemoteRoot: '/srv/project',
workspaceIdentity: 'workspace-identity',
workspaceId: 'workspace-id',
generation: 1,
access: 'read-only',
capabilities: ['read-text']
},
connection,
signal
}
return {
validator,
input,
connection,
calls,
ensureInstalled,
refreshCapabilities,
resolveModelProfile,
setCapabilities: (value: DaemonCapabilities) => {
capabilities = value
},
setState: (value: RemoteAgentConnection['state']) => {
state = value
}
}
}
describe('ManagedRemoteProjectRuntimeValidator', () => {
it('installs, refreshes, and leases exact Ask Runtime evidence', async () => {
const test = harness()
const lease = await test.validator.validate(test.input)
expect(test.calls).toEqual(['model', 'install', 'refresh'])
expect(test.ensureInstalled).toHaveBeenCalledWith('host-1', {
signal: test.input.signal
})
expect(lease.evidence).toEqual({
runtimeSelectionKey: 'opencode:default',
runtimeBundleDigest: installation.bundleDigest,
runtimeAdapterDigest: installation.runtimeAdapterDigest,
agentInstallationIdAtValidation: 'agent-installation',
validatedAt: '2030-01-01T00:00:00.000Z',
workMode: 'ask'
})
expect(() => lease.assertCurrent()).not.toThrow()
test.setCapabilities({
...advertisedCapabilities(),
generation: 3
})
expect(() => lease.assertCurrent()).toThrow(/stale/iu)
lease.release()
lease.release()
expect(() => lease.assertCurrent()).toThrow(/released/iu)
})
it('validates Execute without a second authorization tier', async () => {
const test = harness({ workMode: 'execute' })
const lease = await test.validator.validate(test.input)
expect(lease.evidence).toMatchObject({
workMode: 'execute'
})
test.setState('offline')
expect(() => lease.assertCurrent()).toThrow(/not ready/iu)
})
it('rejects non-OpenCode selections before installation', async () => {
const test = harness({
selection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
}
})
await expect(test.validator.validate(test.input)).rejects.toThrow(
/OpenCode Runtime/iu
)
expect(test.ensureInstalled).not.toHaveBeenCalled()
expect(test.refreshCapabilities).not.toHaveBeenCalled()
})
it('requires the installed Runtime architecture to exactly match the Agent', async () => {
const test = harness({ architecture: 'arm64' })
await expect(test.validator.validate(test.input)).rejects.toThrow(
/architecture/iu
)
expect(test.refreshCapabilities).not.toHaveBeenCalled()
})
it('rejects an unavailable model profile before remote installation', async () => {
const test = harness({ modelProfile: null })
await expect(test.validator.validate(test.input)).rejects.toThrow(
/model profile/iu
)
expect(test.resolveModelProfile).toHaveBeenCalledOnce()
expect(test.ensureInstalled).not.toHaveBeenCalled()
expect(test.refreshCapabilities).not.toHaveBeenCalled()
})
it.each([
[
'missing model bridge',
{
...advertisedCapabilities(),
capabilities: [
{ name: 'workspace/read', version: 1, critical: true },
{ name: 'runtime/acp', version: 3, critical: true }
]
}
],
[
'wrong model bridge version',
{
...advertisedCapabilities(),
capabilities: [
{ name: 'workspace/read', version: 1, critical: true },
{ name: 'runtime/acp', version: 3, critical: true },
{
name: 'runtime/model-bridge',
version: 3,
critical: true
}
]
}
],
[
'non-critical ACP',
{
...advertisedCapabilities(),
capabilities: [
{
name: 'runtime/acp',
version: 3,
critical: false
}
]
}
],
[
'wrong ACP version',
{
...advertisedCapabilities(),
capabilities: [
{
name: 'runtime/acp',
version: 1,
critical: true
}
]
}
],
[
'duplicate OpenCode Runtime',
{
...advertisedCapabilities(),
runtimes: [
...advertisedCapabilities().runtimes,
...advertisedCapabilities().runtimes
]
}
],
[
'wrong Runtime version',
{
...advertisedCapabilities(),
runtimes: [
{
...advertisedCapabilities().runtimes[0]!,
version: '9.9.9'
}
]
}
],
[
'wrong bundle digest',
{
...advertisedCapabilities(),
runtimes: [
{
...advertisedCapabilities().runtimes[0]!,
bundleDigest: digest('9')
}
]
}
],
[
'wrong ACP digest',
{
...advertisedCapabilities(),
runtimes: [
{
...advertisedCapabilities().runtimes[0]!,
acpCapabilitiesDigest: digest('8')
}
]
}
],
[
'missing session load',
{
...advertisedCapabilities(),
runtimes: [
{
...advertisedCapabilities().runtimes[0]!,
sessionLoad: false
}
]
}
],
[
'missing session resume',
{
...advertisedCapabilities(),
runtimes: [
{
...advertisedCapabilities().runtimes[0]!,
sessionResume: false
}
]
}
]
])('fails closed for %s advertisement', async (_name, refreshed) => {
const test = harness({
refreshed: refreshed as DaemonCapabilities
})
await expect(test.validator.validate(test.input)).rejects.toThrow(
/Runtime|ACP/iu
)
})
})
@@ -0,0 +1,232 @@
import { isDeepStrictEqual } from 'node:util'
import { projectRuntimeValidationSchema } from '../../shared/assistant-contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import type { RemoteAgentConnection } from './remote-agent-connection-manager'
import type {
RemoteProjectRuntimeValidationEvidence,
RemoteProjectRuntimeValidationInput,
RemoteProjectRuntimeValidationLease,
RemoteProjectRuntimeValidator
} from './remote-project-save-service'
import type {
RemoteRuntimeInstallationIdentity,
RemoteRuntimeInstallationManager
} from './remote-runtime-installation-manager'
const RUNTIME_ACP_CAPABILITY = 'runtime/acp'
const RUNTIME_ACP_CAPABILITY_VERSION = 3
const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge'
const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1
export class ManagedRemoteProjectRuntimeValidator
implements RemoteProjectRuntimeValidator
{
readonly #installationManager: Pick<
RemoteRuntimeInstallationManager,
'ensureInstalled'
>
readonly #now: () => Date
readonly #resolveModelProfile: (
selection: RemoteProjectRuntimeValidationInput['selection']
) => Promise<ResolvedModelProfile | undefined>
constructor(options: {
installationManager: Pick<
RemoteRuntimeInstallationManager,
'ensureInstalled'
>
resolveModelProfile(
selection: RemoteProjectRuntimeValidationInput['selection']
): Promise<ResolvedModelProfile | undefined>
now?: () => Date
}) {
this.#installationManager = options.installationManager
this.#resolveModelProfile = options.resolveModelProfile
this.#now = options.now ?? (() => new Date())
}
async validate(
input: RemoteProjectRuntimeValidationInput
): Promise<RemoteProjectRuntimeValidationLease> {
input.signal.throwIfAborted()
if (
input.selection.provider !== 'opencode' ||
input.runtimeSelectionKey !==
agentRuntimeSelectionKey(input.selection)
) {
throw new Error(
'Managed remote projects require the OpenCode Runtime'
)
}
assertInputIdentity(input)
const modelProfile = await this.#resolveModelProfile(
input.selection
)
input.signal.throwIfAborted()
if (
modelProfile === undefined ||
modelProfile.protocol === 'openai-images-generations' ||
(modelProfile.authentication === 'api-key' &&
!modelProfile.apiKey)
) {
throw new Error(
'Managed remote OpenCode requires a usable text model profile'
)
}
const installation =
await this.#installationManager.ensureInstalled(
input.host.hostId,
{ signal: input.signal }
)
input.signal.throwIfAborted()
assertInstallationIdentity(input, installation)
const refreshed = await input.connection.refreshCapabilities(
input.signal
)
input.signal.throwIfAborted()
if (
input.connection.state !== 'ready' ||
!isDeepStrictEqual(input.connection.capabilities, refreshed)
) {
throw new Error(
'Remote Agent capabilities changed during Runtime validation'
)
}
assertRuntimeCapability(refreshed, installation)
const validated = projectRuntimeValidationSchema.parse({
runtimeSelectionKey: input.runtimeSelectionKey,
runtimeBundleDigest: installation.bundleDigest,
runtimeAdapterDigest: installation.runtimeAdapterDigest,
agentInstallationIdAtValidation: input.agent.installationId,
validatedAt: this.#now().toISOString(),
workMode: input.workMode
})
const evidence: RemoteProjectRuntimeValidationEvidence = validated
const capabilitySnapshot = structuredClone(refreshed)
let released = false
return {
evidence,
assertCurrent: (): void => {
if (released) {
throw new Error(
'Remote Runtime validation lease is released'
)
}
if (input.connection.state !== 'ready') {
throw new Error(
'Remote Runtime validation connection is not ready'
)
}
let current: RemoteAgentConnection['capabilities']
try {
current = input.connection.capabilities
} catch (error) {
throw new Error(
'Remote Runtime validation connection is not ready',
{ cause: error }
)
}
if (!isDeepStrictEqual(current, capabilitySnapshot)) {
throw new Error(
'Remote Runtime capability validation is stale'
)
}
},
release: (): void => {
released = true
}
}
}
}
function assertInputIdentity(
input: RemoteProjectRuntimeValidationInput
): void {
const connection = input.connection
if (
connection.state !== 'ready' ||
connection.identity.hostId !== input.host.hostId ||
connection.identity.hostRevision !== input.host.hostRevision ||
connection.identity.hostKeyGeneration !==
input.host.hostKeyGeneration ||
connection.identity.remoteUsername !==
input.host.remoteUsername ||
connection.identity.installationId !==
input.agent.installationId ||
connection.identity.binaryDigest !== input.agent.binaryDigest ||
connection.identity.protocolMajor !== input.agent.protocolMajor ||
connection.status.state !== 'ready' ||
connection.status.draining ||
connection.status.installationId !==
input.agent.installationId ||
connection.status.binaryDigest !== input.agent.binaryDigest ||
connection.status.agentVersion !== input.agent.version ||
connection.status.architecture !== input.agent.architecture ||
connection.status.protocol.major !== input.agent.protocolMajor
) {
throw new Error(
'Remote Agent identity does not match Runtime validation'
)
}
}
function assertInstallationIdentity(
input: RemoteProjectRuntimeValidationInput,
installation: RemoteRuntimeInstallationIdentity
): void {
if (
installation.runtimeId !== 'opencode' ||
installation.platform !== 'linux' ||
installation.architecture !== input.agent.architecture
) {
throw new Error(
'Installed OpenCode Runtime architecture is incompatible'
)
}
}
function assertRuntimeCapability(
capabilities: RemoteAgentConnection['capabilities'],
installation: RemoteRuntimeInstallationIdentity
): void {
const acp = capabilities.capabilities.find(
(capability) => capability.name === RUNTIME_ACP_CAPABILITY
)
const modelBridge = capabilities.capabilities.find(
(capability) =>
capability.name === RUNTIME_MODEL_BRIDGE_CAPABILITY
)
const runtimes = capabilities.runtimes.filter(
(runtime) => runtime.runtimeId === 'opencode'
)
if (
acp === undefined ||
acp.version !== RUNTIME_ACP_CAPABILITY_VERSION ||
!acp.critical ||
modelBridge === undefined ||
modelBridge.version !==
RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION ||
!modelBridge.critical ||
runtimes.length !== 1
) {
throw new Error(
'Remote Agent does not advertise the required Runtime ACP capability'
)
}
const runtime = runtimes[0]!
if (
runtime.version !== installation.runtimeVersion ||
runtime.bundleDigest !== installation.bundleDigest ||
runtime.acpCapabilitiesDigest !==
installation.acpCapabilitiesDigest ||
!runtime.sessionLoad ||
!runtime.sessionResume
) {
throw new Error(
'Advertised OpenCode Runtime does not match the verified installation'
)
}
}
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from 'vitest'
import {
AGENT_PROTOCOL_VERSION,
type DaemonCapabilities
} from '../../shared/agent-protocol'
import type { RemoteWorkspaceHandle } from '../../shared/remote-agent-contracts'
import type { RemoteWorkspaceProjectBinding } from '../workspace/remote-workspace-access'
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
import type {
AgentInstallationIdentity,
AgentInstallationManager
} from './agent-installation-manager'
import type { AgentProtocolClient } from './agent-protocol-client'
import {
ManagedRemoteWorkspaceAccessFactory,
type ManagedRemoteWorkspaceAccessFactoryOptions
} from './managed-remote-workspace-access-factory'
import type {
RemoteAgentConnection,
RemoteAgentConnectionManager
} from './remote-agent-connection-manager'
const digest = `sha256:${'a'.repeat(64)}`
const binding: RemoteWorkspaceProjectBinding = {
hostId: '00000000-0000-4000-8000-000000000201',
hostRevision: 2,
hostKeyGeneration: 3,
remoteUsername: 'builder',
remoteRootPath: '/srv/project',
workspaceIdentity: 'workspace-identity',
agentInstallationId: 'installation-test',
agentBinaryDigest: digest,
agentVersion: '0.11.0',
agentArchitecture: 'x64',
agentProtocolMajor: AGENT_PROTOCOL_VERSION.major
}
const installation: AgentInstallationIdentity = {
installationId: verifyAgentInstallationId(
binding.agentInstallationId
),
binaryDigest: binding.agentBinaryDigest,
agentVersion: binding.agentVersion,
protocol: { ...AGENT_PROTOCOL_VERSION },
platform: 'linux',
architecture: binding.agentArchitecture,
supervisor: 'detached-on-demand'
}
const handle: RemoteWorkspaceHandle = {
workspaceId: 'workspace-1',
workspaceIdentity: binding.workspaceIdentity,
canonicalDisplayPath: binding.remoteRootPath,
access: 'read-only',
git: 'available',
capabilities: [
'list',
'stat',
'read-text',
'search',
'git-status',
'git-diff'
],
generation: 1
}
describe('ManagedRemoteWorkspaceAccessFactory', () => {
it('verifies installation before network work and owns the connection lease', async () => {
const test = createHarness()
const access = test.factory.create(binding)
await expect(access.getIdentity()).resolves.toEqual({
kind: 'remote',
id: `${binding.hostId}:${binding.workspaceIdentity}`,
canonicalDisplayPath: binding.remoteRootPath,
access: 'read-only'
})
expect(test.events.slice(0, 2)).toEqual([
'agent:ensure',
'connection:acquire'
])
expect(test.protocolMethods).toContain('workspace/validate')
expect(test.protocolMethods).not.toContain('workspace/open')
expect(test.protocolMethods).not.toContain('workspace/resume')
await access.dispose()
await access.dispose()
expect(test.connectionRelease).toHaveBeenCalledOnce()
expect(
test.protocolMethods.filter(
(method) => method === 'workspace/close'
)
).toHaveLength(1)
})
it.each([
[
'installation id',
{
...installation,
installationId:
verifyAgentInstallationId('installation-other')
}
],
[
'binary digest',
{
...installation,
binaryDigest: `sha256:${'b'.repeat(64)}`
}
],
['version', { ...installation, agentVersion: '0.11.1' }],
['architecture', { ...installation, architecture: 'arm64' as const }],
[
'protocol major',
{
...installation,
protocol: {
...installation.protocol,
major: installation.protocol.major + 1
}
}
]
])(
'rejects an installed Agent with a different persisted %s',
async (_field, changedInstallation) => {
const test = createHarness({
ensureInstalled: async () => changedInstallation
})
const access = test.factory.create(binding)
await expect(access.getIdentity()).rejects.toThrow(
'does not match the persisted project binding'
)
expect(test.acquire).not.toHaveBeenCalled()
}
)
it('propagates connection acquisition failures', async () => {
const test = createHarness({
acquireError: new Error('network unavailable')
})
const access = test.factory.create(binding)
await expect(access.getIdentity()).rejects.toThrow(
'network unavailable'
)
expect(test.connectionRelease).not.toHaveBeenCalled()
})
})
type HarnessOptions = {
acquireError?: Error
ensureInstalled?: () => Promise<AgentInstallationIdentity>
}
function createHarness(options: HarnessOptions = {}): {
factory: ManagedRemoteWorkspaceAccessFactory
events: string[]
protocolMethods: string[]
ensureInstalled: ReturnType<typeof vi.fn>
acquire: ReturnType<typeof vi.fn>
connectionRelease: ReturnType<typeof vi.fn>
} {
const events: string[] = []
const protocolMethods: string[] = []
const ensureInstalled = vi.fn(async () => {
events.push('agent:ensure')
return options.ensureInstalled
? await options.ensureInstalled()
: installation
})
const client = {
request: vi.fn(async (method: string) => {
protocolMethods.push(method)
if (method === 'agent/capabilities') {
return capabilities
}
if (method === 'workspace/validate') {
return {
handle,
validatedAt: '2026-08-21T00:00:00.000Z'
}
}
if (method === 'workspace/close') {
return {
workspaceId: handle.workspaceId,
generation: handle.generation,
closed: true
}
}
throw new Error(`Unexpected protocol method: ${method}`)
})
} as unknown as AgentProtocolClient
const connectionRelease = vi.fn(() => {
events.push('connection:release')
})
const connection: RemoteAgentConnection = {
identity: {
cacheKey: 'connection-cache',
hostId: binding.hostId,
hostRevision: binding.hostRevision,
hostKeyGeneration: binding.hostKeyGeneration,
remoteUsername: binding.remoteUsername,
installationId: binding.agentInstallationId,
binaryDigest: binding.agentBinaryDigest,
protocolMajor: binding.agentProtocolMajor,
protocolMinor: installation.protocol.minor
},
status: {
state: 'ready',
installationId: binding.agentInstallationId,
binaryDigest: binding.agentBinaryDigest,
daemonBootId: 'boot-1',
agentVersion: binding.agentVersion,
protocol: { ...installation.protocol },
platform: 'linux',
architecture: binding.agentArchitecture,
supervisor: 'detached-on-demand',
remoteUserIdentity: 'uid:1000',
draining: false
},
capabilities,
client,
state: 'ready',
reconnect: async () => undefined,
refreshCapabilities: async () => capabilities,
updateAcpBinding: async () => undefined,
flushAcpBindings: async () => undefined,
release: connectionRelease
}
const acquire = vi.fn(async () => {
events.push('connection:acquire')
if (options.acquireError) {
throw options.acquireError
}
return connection
})
const factoryOptions = {
installationManager: {
ensureInstalled
} as unknown as Pick<AgentInstallationManager, 'ensureInstalled'>,
connectionManager: {
acquire
} as unknown as Pick<RemoteAgentConnectionManager, 'acquire'>
} satisfies ManagedRemoteWorkspaceAccessFactoryOptions
return {
factory: new ManagedRemoteWorkspaceAccessFactory(factoryOptions),
events,
protocolMethods,
ensureInstalled,
acquire,
connectionRelease
}
}
const capabilities: DaemonCapabilities = {
generation: 1,
capabilities: [
{
name: 'workspace/read',
version: 1,
critical: true
}
],
runtimes: []
}
@@ -0,0 +1,84 @@
import {
RemoteWorkspaceAccess,
type RemoteWorkspaceAccessFactory,
type RemoteWorkspaceProjectBinding
} from '../workspace/remote-workspace-access'
import type { AgentInstallationManager } from './agent-installation-manager'
import type { RemoteAgentConnectionManager } from './remote-agent-connection-manager'
import {
ProtocolRemoteWorkspaceTransport,
type RemoteWorkspaceInstallationIdentityResolver
} from './protocol-remote-workspace-transport'
export type ManagedRemoteWorkspaceAccessFactoryOptions = {
installationManager: Pick<
AgentInstallationManager,
'ensureInstalled'
>
connectionManager: Pick<RemoteAgentConnectionManager, 'acquire'>
}
/**
* Composes persisted project validation with fresh installation and
* connection leases. The resulting WorkspaceAccess remains strictly
* read-only and owns every acquired connection lease.
*/
export class ManagedRemoteWorkspaceAccessFactory
implements RemoteWorkspaceAccessFactory
{
constructor(
private readonly options: ManagedRemoteWorkspaceAccessFactoryOptions
) {}
create(bindingInput: RemoteWorkspaceProjectBinding): RemoteWorkspaceAccess {
const binding = { ...bindingInput }
const resolver: RemoteWorkspaceInstallationIdentityResolver = {
resolve: async (hostId, installationId, signal) => {
if (
hostId !== binding.hostId ||
installationId !== binding.agentInstallationId
) {
throw new Error(
'Remote workspace installation request does not match its project binding'
)
}
signal?.throwIfAborted()
const installation =
await this.options.installationManager.ensureInstalled(
binding.hostId,
{ signal }
)
signal?.throwIfAborted()
assertInstallationMatches(binding, installation)
return { installation }
}
}
return new RemoteWorkspaceAccess(
binding,
new ProtocolRemoteWorkspaceTransport(
this.options.connectionManager,
resolver
)
)
}
}
function assertInstallationMatches(
binding: RemoteWorkspaceProjectBinding,
installation: Awaited<
ReturnType<AgentInstallationManager['ensureInstalled']>
>
): void {
if (
installation.installationId !== binding.agentInstallationId ||
installation.binaryDigest !== binding.agentBinaryDigest ||
installation.agentVersion !== binding.agentVersion ||
installation.architecture !== binding.agentArchitecture ||
installation.protocol.major !== binding.agentProtocolMajor
) {
throw new Error(
'Installed Remote Agent does not match the persisted project binding'
)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,823 @@
import { EventEmitter } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_PROTOCOL_VERSION,
type AgentFrame,
type AttachWelcome,
type DaemonCapabilities
} from '../../shared/agent-protocol'
import {
decodeAgentFrame,
encodeAgentFrame
} from '../../shared/agent-protocol/frame'
import type {
RemoteWorkspaceHandle
} from '../../shared/remote-agent-contracts'
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
import type { AgentAttachTransport } from './agent-attach-transport'
import { AgentProtocolClient } from './agent-protocol-client'
import type {
RemoteAgentConnection,
RemoteAgentConnectionManager,
RemoteAgentInstallationIdentity
} from './remote-agent-connection-manager'
import {
ProtocolRemoteWorkspaceTransport,
ProtocolRemoteWorkspaceTransportError,
type RemoteWorkspaceInstallationIdentityResolver
} from './protocol-remote-workspace-transport'
import type {
RemoteWorkspaceProjectBinding,
RemoteWorkspaceTransportBinding
} from '../workspace/remote-workspace-access'
type ControllerLease = {
controllerId: string
connectionId: string
generation: number
capabilityGeneration: number
}
type ControllerRegistryLike = {
attach(controllerId: string): ControllerLease
assertCurrent(
controllerId: string,
generation: number
): ControllerLease
revokeCapabilities(controllerId: string): number
}
type ProtocolMethodHandler = (
params: unknown,
context: {
controller: ControllerLease
channelId: string
signal?: AbortSignal
}
) => unknown | Promise<unknown>
const controllerRegistryModule =
'../../agent-daemon/controller-registry'
const eventJournalModule = '../../agent-daemon/event-journal'
const protocolServerModule = '../../agent-daemon/protocol-server'
const { ControllerRegistry } = (await import(
controllerRegistryModule
)) as {
ControllerRegistry: new () => ControllerRegistryLike
}
const { EventJournal } = (await import(eventJournalModule)) as {
EventJournal: new (path: string) => { close(): void }
}
const { AgentProtocolServer } = (await import(
protocolServerModule
)) as {
AgentProtocolServer: new (options: {
controllers: ControllerRegistryLike
events: unknown
status: () => unknown
methods: Record<string, ProtocolMethodHandler>
}) => {
accept(socket: unknown, controller: ControllerLease): void
}
}
const temporaryPaths: string[] = []
const digest = `sha256:${'a'.repeat(64)}`
const timestamp = '2026-08-21T00:00:00.000Z'
const binding: RemoteWorkspaceProjectBinding = {
hostId: 'host-1',
hostRevision: 2,
hostKeyGeneration: 3,
remoteUsername: 'builder',
remoteRootPath: '/srv/project',
workspaceIdentity: 'workspace-identity',
agentInstallationId: 'installation-test',
agentBinaryDigest: digest,
agentVersion: '0.11.0',
agentArchitecture: 'x64',
agentProtocolMajor: AGENT_PROTOCOL_VERSION.major
}
const installation: RemoteAgentInstallationIdentity = {
installationId: verifyAgentInstallationId('installation-test'),
binaryDigest: digest,
agentVersion: '0.11.0',
protocol: AGENT_PROTOCOL_VERSION,
platform: 'linux',
architecture: 'x64',
supervisor: 'detached-on-demand'
}
const handle: RemoteWorkspaceHandle = {
workspaceId: 'workspace-1',
workspaceIdentity: binding.workspaceIdentity,
canonicalDisplayPath: binding.remoteRootPath,
access: 'read-only',
git: 'available',
capabilities: [
'list',
'stat',
'read-text',
'search',
'git-status',
'git-diff'
],
generation: 5
}
afterEach(() => {
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('ProtocolRemoteWorkspaceTransport', () => {
it('bridges fixed validate/read/Git methods through a real protocol client and server', async () => {
const calls: string[] = []
const test = createHarness({
methods: workspaceMethods(calls)
})
const durableBinding = transportBinding(test, 1)
test.resolution.durableWorkspaceValidation = {
binding: durableBinding,
workspaceIdentity: binding.workspaceIdentity,
handle,
validatedAt: timestamp
}
const lease = await test.transport.acquireLease(binding)
expect(lease.binding).toEqual(durableBinding)
expect(lease.resumableHandle).toEqual(handle)
await expect(
lease.validateWorkspace({
remoteRootPath: binding.remoteRootPath,
requestedAccess: 'read-only',
requiredCapabilities: [...handle.capabilities]
})
).resolves.toEqual({ handle, validatedAt: timestamp })
await expect(
lease.openWorkspace({
workspaceIdentity: binding.workspaceIdentity,
requestedAccess: 'read-only'
})
).resolves.toEqual(handle)
await expect(
lease.resumeWorkspace({
workspaceId: handle.workspaceId,
generation: handle.generation,
workspaceIdentity: binding.workspaceIdentity
})
).resolves.toEqual({ resumed: true, handle })
await expect(
lease.listWorkspace({
workspaceId: handle.workspaceId,
generation: handle.generation,
relativePath: '',
limit: 10
})
).resolves.toMatchObject({
entries: [{ relativePath: 'README.md', name: 'README.md' }]
})
await expect(
lease.statWorkspace({
workspaceId: handle.workspaceId,
generation: handle.generation,
relativePath: 'README.md'
})
).resolves.toMatchObject({
relativePath: 'README.md',
byteLength: 5
})
await expect(
lease.readWorkspaceText({
workspaceId: handle.workspaceId,
generation: handle.generation,
relativePath: 'README.md',
offsetBytes: 0,
maximumBytes: 10
})
).resolves.toMatchObject({ content: 'hello', bytesRead: 5 })
await expect(
lease.searchWorkspace({
workspaceId: handle.workspaceId,
generation: handle.generation,
query: 'hello',
caseSensitive: true,
limit: 10
})
).resolves.toMatchObject({
matches: [{ relativePath: 'README.md', line: 1 }]
})
await expect(
lease.getGitStatus({
workspaceId: handle.workspaceId,
generation: handle.generation,
includeIgnored: false,
maximumEntries: 10
})
).resolves.toMatchObject({
repositoryIdentity: 'repository-1',
branch: 'main'
})
await expect(
lease.getGitDiff({
workspaceId: handle.workspaceId,
generation: handle.generation,
staged: false,
maximumBytes: 100
})
).resolves.toMatchObject({
repositoryIdentity: 'repository-1',
patch: '+hello\n'
})
await expect(
lease.closeWorkspace({
workspaceId: handle.workspaceId,
generation: handle.generation
})
).resolves.toEqual({
workspaceId: handle.workspaceId,
generation: handle.generation,
closed: true
})
expect(calls).toEqual([
'workspace/validate',
'workspace/open',
'workspace/resume',
'workspace/list',
'workspace/stat',
'workspace/readText',
'workspace/search',
'git/status',
'git/diff',
'workspace/close'
])
lease.release()
expect(test.release).toHaveBeenCalledOnce()
test.close()
})
it('accepts a durable non-Git handle for read operations', async () => {
const nonGitHandle: RemoteWorkspaceHandle = {
...handle,
git: 'not-a-repository',
capabilities: ['list', 'stat', 'read-text', 'search']
}
const test = createHarness()
test.resolution.durableWorkspaceValidation = {
binding: transportBinding(test, 1),
workspaceIdentity: binding.workspaceIdentity,
handle: nonGitHandle,
validatedAt: timestamp
}
const lease = await test.transport.acquireLease(binding)
expect(lease.resumableHandle).toEqual(nonGitHandle)
lease.release()
expect(test.release).toHaveBeenCalledOnce()
test.close()
})
it('fails closed for project, installation, status, and capability mismatches', async () => {
const hostMismatch = createHarness({
identity: { hostRevision: 99 }
})
await expect(
hostMismatch.transport.acquireLease(binding)
).rejects.toMatchObject({ reason: 'binding-mismatch' })
expect(hostMismatch.release).toHaveBeenCalledOnce()
hostMismatch.close()
const installationMismatch = createHarness({
installation: {
...installation,
installationId:
verifyAgentInstallationId('installation-other')
}
})
await expect(
installationMismatch.transport.acquireLease(binding)
).rejects.toMatchObject({ reason: 'binding-mismatch' })
expect(installationMismatch.acquire).not.toHaveBeenCalled()
installationMismatch.close()
const statusMismatch = createHarness({
connectionStatus: { draining: true }
})
await expect(
statusMismatch.transport.acquireLease(binding)
).rejects.toMatchObject({ reason: 'binding-mismatch' })
expect(statusMismatch.release).toHaveBeenCalledOnce()
statusMismatch.close()
const connectionDigestMismatch = createHarness({
identity: { binaryDigest: `sha256:${'b'.repeat(64)}` }
})
await expect(
connectionDigestMismatch.transport.acquireLease(binding)
).rejects.toMatchObject({ reason: 'binding-mismatch' })
expect(connectionDigestMismatch.release).toHaveBeenCalledOnce()
connectionDigestMismatch.close()
for (const changedInstallation of [
{ ...installation, binaryDigest: `sha256:${'b'.repeat(64)}` },
{ ...installation, agentVersion: '0.11.1' },
{ ...installation, architecture: 'arm64' as const }
]) {
const identityMismatch = createHarness({
installation: changedInstallation
})
await expect(
identityMismatch.transport.acquireLease(binding)
).rejects.toMatchObject({ reason: 'binding-mismatch' })
expect(identityMismatch.acquire).not.toHaveBeenCalled()
identityMismatch.close()
}
for (const changedStatus of [
{ binaryDigest: `sha256:${'b'.repeat(64)}` },
{ agentVersion: '0.11.1' },
{ architecture: 'arm64' as const }
]) {
const liveStatusMismatch = createHarness({
connectionStatus: changedStatus
})
await expect(
liveStatusMismatch.transport.acquireLease(binding)
).rejects.toMatchObject({ reason: 'binding-mismatch' })
expect(liveStatusMismatch.release).toHaveBeenCalledOnce()
liveStatusMismatch.close()
}
const methods = workspaceMethods()
delete methods['git/diff']
const capabilityMismatch = createHarness({ methods })
await expect(
capabilityMismatch.transport.acquireLease(binding)
).rejects.toMatchObject({ reason: 'capability-missing' })
expect(capabilityMismatch.release).toHaveBeenCalledOnce()
capabilityMismatch.close()
})
it('rejects stale capability generations before dispatching a workspace call', async () => {
const calls: string[] = []
const test = createHarness({
methods: workspaceMethods(calls)
})
const lease = await test.transport.acquireLease(binding)
test.controllers.revokeCapabilities(test.controller.controllerId)
await expect(
lease.listWorkspace({
workspaceId: handle.workspaceId,
generation: handle.generation,
relativePath: '',
limit: 10
})
).rejects.toEqual(
expect.objectContaining({
reason: 'stale-generation'
})
)
expect(calls).not.toContain('workspace/list')
lease.release()
test.close()
})
it('preserves cancellation through AgentProtocolClient requests', async () => {
let finish!: () => void
const gate = new Promise<void>((resolveGate) => {
finish = resolveGate
})
const methods = workspaceMethods()
methods['workspace/readText'] = vi.fn(async () => {
await gate
return readResult()
})
const test = createHarness({ methods })
const lease = await test.transport.acquireLease(binding)
const controller = new AbortController()
const operation = lease.readWorkspaceText(
{
workspaceId: handle.workspaceId,
generation: handle.generation,
relativePath: 'README.md',
offsetBytes: 0,
maximumBytes: 10
},
controller.signal
)
await vi.waitFor(() => {
expect(methods['workspace/readText']).toHaveBeenCalled()
})
controller.abort(new Error('read canceled'))
await expect(operation).rejects.toThrow('read canceled')
finish()
lease.release()
test.close()
})
it('only exposes resumable state with a complete durable identity match', async () => {
const test = createHarness()
test.resolution.durableWorkspaceValidation = {
binding: {
...transportBinding(test, 1),
capabilityGeneration: 2
},
workspaceIdentity: binding.workspaceIdentity,
handle,
validatedAt: timestamp
}
const lease = await test.transport.acquireLease(binding)
expect(lease.resumableHandle).toBeUndefined()
lease.release()
test.close()
})
it('keeps one connection reference per lease and makes close and release idempotent', async () => {
const closeHandler = vi.fn(
workspaceMethods()['workspace/close']
)
const methods = workspaceMethods()
methods['workspace/close'] = closeHandler
const test = createHarness({ methods })
const [first, second] = await Promise.all([
test.transport.acquireLease(binding),
test.transport.acquireLease(binding)
])
expect(test.acquire).toHaveBeenCalledTimes(2)
const request = {
workspaceId: handle.workspaceId,
generation: handle.generation
}
const [firstClose, duplicateClose] = await Promise.all([
first.closeWorkspace(request),
first.closeWorkspace(request)
])
expect(firstClose).toEqual(duplicateClose)
expect(closeHandler).toHaveBeenCalledOnce()
first.release()
first.release()
expect(test.release).toHaveBeenCalledTimes(1)
second.release()
second.release()
expect(test.release).toHaveBeenCalledTimes(2)
expect(() => first.binding).toThrow(
ProtocolRemoteWorkspaceTransportError
)
test.close()
})
})
type HarnessOptions = {
methods?: Record<string, ProtocolMethodHandler>
identity?: Partial<RemoteAgentConnection['identity']>
installation?: RemoteAgentInstallationIdentity
connectionStatus?: Partial<RemoteAgentConnection['status']>
}
function createHarness(options: HarnessOptions = {}) {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-workspace-transport-'))
temporaryPaths.push(root)
const controllers = new ControllerRegistry()
const controller = controllers.attach('controller-test')
const events = new EventJournal(resolve(root, 'events.sqlite'))
const status = {
state: 'ready' as const,
installationId: binding.agentInstallationId,
binaryDigest: digest,
daemonBootId: 'boot-test',
agentVersion: '0.11.0',
protocol: AGENT_PROTOCOL_VERSION,
platform: 'linux' as const,
architecture: 'x64' as const,
supervisor: 'detached-on-demand' as const,
remoteUserIdentity: 'uid:1000',
draining: false,
...options.connectionStatus
}
const server = new AgentProtocolServer({
controllers,
events,
status: () => status,
methods: options.methods ?? workspaceMethods()
})
const socket = new MemorySocket()
const transport = new MemoryAgentTransport(socket, controller)
socket.onWrite = (data) => transport.accept(data)
server.accept(socket, controller)
const client = new AgentProtocolClient(
transport as unknown as AgentAttachTransport
)
const release = vi.fn()
const currentCapabilities = (): DaemonCapabilities => {
const current = controllers.assertCurrent(
controller.controllerId,
controller.generation
)
const advertised =
Object.keys(options.methods ?? workspaceMethods()).length >= 10
return {
generation: current.capabilityGeneration,
capabilities: advertised
? [
{
name: 'workspace/read',
version: 1,
critical: true
}
]
: [],
runtimes: []
}
}
const acquire = vi.fn(async () => {
let released = false
return {
identity: {
cacheKey: 'connection-cache',
hostId: binding.hostId,
hostRevision: binding.hostRevision,
hostKeyGeneration: binding.hostKeyGeneration,
remoteUsername: binding.remoteUsername,
installationId: binding.agentInstallationId,
binaryDigest: digest,
protocolMajor: AGENT_PROTOCOL_VERSION.major,
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
...options.identity
},
status,
get capabilities(): DaemonCapabilities {
return currentCapabilities()
},
client,
state: 'ready' as const,
reconnect: async () => undefined,
refreshCapabilities: async () => currentCapabilities(),
updateAcpBinding: async () => undefined,
flushAcpBindings: async () => undefined,
release: () => {
if (!released) {
released = true
release()
}
}
} satisfies RemoteAgentConnection
})
const resolution = {
installation: options.installation ?? installation,
durableWorkspaceValidation: undefined
} as {
installation: RemoteAgentInstallationIdentity
durableWorkspaceValidation?:
import('./protocol-remote-workspace-transport').DurableRemoteWorkspaceValidation
}
const resolver: RemoteWorkspaceInstallationIdentityResolver = {
resolve: vi.fn(async () => resolution)
}
const workspaceTransport = new ProtocolRemoteWorkspaceTransport(
{ acquire } as unknown as Pick<
RemoteAgentConnectionManager,
'acquire'
>,
resolver
)
return {
transport: workspaceTransport,
controllers,
controller,
resolution,
acquire,
release,
close: () => {
client.dispose()
socket.destroy()
events.close()
}
}
}
function workspaceMethods(
calls: string[] = []
): Record<string, ProtocolMethodHandler> {
const method = (
name: string,
result: unknown
): ProtocolMethodHandler =>
vi.fn(async () => {
calls.push(name)
return result
})
return {
'workspace/validate': method('workspace/validate', {
handle,
validatedAt: timestamp
}),
'workspace/open': method('workspace/open', handle),
'workspace/resume': method('workspace/resume', {
resumed: true,
handle
}),
'workspace/close': method('workspace/close', {
workspaceId: handle.workspaceId,
generation: handle.generation,
closed: true
}),
'workspace/list': method('workspace/list', {
entries: [
{
relativePath: 'README.md',
name: 'README.md',
kind: 'file',
byteLength: 5,
modifiedAt: timestamp,
digest,
executable: false
}
]
}),
'workspace/stat': method('workspace/stat', {
relativePath: 'README.md',
name: 'README.md',
kind: 'file',
byteLength: 5,
modifiedAt: timestamp,
digest,
executable: false
}),
'workspace/readText': method('workspace/readText', readResult()),
'workspace/search': method('workspace/search', {
matches: [
{
relativePath: 'README.md',
line: 1,
column: 1,
snippet: 'hello'
}
],
truncated: false
}),
'git/status': method('git/status', {
repositoryIdentity: 'repository-1',
branch: 'main',
entries: [],
truncated: false
}),
'git/diff': method('git/diff', {
repositoryIdentity: 'repository-1',
patch: '+hello\n',
byteLength: 7,
truncated: false
})
}
}
function readResult() {
return {
relativePath: 'README.md',
content: 'hello',
offsetBytes: 0,
bytesRead: 5,
totalBytes: 5,
digest,
truncated: false
}
}
function transportBinding(
_test: ReturnType<typeof createHarness>,
capabilityGeneration: number
): RemoteWorkspaceTransportBinding {
return {
hostId: binding.hostId,
hostRevision: binding.hostRevision,
hostKeyGeneration: binding.hostKeyGeneration,
remoteUsername: binding.remoteUsername,
agentInstallationId: binding.agentInstallationId,
agentBinaryDigest: binding.agentBinaryDigest,
agentVersion: binding.agentVersion,
agentArchitecture: binding.agentArchitecture,
agentProtocolMajor: binding.agentProtocolMajor,
capabilityGeneration
}
}
class MemorySocket extends EventEmitter {
onWrite?: (data: Uint8Array) => void
destroyed = false
pause(): this {
return this
}
resume(): this {
return this
}
write(
data: Uint8Array,
callback?: (error?: Error | null) => void
): boolean {
this.onWrite?.(data)
queueMicrotask(() => callback?.())
return true
}
receive(data: Uint8Array): void {
this.emit('data', Buffer.from(data))
}
destroy(): this {
if (!this.destroyed) {
this.destroyed = true
queueMicrotask(() => this.emit('close'))
}
return this
}
}
class MemoryAgentTransport {
readonly welcome: AttachWelcome
#frames: AgentFrame[] = []
#readers: Array<{
resolve: (frame: AgentFrame) => void
reject: (error: unknown) => void
}> = []
#closeListeners = new Set<(error: Error) => void>()
#disposed = false
constructor(
private readonly socket: MemorySocket,
controller: ControllerLease
) {
this.welcome = {
type: 'goodbuddy-agent-welcome',
protocol: AGENT_PROTOCOL_VERSION,
connectionId: controller.connectionId,
generation: controller.generation,
installationId: binding.agentInstallationId,
binaryDigest: digest,
daemonBootId: 'boot-test',
serverNonce: 'server-test'
}
}
async send(frame: AgentFrame): Promise<void> {
if (this.#disposed) {
throw new Error('transport disposed')
}
this.socket.receive(encodeAgentFrame(frame))
}
receive(): Promise<AgentFrame> {
const frame = this.#frames.shift()
if (frame !== undefined) {
return Promise.resolve(frame)
}
return new Promise((resolveFrame, reject) => {
this.#readers.push({ resolve: resolveFrame, reject })
})
}
accept(data: Uint8Array): void {
const frame = decodeAgentFrame(data, {
protocolMajor: AGENT_PROTOCOL_VERSION.major,
maximumProtocolMinor: AGENT_PROTOCOL_VERSION.minor,
connectionId: this.welcome.connectionId,
generation: this.welcome.generation
})
const reader = this.#readers.shift()
if (reader === undefined) {
this.#frames.push(frame)
} else {
reader.resolve(frame)
}
}
onClose(listener: (error: Error) => void): () => void {
this.#closeListeners.add(listener)
return () => this.#closeListeners.delete(listener)
}
dispose(): void {
if (this.#disposed) {
return
}
this.#disposed = true
const error = new Error('transport disposed')
for (const reader of this.#readers.splice(0)) {
reader.reject(error)
}
for (const listener of this.#closeListeners) {
listener(error)
}
this.#closeListeners.clear()
}
}
@@ -0,0 +1,562 @@
import {
REMOTE_WORKSPACE_READ_CAPABILITIES,
remoteWorkspaceHandleSchema,
type RemoteWorkspaceCloseResult,
type RemoteWorkspaceHandle
} from '../../shared/remote-agent-contracts'
import type {
RemoteWorkspaceProjectBinding,
RemoteWorkspaceTransport,
RemoteWorkspaceTransportBinding,
RemoteWorkspaceTransportLease
} from '../workspace/remote-workspace-access'
import type {
AgentProtocolClient,
AgentProtocolParams,
AgentProtocolResult
} from './agent-protocol-client'
import type {
RemoteAgentConnection,
RemoteAgentConnectionManager,
RemoteAgentInstallationIdentity
} from './remote-agent-connection-manager'
const WORKSPACE_READ_CAPABILITY = 'workspace/read'
const WORKSPACE_READ_CAPABILITY_VERSION = 1
export type DurableRemoteWorkspaceValidation = {
binding: RemoteWorkspaceTransportBinding
workspaceIdentity: string
handle: RemoteWorkspaceHandle
validatedAt: string
}
export type RemoteWorkspaceInstallationResolution = {
installation: RemoteAgentInstallationIdentity
/**
* This value must come from durable validation storage. The transport never
* promotes an in-memory handle or an incomplete record into resume state.
*/
durableWorkspaceValidation?: DurableRemoteWorkspaceValidation
}
export interface RemoteWorkspaceInstallationIdentityResolver {
resolve(
hostId: string,
installationId: string,
signal?: AbortSignal
): Promise<RemoteWorkspaceInstallationResolution>
}
export class ProtocolRemoteWorkspaceTransportError extends Error {
constructor(
message: string,
readonly reason:
| 'binding-mismatch'
| 'capability-missing'
| 'stale-generation'
| 'closed'
) {
super(message)
this.name = 'ProtocolRemoteWorkspaceTransportError'
}
}
/**
* The closed, read-only Workspace/Git bridge over AgentProtocolClient.
* Deliberately no generic protocol request method is exposed.
*/
export class ProtocolRemoteWorkspaceTransport
implements RemoteWorkspaceTransport
{
constructor(
private readonly connections: Pick<
RemoteAgentConnectionManager,
'acquire'
>,
private readonly installations:
RemoteWorkspaceInstallationIdentityResolver
) {}
async acquireLease(
binding: RemoteWorkspaceProjectBinding,
signal?: AbortSignal
): Promise<RemoteWorkspaceTransportLease> {
signal?.throwIfAborted()
const projectBinding = { ...binding }
const resolved = await this.installations.resolve(
projectBinding.hostId,
projectBinding.agentInstallationId,
signal
)
let resolution: RemoteWorkspaceInstallationResolution | undefined
let connection: RemoteAgentConnection | undefined
let retained = false
try {
resolution = snapshotResolution(resolved)
signal?.throwIfAborted()
assertResolution(projectBinding, resolution)
connection = await this.connections.acquire(
projectBinding.hostId,
requireWorkspaceReadCapability(resolution.installation),
signal
)
const status = connection.status
assertConnectionIdentity(projectBinding, resolution, connection)
assertConnectionStatus(projectBinding, resolution, status)
const capabilities = await connection.client.request(
'agent/capabilities',
{},
{ signal }
)
signal?.throwIfAborted()
assertWorkspaceReadCapability(capabilities)
if (
capabilities.generation !==
connection.capabilities.generation
) {
throw staleGenerationError()
}
const transportBinding = liveBinding(
projectBinding,
resolution,
connection,
capabilities.generation
)
const resumableHandle = validatedResumableHandle(
projectBinding,
transportBinding,
resolution.durableWorkspaceValidation
)
const lease = createLease({
binding: projectBinding,
resolution,
connection,
acquiredCapabilityGeneration: capabilities.generation,
resumableHandle
})
retained = true
return lease
} finally {
if (!retained) {
releaseLeaseResources(connection)
}
}
}
}
function snapshotResolution(
resolution: RemoteWorkspaceInstallationResolution
): RemoteWorkspaceInstallationResolution {
const installation = resolution.installation
const durable = resolution.durableWorkspaceValidation
return {
installation: {
...installation,
...(installation.protocol === undefined
? {}
: { protocol: { ...installation.protocol } }),
...(installation.requiredCapabilities === undefined
? {}
: {
requiredCapabilities:
installation.requiredCapabilities.map((entry) => ({
...entry
}))
})
},
...(durable === undefined
? {}
: {
durableWorkspaceValidation: {
binding: { ...durable.binding },
workspaceIdentity: durable.workspaceIdentity,
handle: {
...durable.handle,
capabilities: [...durable.handle.capabilities]
},
validatedAt: durable.validatedAt
}
})
}
}
type LeaseState = {
binding: RemoteWorkspaceProjectBinding
resolution: RemoteWorkspaceInstallationResolution
connection: RemoteAgentConnection
acquiredCapabilityGeneration: number
resumableHandle?: RemoteWorkspaceHandle
}
function createLease(state: LeaseState): RemoteWorkspaceTransportLease {
let released = false
let closing:
| {
workspaceId: string
generation: number
promise: Promise<RemoteWorkspaceCloseResult>
}
| undefined
const assertOpen = (): void => {
if (released) {
throw new ProtocolRemoteWorkspaceTransportError(
'Remote workspace transport lease is closed',
'closed'
)
}
}
const currentClient = (): AgentProtocolClient => {
assertOpen()
if (state.connection.state !== 'ready') {
throw staleGenerationError()
}
assertConnectionIdentity(
state.binding,
state.resolution,
state.connection
)
assertConnectionStatus(
state.binding,
state.resolution,
state.connection.status
)
if (
state.connection.capabilities.generation !==
state.acquiredCapabilityGeneration
) {
throw staleGenerationError()
}
return state.connection.client
}
const request = async <
M extends WorkspaceReadProtocolMethod
>(
method: M,
params: AgentProtocolParams<M>,
signal?: AbortSignal
): Promise<AgentProtocolResult<M>> => {
signal?.throwIfAborted()
const client = currentClient()
const capabilities = state.connection.capabilities
assertWorkspaceReadCapability(capabilities)
if (
capabilities.generation !==
state.acquiredCapabilityGeneration
) {
throw staleGenerationError()
}
if (currentClient() !== client) {
throw staleGenerationError()
}
return await client.request(method, params, { signal })
}
return {
get binding() {
currentClient()
return liveBinding(
state.binding,
state.resolution,
state.connection,
state.acquiredCapabilityGeneration
)
},
...(state.resumableHandle === undefined
? {}
: { resumableHandle: state.resumableHandle }),
validateWorkspace: async (value, signal) =>
await request('workspace/validate', value, signal),
openWorkspace: async (value, signal) =>
await request('workspace/open', value, signal),
resumeWorkspace: async (value, signal) =>
await request('workspace/resume', value, signal),
closeWorkspace: (value) => {
currentClient()
if (
closing?.workspaceId === value.workspaceId &&
closing.generation === value.generation
) {
return closing.promise
}
const promise = request('workspace/close', value)
closing = {
workspaceId: value.workspaceId,
generation: value.generation,
promise
}
return promise
},
listWorkspace: async (value, signal) =>
await request('workspace/list', value, signal),
statWorkspace: async (value, signal) =>
await request('workspace/stat', value, signal),
readWorkspaceText: async (value, signal) =>
await request('workspace/readText', value, signal),
searchWorkspace: async (value, signal) =>
await request('workspace/search', value, signal),
getGitStatus: async (value, signal) =>
await request('git/status', value, signal),
getGitDiff: async (value, signal) =>
await request('git/diff', value, signal),
release: () => {
if (released) {
return
}
released = true
releaseLeaseResources(
state.connection
)
}
}
}
function releaseLeaseResources(
connection: RemoteAgentConnection | undefined
): void {
const errors: unknown[] = []
if (connection !== undefined) {
try {
connection.release()
} catch (error) {
errors.push(error)
}
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(
errors,
'Failed to release remote workspace transport resources'
)
}
}
type WorkspaceReadProtocolMethod =
| 'workspace/validate'
| 'workspace/open'
| 'workspace/resume'
| 'workspace/close'
| 'workspace/list'
| 'workspace/stat'
| 'workspace/readText'
| 'workspace/search'
| 'git/status'
| 'git/diff'
function requireWorkspaceReadCapability(
installation: RemoteAgentInstallationIdentity
): RemoteAgentInstallationIdentity {
const requirements = [...(installation.requiredCapabilities ?? [])]
const index = requirements.findIndex(
(requirement) => requirement.name === WORKSPACE_READ_CAPABILITY
)
const required = {
name: WORKSPACE_READ_CAPABILITY,
minimumVersion: WORKSPACE_READ_CAPABILITY_VERSION,
critical: true
}
if (index < 0) {
requirements.push(required)
} else {
const current = requirements[index]!
requirements[index] = {
name: current.name,
...(current.exactVersion === undefined
? {
minimumVersion: Math.max(
current.minimumVersion!,
WORKSPACE_READ_CAPABILITY_VERSION
)
}
: { exactVersion: current.exactVersion }),
critical: true
}
}
return {
...installation,
requiredCapabilities: requirements
}
}
function assertResolution(
binding: RemoteWorkspaceProjectBinding,
resolution: RemoteWorkspaceInstallationResolution
): void {
const protocol = resolution.installation.protocol
if (
resolution.installation.installationId !==
binding.agentInstallationId ||
resolution.installation.binaryDigest !==
binding.agentBinaryDigest ||
resolution.installation.agentVersion !== binding.agentVersion ||
resolution.installation.architecture !==
binding.agentArchitecture ||
protocol === undefined ||
protocol.major !== binding.agentProtocolMajor
) {
throw bindingMismatchError()
}
}
function assertConnectionIdentity(
binding: RemoteWorkspaceProjectBinding,
resolution: RemoteWorkspaceInstallationResolution,
connection: RemoteAgentConnection
): void {
const identity = connection.identity
const protocol = resolution.installation.protocol
if (
identity.hostId !== binding.hostId ||
identity.hostRevision !== binding.hostRevision ||
identity.hostKeyGeneration !== binding.hostKeyGeneration ||
identity.remoteUsername !== binding.remoteUsername ||
identity.installationId !== binding.agentInstallationId ||
identity.binaryDigest !== binding.agentBinaryDigest ||
identity.binaryDigest !== resolution.installation.binaryDigest ||
identity.protocolMajor !== binding.agentProtocolMajor ||
protocol === undefined ||
identity.protocolMinor !== protocol.minor
) {
throw bindingMismatchError()
}
}
function assertConnectionStatus(
binding: RemoteWorkspaceProjectBinding,
resolution: RemoteWorkspaceInstallationResolution,
status: RemoteAgentConnection['status']
): void {
const installation = resolution.installation
if (
status.state !== 'ready' ||
status.draining ||
status.installationId !== binding.agentInstallationId ||
status.binaryDigest !== binding.agentBinaryDigest ||
status.binaryDigest !== installation.binaryDigest ||
status.agentVersion !== binding.agentVersion ||
status.agentVersion !== installation.agentVersion ||
status.architecture !== binding.agentArchitecture ||
status.architecture !== installation.architecture ||
status.protocol.major !== binding.agentProtocolMajor ||
installation.protocol === undefined ||
status.protocol.minor !== installation.protocol.minor ||
status.platform !== installation.platform ||
status.supervisor !== installation.supervisor ||
installation.agentVersion === undefined
) {
throw bindingMismatchError()
}
}
function assertWorkspaceReadCapability(
capabilities: RemoteAgentConnection['capabilities']
): void {
const capability = capabilities.capabilities.find(
(entry) => entry.name === WORKSPACE_READ_CAPABILITY
)
if (
!Number.isSafeInteger(capabilities.generation) ||
capabilities.generation < 1 ||
capability === undefined ||
capability.version < WORKSPACE_READ_CAPABILITY_VERSION ||
!capability.critical
) {
throw new ProtocolRemoteWorkspaceTransportError(
'Remote Agent does not advertise the critical workspace/read capability',
'capability-missing'
)
}
}
function liveBinding(
binding: RemoteWorkspaceProjectBinding,
resolution: RemoteWorkspaceInstallationResolution,
connection: RemoteAgentConnection,
capabilityGeneration: number
): RemoteWorkspaceTransportBinding {
assertConnectionIdentity(binding, resolution, connection)
assertConnectionStatus(binding, resolution, connection.status)
if (
connection.capabilities.generation !== capabilityGeneration
) {
throw staleGenerationError()
}
return {
hostId: connection.identity.hostId,
hostRevision: connection.identity.hostRevision,
hostKeyGeneration: connection.identity.hostKeyGeneration,
remoteUsername: connection.identity.remoteUsername,
agentInstallationId: connection.identity.installationId,
agentBinaryDigest: connection.identity.binaryDigest,
agentVersion: connection.status.agentVersion,
agentArchitecture: connection.status.architecture,
agentProtocolMajor: connection.identity.protocolMajor,
capabilityGeneration
}
}
function validatedResumableHandle(
binding: RemoteWorkspaceProjectBinding,
transportBinding: RemoteWorkspaceTransportBinding,
validation: DurableRemoteWorkspaceValidation | undefined
): RemoteWorkspaceHandle | undefined {
if (validation === undefined) {
return undefined
}
const timestamp = Date.parse(validation.validatedAt)
if (
!Number.isFinite(timestamp) ||
validation.workspaceIdentity !== binding.workspaceIdentity ||
!sameTransportBinding(validation.binding, transportBinding)
) {
return undefined
}
const parsed = remoteWorkspaceHandleSchema.safeParse(validation.handle)
if (
!parsed.success ||
parsed.data.workspaceIdentity !== binding.workspaceIdentity ||
parsed.data.access !== 'read-only' ||
REMOTE_WORKSPACE_READ_CAPABILITIES.some(
(capability) => !parsed.data.capabilities.includes(capability)
)
) {
return undefined
}
return parsed.data
}
function sameTransportBinding(
left: RemoteWorkspaceTransportBinding,
right: RemoteWorkspaceTransportBinding
): boolean {
return (
left.hostId === right.hostId &&
left.hostRevision === right.hostRevision &&
left.hostKeyGeneration === right.hostKeyGeneration &&
left.remoteUsername === right.remoteUsername &&
left.agentInstallationId === right.agentInstallationId &&
left.agentBinaryDigest === right.agentBinaryDigest &&
left.agentVersion === right.agentVersion &&
left.agentArchitecture === right.agentArchitecture &&
left.agentProtocolMajor === right.agentProtocolMajor &&
left.capabilityGeneration === right.capabilityGeneration
)
}
function bindingMismatchError(): ProtocolRemoteWorkspaceTransportError {
return new ProtocolRemoteWorkspaceTransportError(
'Remote workspace project binding does not match the live Agent connection',
'binding-mismatch'
)
}
function staleGenerationError(): ProtocolRemoteWorkspaceTransportError {
return new ProtocolRemoteWorkspaceTransportError(
'Remote workspace capability generation is stale',
'stale-generation'
)
}
@@ -0,0 +1,588 @@
import { describe, expect, it, vi } from 'vitest'
import {
AGENT_PROTOCOL_VERSION,
type AttachWelcome,
type DaemonCapabilities
} from '../../shared/agent-protocol'
import type {
SshConnectionLease,
SshConnectionPoolTarget
} from '../ssh/ssh-connection-pool'
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
import type { AgentAttachTransport } from './agent-attach-transport'
import type {
AgentProtocolClient,
AgentProtocolMethod
} from './agent-protocol-client'
import {
ControllerStateStore,
type ControllerStateFile,
type PersistedControllerState
} from './controller-state-store'
import {
RemoteAgentConnectionManager,
type RemoteAgentSshPool
} from './remote-agent-connection-manager'
class MemoryStateFile implements ControllerStateFile {
value?: unknown
async read(): Promise<unknown | undefined> {
return structuredClone(this.value)
}
async write(value: PersistedControllerState): Promise<void> {
this.value = structuredClone(value)
}
}
class FakeProtocolClient {
readonly connectionId: string
readonly generation: number
capabilitiesGate?: Promise<void>
resumeGate?: Promise<void>
capabilities: DaemonCapabilities = {
generation: 1,
capabilities: [
{ name: 'workspace/read', version: 1, critical: true }
],
runtimes: []
}
disposed = false
readonly requests: Array<{ method: AgentProtocolMethod; params: unknown }> =
[]
#closeListeners = new Set<(error: Error) => void>()
constructor(index = 0) {
this.connectionId = `connection-${index + 1}`
this.generation = index + 2
}
async request(
method: AgentProtocolMethod,
params: unknown
): Promise<unknown> {
this.requests.push({ method, params })
if (method === 'agent/status') {
return {
state: 'ready',
installationId: 'agent-v1',
binaryDigest: `sha256:${'a'.repeat(64)}`,
daemonBootId: 'boot-1',
agentVersion: '1.0.0',
protocol: AGENT_PROTOCOL_VERSION,
platform: 'linux',
architecture: 'x64',
supervisor: 'detached-on-demand',
remoteUserIdentity: 'user-1',
draining: false
}
}
if (method === 'agent/capabilities') {
await this.capabilitiesGate
return structuredClone(this.capabilities)
}
if (method === 'controller/resume') {
await this.resumeGate
const request = params as {
daemonBootId: string
capabilityGeneration: number
}
return {
resumed: true,
generation: this.generation,
daemonBootId: request.daemonBootId,
capabilityGeneration: request.capabilityGeneration,
leaseDeadlineAt: '2030-01-01T00:00:00.000Z'
}
}
throw new Error(`Unexpected method ${method}`)
}
onClose(listener: (error: Error) => void): () => void {
this.#closeListeners.add(listener)
return () => this.#closeListeners.delete(listener)
}
transportClose(): void {
for (const listener of this.#closeListeners) {
listener(new Error('transport closed'))
}
}
dispose(): void {
this.disposed = true
}
}
function target(revision = 1): SshConnectionPoolTarget {
return {
host: {
id: 'host-1',
name: 'Builder',
hostname: 'builder.example',
port: 22,
username: 'goodbuddy',
authentication: 'password',
password: 'not-persisted',
hostKey: {
algorithm: 'ssh-ed25519',
publicKeyBase64: 'a2V5',
fingerprintSha256: `SHA256:${'a'.repeat(43)}`,
generation: revision
}
},
hostRevision: revision,
hostKeyGeneration: revision
}
}
const installation = {
installationId: verifyAgentInstallationId('agent-v1'),
binaryDigest: `sha256:${'a'.repeat(64)}`,
agentVersion: '1.0.0',
protocol: AGENT_PROTOCOL_VERSION,
platform: 'linux' as const,
architecture: 'x64' as const,
supervisor: 'detached-on-demand' as const,
requiredCapabilities: [
{ name: 'workspace/read', minimumVersion: 1, critical: true }
]
}
function harness(
connectGate?: Promise<void>,
reconnectResumeGate?: Promise<void>
) {
let currentTarget = target()
const release = vi.fn()
const sshLease = {
identity: {
hostId: 'host-1',
hostRevision: 1,
hostKeyGeneration: 1,
authenticationIdentity: 'b'.repeat(64)
},
isUsable: () => true,
release
} as unknown as SshConnectionLease
const sshPool: RemoteAgentSshPool = {
acquire: vi.fn(async () => sshLease),
disposeHost: vi.fn()
}
const resolver = {
resolve: vi.fn(async () => currentTarget)
}
const clients: FakeProtocolClient[] = []
const stateFile = new MemoryStateFile()
const connectTransport = vi.fn(async () => {
await connectGate
const welcome: AttachWelcome = {
type: 'goodbuddy-agent-welcome',
protocol: AGENT_PROTOCOL_VERSION,
connectionId: `connection-${connectTransport.mock.calls.length}`,
generation: connectTransport.mock.calls.length + 1,
installationId: 'agent-v1',
binaryDigest: `sha256:${'a'.repeat(64)}`,
daemonBootId: 'boot-1',
serverNonce: 'server-1'
}
return {
welcome,
dispose: vi.fn()
} as unknown as AgentAttachTransport
})
const manager = new RemoteAgentConnectionManager({
resolver,
sshPool,
controllerState: new ControllerStateStore(stateFile),
goodBuddyVersion: '0.11.0',
idleTimeoutMs: 0,
connectTransport,
createProtocolClient: () => {
const client = new FakeProtocolClient(clients.length)
if (clients.length > 0) {
client.resumeGate = reconnectResumeGate
}
clients.push(client)
return client as unknown as AgentProtocolClient
}
})
return {
manager,
resolver,
sshPool,
release,
connectTransport,
clients,
stateFile,
changeRevision: (revision: number) => {
currentTarget = target(revision)
}
}
}
describe('RemoteAgentConnectionManager', () => {
it('deduplicates concurrent connects and ref-counts the shared connection', async () => {
const test = harness()
const [first, second] = await Promise.all([
test.manager.acquire('host-1', installation),
test.manager.acquire('host-1', installation)
])
expect(first.client).toBe(second.client)
expect(test.connectTransport).toHaveBeenCalledTimes(1)
expect(test.sshPool.acquire).toHaveBeenCalledTimes(1)
first.release()
expect(test.release).not.toHaveBeenCalled()
second.release()
expect(test.release).toHaveBeenCalledTimes(1)
await test.manager.dispose()
})
it('isolates cache entries across Host revisions', async () => {
const test = harness()
const first = await test.manager.acquire('host-1', installation)
test.changeRevision(2)
const second = await test.manager.acquire('host-1', installation)
expect(first.identity.cacheKey).not.toBe(second.identity.cacheKey)
expect(test.connectTransport).toHaveBeenCalledTimes(2)
first.release()
second.release()
await test.manager.dispose()
})
it('cancels one waiter without canceling a shared connect', async () => {
let openTransport!: () => void
const gate = new Promise<void>((resolve) => {
openTransport = resolve
})
const test = harness(gate)
const controller = new AbortController()
const canceled = test.manager.acquire(
'host-1',
installation,
controller.signal
)
const retained = test.manager.acquire('host-1', installation)
await vi.waitFor(() =>
expect(test.connectTransport).toHaveBeenCalledTimes(1)
)
controller.abort(new Error('waiter canceled'))
await expect(canceled).rejects.toThrow('waiter canceled')
openTransport()
const lease = await retained
expect(lease.state).toBe('ready')
lease.release()
await test.manager.dispose()
})
it('invalidates every connection for a Host without stopping its daemon', async () => {
const test = harness()
const lease = await test.manager.acquire('host-1', installation)
await test.manager.invalidateHost('host-1')
expect(test.sshPool.disposeHost).toHaveBeenCalledWith('host-1')
expect(() => lease.client).toThrow()
lease.release()
await test.manager.dispose()
})
it('does not publish a connection that finishes after Host invalidation', async () => {
let finishTransport!: () => void
const gate = new Promise<void>((resolve) => {
finishTransport = resolve
})
const test = harness(gate)
const acquiring = test.manager.acquire('host-1', installation)
await vi.waitFor(() =>
expect(test.connectTransport).toHaveBeenCalledOnce()
)
await test.manager.invalidateHost('host-1')
finishTransport()
await expect(acquiring).rejects.toMatchObject({
disposition: 'transient',
reason: 'network'
})
expect(test.clients[0]?.disposed).toBe(true)
expect(test.release).toHaveBeenCalledOnce()
await test.manager.dispose()
})
it('refreshes and validates capabilities on the current client generation', async () => {
const test = harness()
const lease = await test.manager.acquire('host-1', installation)
const refreshed: DaemonCapabilities = {
generation: 2,
capabilities: [
{ name: 'workspace/read', version: 1, critical: true },
{ name: 'runtime/acp', version: 3, critical: true }
],
runtimes: [
{
runtimeId: 'opencode',
version: '1.0.0',
bundleDigest: `sha256:${'b'.repeat(64)}`,
acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}`,
sessionLoad: true,
sessionResume: true
}
]
}
test.clients[0]!.capabilities = refreshed
await expect(lease.refreshCapabilities()).resolves.toEqual(
refreshed
)
expect(lease.capabilities).toEqual(refreshed)
test.clients[0]!.capabilities = {
generation: 3,
capabilities: [
{ name: 'runtime/acp', version: 3, critical: true }
],
runtimes: refreshed.runtimes
}
await expect(lease.refreshCapabilities()).rejects.toMatchObject({
reason: 'capability',
disposition: 'terminal'
})
expect(lease.capabilities).toEqual(refreshed)
lease.release()
await test.manager.dispose()
})
it('rejects versions above an exact capability requirement', async () => {
const test = harness()
const lease = await test.manager.acquire('host-1', {
...installation,
requiredCapabilities: [
{
name: 'workspace/read',
exactVersion: 1,
critical: true
}
]
})
test.clients[0]!.capabilities = {
generation: 2,
capabilities: [
{ name: 'workspace/read', version: 2, critical: true }
],
runtimes: []
}
await expect(lease.refreshCapabilities()).rejects.toMatchObject({
reason: 'capability',
disposition: 'terminal'
})
lease.release()
await test.manager.dispose()
})
it('refuses capability refresh after its lease is released', async () => {
const test = harness()
const lease = await test.manager.acquire('host-1', installation)
lease.release()
await expect(lease.refreshCapabilities()).rejects.toThrow(
'lease is released'
)
await test.manager.dispose()
})
it('does not publish a capability refresh after its lease is released in flight', async () => {
const test = harness()
const [refreshing, retained] = await Promise.all([
test.manager.acquire('host-1', installation),
test.manager.acquire('host-1', installation)
])
let finishRefresh!: () => void
test.clients[0]!.capabilitiesGate = new Promise<void>((resolve) => {
finishRefresh = resolve
})
test.clients[0]!.capabilities = {
generation: 2,
capabilities: [
{ name: 'workspace/read', version: 1, critical: true }
],
runtimes: []
}
const refresh = refreshing.refreshCapabilities()
refreshing.release()
finishRefresh()
await expect(refresh).rejects.toThrow('lease is released')
expect(retained.capabilities.generation).toBe(1)
retained.release()
await test.manager.dispose()
})
it('proves the exact predecessor tuple and notifies retained leases after a client swap', async () => {
const test = harness()
const lease = await test.manager.acquire('host-1', installation)
const originalClient = lease.client
await lease.updateAcpBinding('binding-1', {
bindingId: 'binding-1',
channelId: 'binding-1',
channelEpoch: '9',
cursors: {
lastOutboundJournaledSequence: '2',
lastOutboundDeliveredSequence: '2',
lastInboundJournaledSequence: '4',
lastMainAckSequence: '3'
}
})
const changed = vi.fn()
lease.onClientChange?.(changed)
test.clients[0]!.transportClose()
expect(lease.state).toBe('offline')
await lease.reconnect()
expect(lease.client).not.toBe(originalClient)
expect(lease.client.generation).toBe(3)
expect(changed).toHaveBeenCalledOnce()
expect(
test.clients[1]!.requests.find(
(request) => request.method === 'controller/resume'
)
).toEqual({
method: 'controller/resume',
params: {
previousGeneration: 2,
previousConnectionId: 'connection-1',
daemonBootId: 'boot-1',
capabilityGeneration: 1
}
})
lease.release()
await test.manager.dispose()
})
it('persists ACP recovery cursors while the retained transport is offline', async () => {
const test = harness()
const lease = await test.manager.acquire('host-1', installation)
await lease.updateAcpBinding('binding-1', {
bindingId: 'binding-1',
channelId: 'binding-1',
channelEpoch: '9',
cursors: {
lastOutboundJournaledSequence: '2',
lastOutboundDeliveredSequence: '2',
lastInboundJournaledSequence: '3',
lastMainAckSequence: '1'
}
})
test.clients[0]!.transportClose()
expect(lease.state).toBe('offline')
await expect(
lease.updateAcpBinding('binding-1', {
bindingId: 'binding-1',
channelId: 'binding-1',
channelEpoch: '9',
cursors: {
lastOutboundJournaledSequence: '2',
lastOutboundDeliveredSequence: '2',
lastInboundJournaledSequence: '3',
lastMainAckSequence: '2'
}
})
).resolves.toBeUndefined()
await expect(lease.flushAcpBindings()).resolves.toBeUndefined()
expect(test.stateFile.value).toMatchObject({
connections: [
{
acpBindings: [
{
bindingId: 'binding-1',
cursors: {
lastMainAckSequence: '2'
}
}
]
}
]
})
lease.release()
await expect(
lease.flushAcpBindings()
).rejects.toThrow(/released/iu)
await test.manager.dispose()
})
it('preserves ACP cursors persisted while reconnect resume is in flight', async () => {
let finishResume!: () => void
const resumeGate = new Promise<void>((resolve) => {
finishResume = resolve
})
const test = harness(undefined, resumeGate)
const lease = await test.manager.acquire('host-1', installation)
await lease.updateAcpBinding('binding-1', {
bindingId: 'binding-1',
channelId: 'binding-1',
channelEpoch: '9',
cursors: {
lastOutboundJournaledSequence: '2',
lastOutboundDeliveredSequence: '2',
lastInboundJournaledSequence: '3',
lastMainAckSequence: '1'
}
})
await lease.flushAcpBindings()
test.clients[0]!.transportClose()
const reconnect = lease.reconnect()
await vi.waitFor(() =>
expect(
test.clients[1]?.requests.some(
(request) => request.method === 'controller/resume'
)
).toBe(true)
)
await lease.updateAcpBinding('binding-1', {
bindingId: 'binding-1',
channelId: 'binding-1',
channelEpoch: '9',
cursors: {
lastOutboundJournaledSequence: '2',
lastOutboundDeliveredSequence: '2',
lastInboundJournaledSequence: '3',
lastMainAckSequence: '2'
}
})
await lease.flushAcpBindings()
finishResume()
await reconnect
expect(test.stateFile.value).toMatchObject({
connections: [
{
acpBindings: [
{
bindingId: 'binding-1',
cursors: {
lastMainAckSequence: '2'
}
}
]
}
]
})
lease.release()
await test.manager.dispose()
})
it('shuts down channels and refuses later acquisition', async () => {
const test = harness()
const lease = await test.manager.acquire('host-1', installation)
await test.manager.dispose()
expect(test.clients[0]!.disposed).toBe(true)
expect(() => lease.client).toThrow()
await expect(
test.manager.acquire('host-1', installation)
).rejects.toMatchObject({ reason: 'shutdown' })
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,285 @@
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { SshConnectionPoolTarget } from '../ssh/ssh-connection-pool'
import type { SshHostStore } from '../ssh/ssh-host-store'
import type { AgentInstallationManager } from './agent-installation-manager'
import type { ControllerStateStore } from './controller-state-store'
import type { RemoteAgentConnectionManager } from './remote-agent-connection-manager'
import {
RemoteAgentServices,
type RemoteAgentServicesOptions
} from './remote-agent-services'
type Factories = NonNullable<RemoteAgentServicesOptions['factories']>
type InstallationManagerOptions = Parameters<
NonNullable<Factories['createAgentInstallationManager']>
>[0]
type ConnectionManagerOptions = Parameters<
NonNullable<Factories['createConnectionManager']>
>[0]
const target: SshConnectionPoolTarget = {
host: {
id: 'host-1',
name: 'Builder',
hostname: 'builder.example',
port: 22,
username: 'goodbuddy',
authentication: 'password',
password: 'not-persisted',
hostKey: {
algorithm: 'ssh-ed25519',
publicKeyBase64: 'a2V5',
fingerprintSha256: `SHA256:${'a'.repeat(43)}`,
generation: 1
}
},
hostRevision: 1,
hostKeyGeneration: 1
}
function harness(overrides: {
installationDispose?: () => Promise<void>
connectionDispose?: () => Promise<void>
controllerFlush?: () => Promise<void>
controllerDispose?: () => void
poolDispose?: () => void
invalidateHost?: (hostId: string) => Promise<void>
} = {}) {
const resolveConnectionTarget = vi.fn(async () => target)
const sshHostStore = {
resolveConnectionTarget
} as unknown as SshHostStore
const sshPool = {
acquire: vi.fn(),
disposeHost: vi.fn(),
dispose: vi.fn(overrides.poolDispose ?? (() => undefined))
}
const controllerState = {
flush: vi.fn(overrides.controllerFlush ?? (async () => undefined)),
dispose: vi.fn(overrides.controllerDispose ?? (() => undefined))
}
const installationManager = {
ensureInstalled: vi.fn(),
dispose: vi.fn(
overrides.installationDispose ?? (async () => undefined)
)
}
const connectionManager = {
acquire: vi.fn(),
invalidateHost: vi.fn(
overrides.invalidateHost ?? (async () => undefined)
),
dispose: vi.fn(
overrides.connectionDispose ?? (async () => undefined)
)
}
const createSshConnectionPool = vi.fn(() => sshPool)
const createControllerStateStore = vi.fn(
(filePath: string) => {
void filePath
return controllerState as unknown as ControllerStateStore
}
)
const createAgentInstallationManager = vi.fn(
(options: InstallationManagerOptions) => {
void options
return installationManager as unknown as AgentInstallationManager
}
)
const createConnectionManager = vi.fn(
(options: ConnectionManagerOptions) => {
void options
return connectionManager as unknown as RemoteAgentConnectionManager
}
)
const services = new RemoteAgentServices({
sshHostStore,
goodBuddyVersion: '0.11.0',
userDataPath: join('profile', 'GoodBuddy'),
appPath: join('workspace', 'goodbuddy'),
resourcesPath: join('installed', 'resources'),
packaged: false,
factories: {
createSshConnectionPool:
createSshConnectionPool as never,
createControllerStateStore,
createAgentInstallationManager,
createConnectionManager
}
})
return {
services,
resolveConnectionTarget,
sshPool,
controllerState,
installationManager,
connectionManager,
createSshConnectionPool,
createControllerStateStore,
createAgentInstallationManager,
createConnectionManager
}
}
describe('RemoteAgentServices', () => {
it('shares one atomic host resolver and SSH pool across services', async () => {
const instance = harness()
const installerOptions =
instance.createAgentInstallationManager.mock.calls[0]?.[0]
const connectionOptions =
instance.createConnectionManager.mock.calls[0]?.[0]
expect(instance.createSshConnectionPool).toHaveBeenCalledOnce()
expect(installerOptions?.resolver).toBe(
connectionOptions?.resolver
)
expect(installerOptions?.resolver).toBe(
instance.services.targetResolver
)
expect(installerOptions?.sshPool).toBe(connectionOptions?.sshPool)
expect(installerOptions?.sshPool).toBe(instance.services.sshPool)
await expect(
instance.services.targetResolver.resolve('host-1')
).resolves.toBe(target)
expect(instance.resolveConnectionTarget).toHaveBeenCalledWith(
'host-1'
)
})
it('constructs lazily with bundled paths and no host or network work', () => {
const instance = harness()
expect(instance.resolveConnectionTarget).not.toHaveBeenCalled()
expect(instance.sshPool.acquire).not.toHaveBeenCalled()
expect(instance.installationManager.ensureInstalled).not.toHaveBeenCalled()
expect(instance.connectionManager.acquire).not.toHaveBeenCalled()
expect(instance.services.resourcePaths).toEqual({
keyRegistryPath: join(
'workspace',
'goodbuddy',
'resources',
'agent-release-keys.json'
),
runtimeLockPath: join(
'workspace',
'goodbuddy',
'agent-runtime-lock.json'
),
bundleDirectories: {
x64: join(
'workspace',
'goodbuddy',
'.agent-resources',
'linux-x64'
),
arm64: join(
'workspace',
'goodbuddy',
'.agent-resources',
'linux-arm64'
)
}
})
expect(
instance.createControllerStateStore
).toHaveBeenCalledWith(
join(
'profile',
'GoodBuddy',
'remote-agent-controller-state.json'
)
)
})
it('keeps default service construction and shutdown lazy', async () => {
const resolveConnectionTarget = vi.fn(async () => target)
const services = new RemoteAgentServices({
sshHostStore: {
resolveConnectionTarget
} as unknown as SshHostStore,
goodBuddyVersion: '0.11.0',
userDataPath: join('profile', 'GoodBuddy'),
appPath: join('workspace', 'goodbuddy'),
resourcesPath: join('installed', 'resources'),
packaged: false
})
expect(resolveConnectionTarget).not.toHaveBeenCalled()
await services.dispose()
expect(resolveConnectionTarget).not.toHaveBeenCalled()
})
it('delegates credential-free host invalidation immediately', async () => {
let finishPersistence: (() => void) | undefined
const persistence = new Promise<void>((resolve) => {
finishPersistence = resolve
})
const invalidateHost = vi.fn(() => persistence)
const instance = harness({ invalidateHost })
const invalidation = instance.services.invalidateHost('host-1')
expect(invalidateHost).toHaveBeenCalledWith('host-1')
expect(invalidation).toBe(persistence)
finishPersistence?.()
await invalidation
})
it('disposes in dependency order once and aggregates failures', async () => {
const order: string[] = []
const installerError = new Error('installer failed')
const connectionError = new Error('connections failed')
const controllerError = new Error('controller failed')
const poolError = new Error('pool failed')
const instance = harness({
installationDispose: async () => {
order.push('installer')
throw installerError
},
connectionDispose: async () => {
order.push('connections')
throw connectionError
},
controllerFlush: async () => {
order.push('controller-flush')
throw controllerError
},
controllerDispose: () => {
order.push('controller-dispose')
},
poolDispose: () => {
order.push('ssh-pool')
throw poolError
}
})
const first = instance.services.dispose()
const second = instance.services.dispose()
expect(second).toBe(first)
const result = await first.catch((error: unknown) => error)
expect(result).toBeInstanceOf(AggregateError)
expect((result as AggregateError).errors).toEqual([
installerError,
connectionError,
controllerError,
poolError
])
expect(order).toEqual([
'installer',
'connections',
'controller-flush',
'controller-dispose',
'ssh-pool'
])
expect(instance.installationManager.dispose).toHaveBeenCalledOnce()
expect(instance.connectionManager.dispose).toHaveBeenCalledOnce()
expect(instance.controllerState.flush).toHaveBeenCalledOnce()
expect(instance.controllerState.dispose).toHaveBeenCalledOnce()
expect(instance.sshPool.dispose).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,142 @@
import { join } from 'node:path'
import { SshConnectionPool } from '../ssh/ssh-connection-pool'
import type { SshHostStore } from '../ssh/ssh-host-store'
import { AgentInstallationManager } from './agent-installation-manager'
import {
resolveBundledAgentResourcePaths,
type BundledAgentResourcePaths
} from './bundled-agent-resources'
import {
ControllerStateStore,
JsonControllerStateFile
} from './controller-state-store'
import {
RemoteAgentConnectionManager,
type RemoteAgentTargetResolver
} from './remote-agent-connection-manager'
const CONTROLLER_STATE_FILE_NAME = 'remote-agent-controller-state.json'
type RemoteAgentServiceFactories = {
createSshConnectionPool: () => SshConnectionPool
createControllerStateStore: (
filePath: string
) => ControllerStateStore
createAgentInstallationManager: (options: {
resolver: RemoteAgentTargetResolver
sshPool: SshConnectionPool
resourcePaths: BundledAgentResourcePaths
}) => AgentInstallationManager
createConnectionManager: (options: {
resolver: RemoteAgentTargetResolver
sshPool: SshConnectionPool
controllerState: ControllerStateStore
goodBuddyVersion: string
}) => RemoteAgentConnectionManager
}
export type RemoteAgentServicesOptions = {
sshHostStore: SshHostStore
goodBuddyVersion: string
userDataPath: string
appPath: string
resourcesPath: string
packaged: boolean
factories?: Partial<RemoteAgentServiceFactories>
}
/**
* Main-process lifecycle owner for the dormant remote Agent control plane.
* Construction only composes local objects and paths; network, disk, bundle
* verification, and installation work remain lazy until an explicit caller
* uses the corresponding service.
*/
export class RemoteAgentServices {
readonly targetResolver: RemoteAgentTargetResolver
readonly resourcePaths: BundledAgentResourcePaths
readonly sshPool: SshConnectionPool
readonly controllerState: ControllerStateStore
readonly installationManager: AgentInstallationManager
readonly connectionManager: RemoteAgentConnectionManager
#disposePromise: Promise<void> | undefined
constructor(options: RemoteAgentServicesOptions) {
const factories: RemoteAgentServiceFactories = {
createSshConnectionPool: () => new SshConnectionPool(),
createControllerStateStore: (filePath) =>
new ControllerStateStore(new JsonControllerStateFile(filePath)),
createAgentInstallationManager: (managerOptions) =>
new AgentInstallationManager(managerOptions),
createConnectionManager: (managerOptions) =>
new RemoteAgentConnectionManager(managerOptions),
...options.factories
}
this.targetResolver = {
resolve: (hostId) =>
options.sshHostStore.resolveConnectionTarget(hostId)
}
this.resourcePaths = resolveBundledAgentResourcePaths({
appPath: options.appPath,
resourcesPath: options.resourcesPath,
packaged: options.packaged
})
this.sshPool = factories.createSshConnectionPool()
this.controllerState = factories.createControllerStateStore(
join(options.userDataPath, CONTROLLER_STATE_FILE_NAME)
)
this.installationManager =
factories.createAgentInstallationManager({
resolver: this.targetResolver,
sshPool: this.sshPool,
resourcePaths: this.resourcePaths
})
this.connectionManager = factories.createConnectionManager({
resolver: this.targetResolver,
sshPool: this.sshPool,
controllerState: this.controllerState,
goodBuddyVersion: options.goodBuddyVersion
})
}
/**
* Invalidates live and persisted controller state. The connection manager
* closes matching connections and pool entries before its first await, so a
* credential-free SSH host lifecycle hook starts fail-closed synchronously.
*/
invalidateHost(hostId: string): Promise<void> {
return this.connectionManager.invalidateHost(hostId)
}
dispose(): Promise<void> {
this.#disposePromise ??= this.#dispose()
return this.#disposePromise
}
async #dispose(): Promise<void> {
const errors: unknown[] = []
const settle = async (
operation: () => void | Promise<void>
): Promise<void> => {
try {
await operation()
} catch (error) {
errors.push(error)
}
}
await settle(() => this.installationManager.dispose())
await settle(() => this.connectionManager.dispose())
await settle(() => this.controllerState.flush())
await settle(() => this.controllerState.dispose())
await settle(() => this.sshPool.dispose())
if (errors.length > 0) {
throw new AggregateError(
errors,
'Remote Agent services failed to dispose completely'
)
}
}
}
@@ -0,0 +1,510 @@
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssistantProject } from '../../shared/assistant-contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import type { SshConnectionTarget } from '../ssh/ssh-host-store'
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
import type { AgentInstallationIdentity } from './agent-installation-manager'
import type { RemoteAgentConnection } from './remote-agent-connection-manager'
import {
RemoteProjectSaveService,
type RemoteProjectSaveOwner,
type RemoteProjectSaveServiceOptions,
type RemoteProjectRuntimeValidationLease
} from './remote-project-save-service'
const hostId = '00000000-0000-4000-8000-000000000001'
const projectId = '00000000-0000-4000-8000-000000000002'
const root = '/srv/project'
const digest = `sha256:${'a'.repeat(64)}`
const services: RemoteProjectSaveService[] = []
afterEach(async () => {
await Promise.all(services.splice(0).map((service) => service.dispose()))
})
class Owner extends EventEmitter implements RemoteProjectSaveOwner {
destroyed = false
constructor(readonly id: number) {
super()
}
isDestroyed(): boolean {
return this.destroyed
}
destroy(): void {
this.destroyed = true
this.emit('destroyed')
}
}
const draft = {
name: 'Remote',
description: 'Validated remotely',
defaultWorkMode: 'ask' as const,
runtimeSelection: { provider: 'opencode' as const },
hostId,
remoteRootPath: root
}
function target(): SshConnectionTarget {
return {
host: {
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
hostKey: {
algorithm: 'ssh-ed25519',
publicKeyBase64: Buffer.from('key').toString('base64'),
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
acceptedAt: '2030-01-01T00:00:00.000Z',
generation: 2
}
},
hostRevision: 3,
hostKeyGeneration: 2
}
}
const installation: AgentInstallationIdentity = {
installationId: verifyAgentInstallationId('agent-installation'),
binaryDigest: digest,
agentVersion: '1.2.3',
protocol: { major: 1, minor: 0 },
platform: 'linux',
architecture: 'x64',
supervisor: 'detached-on-demand'
}
function project(): AssistantProject {
return {
id: projectId,
...draft,
rootPath: root,
kind: 'user',
executionSpace: { kind: 'ssh', hostId, remoteRootPath: root },
status: 'active',
createdAt: '2030-01-01T00:00:00.000Z',
updatedAt: '2030-01-01T00:00:00.000Z'
}
}
function harness(options: {
ensureInstalled?: (signal: AbortSignal) => Promise<AgentInstallationIdentity>
validateWorkspace?: () => unknown
create?: () => AssistantProject
getProject?: () => AssistantProject
resolveRuntimeSelection?: (
selection: AgentRuntimeSelection
) => Promise<AgentRuntimeSelection>
} = {}) {
const calls: string[] = []
const currentTarget = target()
const workspace = {
workspaceId: 'workspace-id',
workspaceIdentity: 'workspace-identity',
canonicalDisplayPath: root,
access: 'read-only' as const,
git: 'available' as const,
capabilities: [
'list',
'stat',
'read-text',
'search',
'git-status',
'git-diff'
],
generation: 1
}
const connectionRelease = vi.fn(() => calls.push('connection-release'))
const request = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'workspace/validate') {
return (
options.validateWorkspace?.() ?? {
handle: workspace,
validatedAt: '2030-01-01T00:00:01.000Z'
}
)
}
if (method === 'workspace/close') {
return {
workspaceId: workspace.workspaceId,
generation: workspace.generation,
closed: true
}
}
throw new Error(`Unexpected method: ${method}`)
})
const connection = {
identity: {
cacheKey: 'cache',
hostId,
hostRevision: 3,
hostKeyGeneration: 2,
remoteUsername: 'builder',
installationId: installation.installationId,
binaryDigest: digest,
protocolMajor: 1,
protocolMinor: 0
},
status: {
state: 'ready',
installationId: installation.installationId,
binaryDigest: digest,
daemonBootId: 'boot',
agentVersion: '1.2.3',
protocol: { major: 1, minor: 0 },
platform: 'linux',
architecture: 'x64',
supervisor: 'detached-on-demand',
remoteUserIdentity: 'user',
draining: false
},
capabilities: {
generation: 1,
capabilities: [
{ name: 'workspace/read', version: 1, critical: true }
],
runtimes: []
},
client: { request },
state: 'ready',
release: connectionRelease
} as unknown as RemoteAgentConnection
const runtimeRelease = vi.fn(() => calls.push('runtime-release'))
const runtimeLease: RemoteProjectRuntimeValidationLease = {
evidence: {
runtimeSelectionKey: agentRuntimeSelectionKey(
draft.runtimeSelection
),
runtimeBundleDigest: digest,
runtimeAdapterDigest: `sha256:${'b'.repeat(64)}`,
agentInstallationIdAtValidation: installation.installationId,
validatedAt: '2030-01-01T00:00:02.000Z',
workMode: 'ask'
},
assertCurrent: vi.fn(() => calls.push('runtime-current')),
release: runtimeRelease
}
const assertWrite = (write: Parameters<
RemoteProjectSaveServiceOptions['database']['createValidatedSshProject']
>[0]) => {
write.assertSshHostCurrent({
hostId,
hostRevision: 3,
hostKeyGeneration: 2,
remoteUsername: 'builder',
remoteRootPath: root,
workspaceIdentity: workspace.workspaceIdentity,
agentProtocolMajor: 1,
agentInstallationId: installation.installationId,
agentBinaryDigest: digest,
agentVersion: '1.2.3',
agentArchitecture: 'x64'
})
}
const createProject = vi.fn((write) => {
calls.push('database-create')
assertWrite(write)
return options.create?.() ?? project()
})
const updateProject = vi.fn((_id, _updatedAt, write) => {
calls.push('database-update')
assertWrite(write)
return project()
})
const progress: string[] = []
const service = new RemoteProjectSaveService({
database: {
getProject: vi.fn(() => options.getProject?.() ?? project()),
createValidatedSshProject: createProject,
updateValidatedSshProject: updateProject
},
sshHosts: {
resolveConnectionTarget: vi.fn(async () => {
calls.push('host')
return structuredClone(currentTarget)
}),
assertConnectionTargetCurrent: vi.fn(() => calls.push('host-current'))
},
installationManager: {
ensureInstalled: vi.fn(async (_hostId, requestOptions) => {
calls.push('install')
return options.ensureInstalled
? options.ensureInstalled(requestOptions.signal!)
: installation
})
},
connectionManager: {
acquire: vi.fn(async () => {
calls.push('attach')
return connection
})
},
resolveRuntimeSelection:
options.resolveRuntimeSelection ??
(async (selection) => selection),
runtimeValidator: {
validate: vi.fn(async (input) => {
calls.push('runtime')
return {
...runtimeLease,
evidence: {
...runtimeLease.evidence,
runtimeSelectionKey: input.runtimeSelectionKey
}
}
})
},
notify: (_owner, value) => progress.push(value.phase)
})
services.push(service)
return {
service,
calls,
createProject,
updateProject,
runtimeRelease,
connectionRelease,
progress
}
}
describe('RemoteProjectSaveService awaited save', () => {
it('validates and creates in one awaited request, then cleans up in reverse order', async () => {
const value = harness()
await expect(
value.service.save(new Owner(1), { intent: 'create', draft })
).resolves.toMatchObject({ id: projectId })
expect(value.progress).toEqual([
'host',
'agent',
'workspace',
'runtime',
'saving'
])
expect(value.createProject).toHaveBeenCalledOnce()
expect(value.calls.slice(-3)).toEqual([
'runtime-release',
'workspace/close',
'connection-release'
])
})
it('accepts a readable non-Git remote workspace', async () => {
const value = harness({
validateWorkspace: () => ({
handle: {
workspaceId: 'workspace-id',
workspaceIdentity: 'workspace-identity',
canonicalDisplayPath: root,
access: 'read-only' as const,
git: 'not-a-repository' as const,
capabilities: [
'list',
'stat',
'read-text',
'search'
] as const,
generation: 1
},
validatedAt: '2030-01-01T00:00:01.000Z'
})
})
await expect(
value.service.save(new Owner(1), {
intent: 'create',
draft
})
).resolves.toMatchObject({ id: projectId })
expect(value.createProject).toHaveBeenCalledOnce()
})
it('updates using the project revision captured before remote validation', async () => {
const value = harness()
await value.service.save(new Owner(1), {
intent: 'update',
draft: { projectId, ...draft }
})
expect(value.updateProject).toHaveBeenCalledWith(
projectId,
'2030-01-01T00:00:00.000Z',
expect.any(Object)
)
})
it('validates and persists the configured Runtime profile selection', async () => {
const configuredSelection = {
provider: 'opencode' as const,
profileId: '00000000-0000-4000-8000-000000000003'
}
const value = harness({
resolveRuntimeSelection: vi.fn(async () => configuredSelection)
})
await value.service.save(new Owner(1), {
intent: 'create',
draft
})
expect(value.createProject).toHaveBeenCalledWith(
expect.objectContaining({
project: expect.objectContaining({
runtimeSelection: configuredSelection
})
})
)
})
it('activates a persisted SSH project through the full update pipeline', async () => {
const value = harness()
await expect(
value.service.activate(new Owner(1), projectId)
).resolves.toMatchObject({ id: projectId })
expect(value.updateProject).toHaveBeenCalledWith(
projectId,
'2030-01-01T00:00:00.000Z',
expect.objectContaining({
project: expect.objectContaining({
name: draft.name,
description: draft.description,
defaultWorkMode: draft.defaultWorkMode,
runtimeSelection: draft.runtimeSelection
}),
executionSpace: expect.objectContaining({
hostId,
remoteRootPath: root
})
})
)
expect(value.calls).toEqual(expect.arrayContaining([
'host',
'install',
'attach',
'workspace/validate',
'runtime',
'database-update'
]))
})
it('coalesces repeated activation for the same owner and project', async () => {
let releaseInstallation!: () => void
const installationGate = new Promise<void>((resolve) => {
releaseInstallation = resolve
})
const value = harness({
ensureInstalled: async () => {
await installationGate
return installation
}
})
const owner = new Owner(1)
const first = value.service.activate(owner, projectId)
await vi.waitFor(() => expect(value.calls).toContain('install'))
const second = value.service.activate(owner, projectId)
await expect(
value.service.save(owner, { intent: 'create', draft })
).rejects.toThrow('already in progress')
expect(
value.calls.filter((call) => call === 'install')
).toHaveLength(1)
releaseInstallation()
await expect(Promise.all([first, second])).resolves.toEqual([
expect.objectContaining({ id: projectId }),
expect.objectContaining({ id: projectId })
])
expect(value.updateProject).toHaveBeenCalledOnce()
})
it('rejects activation for a local project before remote work starts', async () => {
const value = harness({
getProject: () => ({
...project(),
rootPath: 'C:\\workspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\workspace'
}
})
})
await expect(
value.service.activate(new Owner(1), projectId)
).rejects.toThrow('active managed SSH projects')
expect(value.calls).toEqual([])
expect(value.updateProject).not.toHaveBeenCalled()
})
it('does not write when workspace validation fails', async () => {
const value = harness({
validateWorkspace: () => {
throw new Error('workspace unavailable')
}
})
await expect(
value.service.save(new Owner(1), { intent: 'create', draft })
).rejects.toThrow('workspace unavailable')
expect(value.createProject).not.toHaveBeenCalled()
expect(value.connectionRelease).toHaveBeenCalledOnce()
})
it('cancels the current owner save without an operation id', async () => {
const value = harness({
ensureInstalled: (signal) =>
new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
})
const owner = new Owner(1)
const saving = value.service.save(owner, { intent: 'create', draft })
await vi.waitFor(() => expect(value.calls).toContain('install'))
value.service.cancelCurrent(owner)
await expect(saving).rejects.toMatchObject({ name: 'AbortError' })
expect(value.createProject).not.toHaveBeenCalled()
})
it('cancels when the renderer owner is destroyed', async () => {
const value = harness({
ensureInstalled: (signal) =>
new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
})
const owner = new Owner(1)
const saving = value.service.save(owner, { intent: 'create', draft })
await vi.waitFor(() => expect(value.calls).toContain('install'))
owner.destroy()
await expect(saving).rejects.toMatchObject({ name: 'AbortError' })
})
it('surfaces transactional conflicts and permits a later retry', async () => {
const value = harness({
create: () => {
throw new Error('project conflict')
}
})
const owner = new Owner(1)
await expect(
value.service.save(owner, { intent: 'create', draft })
).rejects.toThrow('project conflict')
await expect(
value.service.save(owner, {
intent: 'update',
draft: { projectId, ...draft }
})
).resolves.toMatchObject({ id: projectId })
})
})
@@ -0,0 +1,673 @@
import { isDeepStrictEqual } from 'node:util'
import type {
AssistantProject,
ProjectRuntimeValidation,
SshExecutionValidation
} from '../../shared/assistant-contracts'
import { projectRuntimeValidationSchema } from '../../shared/assistant-contracts'
import {
remoteProjectSaveRequestSchema,
type RemoteProjectSavePhase,
type RemoteProjectSaveProgress,
type RemoteProjectSaveRequest
} from '../../shared/remote-project-candidate-contracts'
import { sha256DigestSchema } from '../../shared/agent-protocol/contracts'
import {
REMOTE_WORKSPACE_READ_CAPABILITIES,
remoteWorkspaceValidateResultSchema,
type RemoteWorkspaceHandle
} from '../../shared/remote-agent-contracts'
import {
agentRuntimeSelectionKey,
type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import type {
AssistantDatabase,
ValidatedSshHostPrecondition,
ValidatedSshProjectWrite
} from '../assistant/assistant-database'
import type {
CurrentSshConnectionTarget,
SshConnectionTarget,
SshHostStore
} from '../ssh/ssh-host-store'
import type {
AgentInstallationIdentity,
AgentInstallationManager
} from './agent-installation-manager'
import type {
RemoteAgentConnection,
RemoteAgentConnectionManager
} from './remote-agent-connection-manager'
const CLEANUP_TIMEOUT_MS = 5_000
const WORKSPACE_READ_CAPABILITY = {
name: 'workspace/read',
minimumVersion: 1,
critical: true
} as const
export interface RemoteProjectSaveOwner {
readonly id: number
isDestroyed(): boolean
on(event: 'destroyed', listener: () => void): unknown
removeListener(event: 'destroyed', listener: () => void): unknown
}
export type RemoteProjectRuntimeValidationInput = Readonly<{
selection: AgentRuntimeSelection
runtimeSelectionKey: string
workMode: 'ask' | 'execute'
host: Readonly<{
hostId: string
hostRevision: number
hostKeyGeneration: number
remoteUsername: string
}>
agent: Readonly<{
installationId: string
binaryDigest: string
version: string
architecture: 'x64' | 'arm64'
protocolMajor: number
}>
workspace: Readonly<{
canonicalRemoteRoot: string
workspaceIdentity: string
workspaceId: string
generation: number
access: 'read-only'
capabilities: readonly string[]
}>
connection: RemoteAgentConnection
signal: AbortSignal
}>
export type RemoteProjectRuntimeValidationEvidence =
ProjectRuntimeValidation
export interface RemoteProjectRuntimeValidationLease {
readonly evidence: RemoteProjectRuntimeValidationEvidence
assertCurrent(): void
release(): void
}
export interface RemoteProjectRuntimeValidator {
validate(
input: RemoteProjectRuntimeValidationInput
): Promise<RemoteProjectRuntimeValidationLease>
}
export class UnavailableRemoteProjectRuntimeValidator
implements RemoteProjectRuntimeValidator
{
async validate(): Promise<never> {
throw new Error('Remote Runtime validation is unavailable')
}
}
type ActiveSave = {
controller: AbortController
done: Promise<void>
resolveDone: () => void
destroyedListener: () => void
installation?: AgentInstallationIdentity
connection?: RemoteAgentConnection
workspace?: RemoteWorkspaceHandle
runtimeLease?: RemoteProjectRuntimeValidationLease
}
export type RemoteProjectSaveServiceOptions = {
database: Pick<
AssistantDatabase,
| 'getProject'
| 'createValidatedSshProject'
| 'updateValidatedSshProject'
>
sshHosts: Pick<
SshHostStore,
'resolveConnectionTarget' | 'assertConnectionTargetCurrent'
>
installationManager: Pick<AgentInstallationManager, 'ensureInstalled'>
connectionManager: Pick<RemoteAgentConnectionManager, 'acquire'>
resolveRuntimeSelection(
selection: AgentRuntimeSelection
): Promise<AgentRuntimeSelection>
runtimeValidator?: RemoteProjectRuntimeValidator
notify?: (
owner: RemoteProjectSaveOwner,
progress: RemoteProjectSaveProgress
) => void
cleanupTimeoutMs?: number
}
export class RemoteProjectSaveService {
readonly #database: RemoteProjectSaveServiceOptions['database']
readonly #sshHosts: RemoteProjectSaveServiceOptions['sshHosts']
readonly #installationManager: RemoteProjectSaveServiceOptions['installationManager']
readonly #connectionManager: RemoteProjectSaveServiceOptions['connectionManager']
readonly #resolveRuntimeSelection: RemoteProjectSaveServiceOptions['resolveRuntimeSelection']
readonly #runtimeValidator: RemoteProjectRuntimeValidator
readonly #notify?: RemoteProjectSaveServiceOptions['notify']
readonly #cleanupTimeoutMs: number
readonly #active = new Map<RemoteProjectSaveOwner, ActiveSave>()
readonly #activations = new Map<
RemoteProjectSaveOwner,
{ projectId: string; promise: Promise<AssistantProject> }
>()
#disposed = false
constructor(options: RemoteProjectSaveServiceOptions) {
this.#database = options.database
this.#sshHosts = options.sshHosts
this.#installationManager = options.installationManager
this.#connectionManager = options.connectionManager
this.#resolveRuntimeSelection = options.resolveRuntimeSelection
this.#runtimeValidator =
options.runtimeValidator ??
new UnavailableRemoteProjectRuntimeValidator()
this.#notify = options.notify
this.#cleanupTimeoutMs =
options.cleanupTimeoutMs ?? CLEANUP_TIMEOUT_MS
if (
!Number.isSafeInteger(this.#cleanupTimeoutMs) ||
this.#cleanupTimeoutMs < 1
) {
throw new RangeError('Invalid remote project cleanup timeout')
}
}
async save(
owner: RemoteProjectSaveOwner,
input: unknown
): Promise<AssistantProject> {
this.#assertOwnerAvailable(owner)
if (this.#active.has(owner)) {
throw new Error('A remote project save is already in progress')
}
const request = remoteProjectSaveRequestSchema.parse(input)
let resolveDone!: () => void
const active: ActiveSave = {
controller: new AbortController(),
done: new Promise<void>((resolve) => {
resolveDone = resolve
}),
resolveDone,
destroyedListener: () => {
active.controller.abort(
new DOMException('Remote project owner was destroyed', 'AbortError')
)
}
}
this.#active.set(owner, active)
owner.on('destroyed', active.destroyedListener)
try {
return await this.#performSave(owner, active, request)
} finally {
await this.#cleanup(active)
owner.removeListener('destroyed', active.destroyedListener)
if (this.#active.get(owner) === active) {
this.#active.delete(owner)
}
active.resolveDone()
}
}
async activate(
owner: RemoteProjectSaveOwner,
projectId: string
): Promise<AssistantProject> {
this.#assertOwnerAvailable(owner)
const existing = this.#activations.get(owner)
if (existing) {
if (existing.projectId === projectId) {
return await existing.promise
}
throw new Error('A remote project save is already in progress')
}
const project = this.#database.getProject(projectId)
if (
project.kind !== 'user' ||
project.channel !== undefined ||
project.status !== 'active' ||
project.executionSpace.kind !== 'ssh' ||
project.runtimeSelection === undefined
) {
throw new Error('Only active managed SSH projects can be activated')
}
const promise = this.save(owner, {
intent: 'update',
draft: {
projectId: project.id,
name: project.name,
description: project.description,
defaultWorkMode: project.defaultWorkMode,
runtimeSelection: project.runtimeSelection,
hostId: project.executionSpace.hostId,
remoteRootPath: project.executionSpace.remoteRootPath
}
})
this.#activations.set(owner, { projectId, promise })
try {
return await promise
} finally {
if (this.#activations.get(owner)?.promise === promise) {
this.#activations.delete(owner)
}
}
}
cancelCurrent(owner: RemoteProjectSaveOwner): void {
this.#active.get(owner)?.controller.abort(
new DOMException('Remote project save cancelled', 'AbortError')
)
}
async dispose(): Promise<void> {
if (this.#disposed) return
this.#disposed = true
const saves = [...this.#active.entries()]
for (const [, active] of saves) {
active.controller.abort(
new DOMException('Remote project service disposed', 'AbortError')
)
}
await Promise.allSettled(saves.map(([, active]) => active.done))
}
async #performSave(
owner: RemoteProjectSaveOwner,
active: ActiveSave,
request: RemoteProjectSaveRequest
): Promise<AssistantProject> {
const { signal } = active.controller
const expectedUpdatedAt =
request.intent === 'update'
? this.#readUpdateRevision(request)
: undefined
const runtimeSelection = await this.#resolveRuntimeSelection(
request.draft.runtimeSelection
)
signal.throwIfAborted()
this.#progress(owner, 'host')
const target = await this.#sshHosts.resolveConnectionTarget(
request.draft.hostId
)
signal.throwIfAborted()
assertTarget(request.draft.hostId, target)
this.#progress(owner, 'agent')
const installation = await this.#installationManager.ensureInstalled(
request.draft.hostId,
{ signal }
)
signal.throwIfAborted()
assertInstallation(installation)
active.installation = installation
const connection = await this.#connectionManager.acquire(
request.draft.hostId,
{
...installation,
requiredCapabilities: [WORKSPACE_READ_CAPABILITY]
},
signal
)
active.connection = connection
signal.throwIfAborted()
assertConnectionIdentity(target, installation, connection)
this.#progress(owner, 'workspace')
const validated = remoteWorkspaceValidateResultSchema.parse(
await connection.client.request(
'workspace/validate',
{
remoteRootPath: request.draft.remoteRootPath,
requestedAccess: 'read-only',
requiredCapabilities: [...REMOTE_WORKSPACE_READ_CAPABILITIES]
},
{ signal }
)
)
active.workspace = validated.handle
signal.throwIfAborted()
assertWorkspace(validated.handle)
if (
validated.handle.canonicalDisplayPath !==
request.draft.remoteRootPath
) {
throw new Error('Remote workspace path changed during validation')
}
assertCriticalWorkspaceRead(connection)
this.#progress(owner, 'runtime')
const selectionKey = agentRuntimeSelectionKey(
runtimeSelection
)
const runtimeLease = await this.#runtimeValidator.validate({
selection: runtimeSelection,
runtimeSelectionKey: selectionKey,
workMode: request.draft.defaultWorkMode,
host: {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
remoteUsername: target.host.username
},
agent: {
installationId: installation.installationId,
binaryDigest: installation.binaryDigest,
version: installation.agentVersion,
architecture: installation.architecture,
protocolMajor: installation.protocol.major
},
workspace: {
canonicalRemoteRoot: validated.handle.canonicalDisplayPath,
workspaceIdentity: validated.handle.workspaceIdentity,
workspaceId: validated.handle.workspaceId,
generation: validated.handle.generation,
access: 'read-only',
capabilities: validated.handle.capabilities
},
connection,
signal
})
active.runtimeLease = runtimeLease
signal.throwIfAborted()
const runtimeEvidence = projectRuntimeValidationSchema.parse(
runtimeLease.evidence
)
assertRuntimeEvidence(
runtimeEvidence,
selectionKey,
installation.installationId,
request.draft.defaultWorkMode
)
this.#progress(owner, 'saving')
signal.throwIfAborted()
const write = this.#validatedWrite(
request,
runtimeSelection,
target,
installation,
connection,
validated.handle,
runtimeLease,
runtimeEvidence,
validated.validatedAt
)
return request.intent === 'create'
? this.#database.createValidatedSshProject(write)
: this.#database.updateValidatedSshProject(
request.draft.projectId,
expectedUpdatedAt!,
write
)
}
#readUpdateRevision(
request: Extract<RemoteProjectSaveRequest, { intent: 'update' }>
): string {
const project = this.#database.getProject(request.draft.projectId)
if (
project.kind !== 'user' ||
project.channel !== undefined ||
project.executionSpace.kind !== 'ssh' ||
project.executionSpace.hostId !== request.draft.hostId ||
project.executionSpace.remoteRootPath !==
request.draft.remoteRootPath
) {
throw new Error(
'Only an existing user SSH project with the same Host and root can be updated'
)
}
return project.updatedAt
}
#validatedWrite(
request: RemoteProjectSaveRequest,
runtimeSelection: AgentRuntimeSelection,
target: SshConnectionTarget,
installation: AgentInstallationIdentity,
connection: RemoteAgentConnection,
workspace: RemoteWorkspaceHandle,
runtimeLease: RemoteProjectRuntimeValidationLease,
runtime: ProjectRuntimeValidation,
validatedAt: string
): ValidatedSshProjectWrite {
const executionValidation: SshExecutionValidation = {
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
remoteUsername: target.host.username,
workspaceIdentity: workspace.workspaceIdentity,
agentProtocolMajor: installation.protocol.major,
agentInstallationIdAtValidation: installation.installationId,
agentBinaryDigestAtValidation: installation.binaryDigest,
agentVersionAtValidation: installation.agentVersion,
agentArchitectureAtValidation: installation.architecture,
validatedAt
}
return {
project: {
name: request.draft.name,
description: request.draft.description,
rootPath: workspace.canonicalDisplayPath,
defaultWorkMode: request.draft.defaultWorkMode,
runtimeSelection
},
executionSpace: {
kind: 'ssh',
hostId: target.host.id,
remoteRootPath: workspace.canonicalDisplayPath,
validation: executionValidation
},
runtimeValidation: structuredClone(runtime),
assertSshHostCurrent: (expected) => {
assertPrecondition(
expected,
target,
installation,
workspace
)
this.#sshHosts.assertConnectionTargetCurrent(
currentTargetEvidence(target)
)
assertConnectionIdentity(target, installation, connection)
assertCriticalWorkspaceRead(connection)
runtimeLease.assertCurrent()
if (
!isDeepStrictEqual(
projectRuntimeValidationSchema.parse(runtimeLease.evidence),
runtime
)
) {
throw new Error('Remote Runtime validation changed before save')
}
}
}
}
async #cleanup(active: ActiveSave): Promise<void> {
try {
active.runtimeLease?.release()
} catch {
// Continue releasing independently owned resources.
}
const workspace = active.workspace
const connection = active.connection
if (workspace && connection) {
try {
await connection.client.request(
'workspace/close',
{
workspaceId: workspace.workspaceId,
generation: workspace.generation
},
{ signal: AbortSignal.timeout(this.#cleanupTimeoutMs) }
)
} catch {
// The connection may already be offline after cancellation.
}
}
try {
connection?.release()
} catch {
// A concurrently closed connection is already released.
}
}
#progress(
owner: RemoteProjectSaveOwner,
phase: RemoteProjectSavePhase
): void {
if (!this.#notify || owner.isDestroyed()) return
try {
this.#notify(owner, { phase })
} catch {
// Renderer progress observers cannot affect the save.
}
}
#assertOwnerAvailable(owner: RemoteProjectSaveOwner): void {
if (this.#disposed) {
throw new Error('Remote project save service is disposed')
}
if (
!owner ||
!Number.isSafeInteger(owner.id) ||
owner.id < 0 ||
owner.isDestroyed()
) {
throw new Error('Remote project owner is unavailable')
}
}
}
function assertTarget(hostId: string, target: SshConnectionTarget): void {
if (
target.host.id !== hostId ||
!target.host.hostKey ||
target.host.hostKey.generation !== target.hostKeyGeneration ||
target.hostRevision < 1 ||
target.hostKeyGeneration < 1
) {
throw new Error('SSH Host binding is unavailable')
}
}
function assertInstallation(
installation: AgentInstallationIdentity
): void {
if (
!/^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/u.test(
installation.installationId
) ||
!sha256DigestSchema.safeParse(installation.binaryDigest).success ||
installation.platform !== 'linux' ||
!['x64', 'arm64'].includes(installation.architecture) ||
installation.supervisor !== 'detached-on-demand'
) {
throw new Error('Remote Agent installation is incompatible')
}
}
function assertConnectionIdentity(
target: SshConnectionTarget,
installation: AgentInstallationIdentity,
connection: RemoteAgentConnection
): void {
if (
connection.state !== 'ready' ||
connection.identity.hostId !== target.host.id ||
connection.identity.hostRevision !== target.hostRevision ||
connection.identity.hostKeyGeneration !== target.hostKeyGeneration ||
connection.identity.remoteUsername !== target.host.username ||
connection.identity.installationId !== installation.installationId ||
connection.identity.binaryDigest !== installation.binaryDigest ||
connection.status.state !== 'ready' ||
connection.status.draining ||
connection.status.installationId !== installation.installationId ||
connection.status.binaryDigest !== installation.binaryDigest
) {
throw new Error('Remote Agent identity is incompatible')
}
}
function assertCriticalWorkspaceRead(
connection: RemoteAgentConnection
): void {
const capability = connection.capabilities.capabilities.find(
(entry) => entry.name === WORKSPACE_READ_CAPABILITY.name
)
if (
!capability ||
capability.version < WORKSPACE_READ_CAPABILITY.minimumVersion ||
!capability.critical
) {
throw new Error('Remote Agent lacks workspace read capability')
}
}
function assertWorkspace(handle: RemoteWorkspaceHandle): void {
if (
handle.access !== 'read-only' ||
REMOTE_WORKSPACE_READ_CAPABILITIES.some(
(capability) => !handle.capabilities.includes(capability)
) ||
handle.capabilities.includes('write-text-atomic') ||
handle.capabilities.includes('apply-change-set')
) {
throw new Error('Remote workspace validation is incompatible')
}
}
function assertRuntimeEvidence(
evidence: ProjectRuntimeValidation,
selectionKey: string,
installationId: string,
workMode: 'ask' | 'execute'
): void {
if (
evidence.runtimeSelectionKey !== selectionKey ||
evidence.agentInstallationIdAtValidation !== installationId ||
evidence.workMode !== workMode
) {
throw new Error('Remote Runtime validation does not match the project')
}
}
function assertPrecondition(
expected: ValidatedSshHostPrecondition,
target: SshConnectionTarget,
installation: AgentInstallationIdentity,
workspace: RemoteWorkspaceHandle
): void {
if (
expected.hostId !== target.host.id ||
expected.hostRevision !== target.hostRevision ||
expected.hostKeyGeneration !== target.hostKeyGeneration ||
expected.remoteUsername !== target.host.username ||
expected.remoteRootPath !== workspace.canonicalDisplayPath ||
expected.workspaceIdentity !== workspace.workspaceIdentity ||
expected.agentProtocolMajor !== installation.protocol.major ||
expected.agentInstallationId !== installation.installationId ||
expected.agentBinaryDigest !== installation.binaryDigest ||
expected.agentVersion !== installation.agentVersion ||
expected.agentArchitecture !== installation.architecture
) {
throw new Error('Validated SSH project precondition changed')
}
}
function currentTargetEvidence(
target: SshConnectionTarget
): CurrentSshConnectionTarget {
return {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
username: target.host.username
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,384 @@
import { createHash } from 'node:crypto'
import {
mkdir,
mkdtemp,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
AgentArchitecture,
AgentReleaseKeyRegistry
} from '../../shared/agent-installation-contracts'
import {
digestRemoteRuntimeBundleIdentity,
digestRemoteRuntimeBundleManifest,
type RemoteRuntimeBundleManifest,
type RemoteRuntimeLock
} from '../../shared/remote-runtime-launch-contracts'
import type { BundledRemoteRuntimeResourcePaths } from './bundled-remote-runtime-resources'
import {
createRemoteRuntimeResourceLoader,
loadRemoteRuntimeVerificationMetadata,
loadVerifiedRemoteRuntimeResourceBundle
} from './remote-runtime-resource-loader'
import {
RemoteRuntimeBundleResourcesUnavailableError
} from './remote-runtime-installation-manager'
const temporaryDirectories: string[] = []
const registry: AgentReleaseKeyRegistry = {
formatVersion: 1,
keys: [],
revocations: []
}
const runtimeLock: RemoteRuntimeLock = {
formatVersion: 1,
runtimes: {
opencode: {
version: '1.18.9',
provider: 'opencode',
entrypoint: 'bin/opencode',
entrypointIdentity: 'opencode-acp',
argvPrefix: ['acp'],
allowedEnvironmentNames: [
'HOME',
'LANG',
'LC_ALL',
'PATH',
'TMPDIR',
'XDG_CACHE_HOME',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_STATE_HOME'
],
protocol: { major: 1, minor: 0 },
targets: {
x64: {
package: 'opencode-linux-x64-baseline',
integrity:
'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ=='
},
arm64: {
package: 'opencode-linux-arm64',
integrity:
'sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A=='
}
}
}
}
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('remote Runtime resource loader', () => {
it('loads one verified digest directory and preserves metadata bytes', async () => {
const fixture = await createFixture()
const verify = vi.fn(async (bundleDirectory) => ({
bundleDirectory,
executablePath: join(bundleDirectory, 'bin', 'opencode'),
manifest: fixture.manifest,
manifestDigest:
await digestRemoteRuntimeBundleManifest(fixture.manifest)
}))
const loaded = await loadVerifiedRemoteRuntimeResourceBundle(
fixture.paths,
'x64',
{
verificationEnvironment: 'test',
verifyRuntimeBundle: verify
}
)
expect(verify).toHaveBeenCalledWith(fixture.bundleDirectory, {
architecture: 'x64',
releaseKeyRegistry: registry,
runtimeLock,
verificationEnvironment: 'test'
})
expect(loaded).toEqual({
bundleDirectory: fixture.bundleDirectory,
manifest: fixture.manifest,
manifestDigest:
await digestRemoteRuntimeBundleManifest(fixture.manifest),
canonicalReleaseKeyRegistryBytes: canonical(registry),
canonicalRemoteRuntimeLockBytes: canonical(runtimeLock)
})
expect(Object.keys(loaded)).toHaveLength(5)
})
it('rejects verification injection outside test verification', async () => {
const fixture = await createFixture()
const verify = vi.fn()
expect(() =>
createRemoteRuntimeResourceLoader(fixture.paths, {
verifyRuntimeBundle: verify
})
).toThrow('allowed only in test verification')
await expect(
loadVerifiedRemoteRuntimeResourceBundle(
fixture.paths,
'x64',
{ verifyRuntimeBundle: verify }
)
).rejects.toThrow('allowed only in test verification')
})
it('rejects missing and multiple digest directories', async () => {
const missing = await createFixture({ digestDirectories: 0 })
const multiple = await createFixture({ digestDirectories: 2 })
const verify = vi.fn()
await expect(loadWithTestVerifier(missing.paths, verify))
.rejects.toThrow('exactly one digest directory')
await expect(loadWithTestVerifier(multiple.paths, verify))
.rejects.toThrow('exactly one digest directory')
expect(verify).not.toHaveBeenCalled()
})
it('loads verification metadata when installable Runtime resources are not packaged', async () => {
const fixture = await createFixture()
await rm(fixture.paths.runtimeRoots.x64, {
recursive: true,
force: true
})
await expect(
loadVerifiedRemoteRuntimeResourceBundle(
fixture.paths,
'x64',
{ verificationEnvironment: 'test' }
)
).rejects.toBeInstanceOf(
RemoteRuntimeBundleResourcesUnavailableError
)
await expect(
loadRemoteRuntimeVerificationMetadata(fixture.paths)
).resolves.toEqual({
releaseKeyRegistry: registry,
runtimeLock,
canonicalReleaseKeyRegistryBytes: canonical(registry),
canonicalRemoteRuntimeLockBytes: canonical(runtimeLock)
})
})
it('rejects a symlinked digest directory', async () => {
const fixture = await createFixture({ digestDirectories: 0 })
const target = join(fixture.root, 'target')
await mkdir(target)
await symlink(
target,
join(fixture.paths.runtimeRoots.x64, 'a'.repeat(64)),
process.platform === 'win32' ? 'junction' : 'dir'
)
await expect(loadWithTestVerifier(fixture.paths, vi.fn()))
.rejects.toThrow('not a real directory')
})
it('rejects architecture and digest-directory mismatches', async () => {
const fixture = await createFixture()
const wrongArchitecture = {
...fixture.manifest,
architecture: 'arm64' as const
}
const architectureVerifier = vi.fn(async (bundleDirectory) => ({
bundleDirectory,
executablePath: join(bundleDirectory, 'bin', 'opencode'),
manifest: wrongArchitecture,
manifestDigest:
await digestRemoteRuntimeBundleManifest(wrongArchitecture)
}))
await expect(
loadWithTestVerifier(fixture.paths, architectureVerifier)
).rejects.toThrow('does not match the requested OpenCode target')
const wrongDigest = {
...fixture.manifest,
bundleDigest: `sha256:${'f'.repeat(64)}` as const
}
const digestVerifier = vi.fn(async (bundleDirectory) => ({
bundleDirectory,
executablePath: join(bundleDirectory, 'bin', 'opencode'),
manifest: wrongDigest,
manifestDigest:
await digestRemoteRuntimeBundleManifest(wrongDigest)
}))
await expect(
loadWithTestVerifier(fixture.paths, digestVerifier)
).rejects.toThrow('does not match its resource directory')
})
it.each([
{
name: 'malformed',
bytes: Buffer.from('not json\n'),
error: 'invalid JSON'
},
{
name: 'noncanonical',
bytes: Buffer.from(JSON.stringify(registry)),
error: 'not canonical'
},
{
name: 'oversize',
bytes: Buffer.alloc(1024 * 1024 + 1, 0x20),
error: 'exceeds its safety limit'
}
])('rejects $name metadata', async ({ bytes, error }) => {
const fixture = await createFixture()
await writeFile(fixture.paths.keyRegistryPath, bytes)
await expect(loadWithTestVerifier(fixture.paths, vi.fn()))
.rejects.toThrow(error)
})
})
async function createFixture(options: {
digestDirectories?: number
} = {}): Promise<{
root: string
paths: BundledRemoteRuntimeResourcePaths
bundleDirectory: string
manifest: RemoteRuntimeBundleManifest
}> {
const root = await mkdtemp(join(tmpdir(), 'runtime-loader-'))
temporaryDirectories.push(root)
const paths: BundledRemoteRuntimeResourcePaths = {
keyRegistryPath: join(root, 'agent-release-keys.json'),
runtimeLockPath: join(root, 'remote-runtime-lock.json'),
runtimeRoots: {
x64: join(root, 'linux-x64', 'opencode'),
arm64: join(root, 'linux-arm64', 'opencode')
}
}
await mkdir(paths.runtimeRoots.x64, { recursive: true })
await mkdir(paths.runtimeRoots.arm64, { recursive: true })
await writeFile(paths.keyRegistryPath, canonical(registry))
await writeFile(paths.runtimeLockPath, canonical(runtimeLock))
const manifest = await createManifest('x64')
const digest = manifest.bundleDigest.slice('sha256:'.length)
const directoryCount = options.digestDirectories ?? 1
for (let index = 0; index < directoryCount; index += 1) {
await mkdir(
join(
paths.runtimeRoots.x64,
index === 0 ? digest : 'e'.repeat(64)
)
)
}
return {
root,
paths,
bundleDirectory: join(paths.runtimeRoots.x64, digest),
manifest
}
}
async function createManifest(
architecture: AgentArchitecture
): Promise<RemoteRuntimeBundleManifest> {
const executable = Buffer.from('opencode')
const license = Buffer.from('license')
const initial: RemoteRuntimeBundleManifest = {
formatVersion: 2,
product: 'GoodBuddy',
runtimeId: 'opencode',
runtimeVersion: '1.18.9',
provider: 'opencode',
platform: 'linux',
architecture,
signingKeyId: 'test-key',
bundleDigest: `sha256:${'0'.repeat(64)}`,
adapterDigest: `sha256:${'1'.repeat(64)}`,
sourcePackage: {
name:
runtimeLock.runtimes.opencode.targets[architecture].package,
integrity:
runtimeLock.runtimes.opencode.targets[architecture].integrity
},
entrypoint: {
identity: 'opencode-acp',
path: 'bin/opencode',
sha256: sha256(executable),
argvPrefix: ['acp']
},
files: [
{
path: 'bin/opencode',
size: executable.byteLength,
sha256: sha256(executable),
mode: '0755'
},
{
path: 'licenses/LICENSE',
size: license.byteLength,
sha256: sha256(license),
mode: '0644'
}
],
licenses: [
{
package: 'opencode',
version: '1.18.9',
spdx: 'MIT',
path: 'licenses/LICENSE'
}
],
allowedEnvironmentNames: [
'HOME',
'LANG',
'LC_ALL',
'PATH',
'TMPDIR',
'XDG_CACHE_HOME',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_STATE_HOME'
],
protocol: { major: 1, minor: 0 },
acpCapabilitiesDigest: `sha256:${'3'.repeat(64)}`,
limits: {
maximumPromptRuntimeMilliseconds: 60_000,
maximumPromptInputBytes: 1024 * 1024,
maximumPromptOutputBytes: 1024 * 1024
}
}
return {
...initial,
bundleDigest: await digestRemoteRuntimeBundleIdentity(initial)
}
}
function loadWithTestVerifier(
paths: BundledRemoteRuntimeResourcePaths,
verifyRuntimeBundle: NonNullable<
Parameters<
typeof loadVerifiedRemoteRuntimeResourceBundle
>[2]
>['verifyRuntimeBundle']
) {
return loadVerifiedRemoteRuntimeResourceBundle(paths, 'x64', {
verificationEnvironment: 'test',
verifyRuntimeBundle
})
}
function canonical(value: unknown): Buffer {
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8')
}
function sha256(contents: Uint8Array): string {
return createHash('sha256').update(contents).digest('hex')
}
@@ -0,0 +1,314 @@
import { constants } from 'node:fs'
import {
lstat,
open,
readdir
} from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import {
verifyRuntimeBundle,
type VerifiedRuntimeBundle,
type VerifyRuntimeBundleOptions
} from '../../shared/node/runtime-bundle-verifier'
import {
agentReleaseKeyRegistrySchema,
type AgentArchitecture,
type AgentReleaseKeyRegistry
} from '../../shared/agent-installation-contracts'
import {
digestRemoteRuntimeBundleManifest,
remoteRuntimeBundleManifestSchema,
remoteRuntimeLockSchema,
type RemoteRuntimeLock
} from '../../shared/remote-runtime-launch-contracts'
import {
RemoteRuntimeBundleResourcesUnavailableError,
type RemoteRuntimeInstallationBundleLoader,
type VerifiedRemoteRuntimeInstallationBundle
} from './remote-runtime-installation-manager'
import {
getBundledRemoteRuntimeRoot,
type BundledRemoteRuntimeResourcePaths
} from './bundled-remote-runtime-resources'
const MAXIMUM_METADATA_BYTES = 1024 * 1024
const digestDirectoryPattern = /^[a-f0-9]{64}$/u
type RuntimeBundleVerifier = (
bundleDirectory: string,
options: VerifyRuntimeBundleOptions
) => Promise<VerifiedRuntimeBundle>
export type RemoteRuntimeResourceLoaderOptions = {
verificationEnvironment?: 'production' | 'test'
verifyRuntimeBundle?: RuntimeBundleVerifier
}
type CanonicalMetadata<T> = {
bytes: Buffer
value: T
}
export type RemoteRuntimeVerificationMetadata = {
releaseKeyRegistry: AgentReleaseKeyRegistry
runtimeLock: RemoteRuntimeLock
canonicalReleaseKeyRegistryBytes: Buffer
canonicalRemoteRuntimeLockBytes: Buffer
}
export function createRemoteRuntimeResourceLoader(
paths: BundledRemoteRuntimeResourcePaths,
options: RemoteRuntimeResourceLoaderOptions = {}
): RemoteRuntimeInstallationBundleLoader {
assertSafeVerificationInjection(options)
return (architecture) =>
loadVerifiedRemoteRuntimeResourceBundle(
paths,
architecture,
options
)
}
export async function loadVerifiedRemoteRuntimeResourceBundle(
paths: BundledRemoteRuntimeResourcePaths,
architecture: AgentArchitecture,
options: RemoteRuntimeResourceLoaderOptions = {}
): Promise<VerifiedRemoteRuntimeInstallationBundle> {
assertSafeVerificationInjection(options)
const verificationEnvironment =
options.verificationEnvironment ?? 'production'
const metadata =
await loadRemoteRuntimeVerificationMetadata(paths)
const bundleDirectory = await resolveSingleDigestDirectory(
getBundledRemoteRuntimeRoot(paths, architecture)
)
const verifier = options.verifyRuntimeBundle ?? verifyRuntimeBundle
const verified = await verifier(bundleDirectory, {
architecture,
releaseKeyRegistry: metadata.releaseKeyRegistry,
runtimeLock: metadata.runtimeLock,
verificationEnvironment
})
return validateVerifiedBundle(
verified,
bundleDirectory,
architecture,
metadata.canonicalReleaseKeyRegistryBytes,
metadata.canonicalRemoteRuntimeLockBytes
)
}
export async function loadRemoteRuntimeVerificationMetadata(
paths: BundledRemoteRuntimeResourcePaths
): Promise<RemoteRuntimeVerificationMetadata> {
const [keyRegistry, runtimeLock] = await Promise.all([
readCanonicalMetadata(
paths.keyRegistryPath,
agentReleaseKeyRegistrySchema,
'Runtime release-key registry'
),
readCanonicalMetadata(
paths.runtimeLockPath,
remoteRuntimeLockSchema,
'Remote Runtime lock'
)
])
return {
releaseKeyRegistry: keyRegistry.value,
runtimeLock: runtimeLock.value,
canonicalReleaseKeyRegistryBytes: keyRegistry.bytes,
canonicalRemoteRuntimeLockBytes: runtimeLock.bytes
}
}
function assertSafeVerificationInjection(
options: RemoteRuntimeResourceLoaderOptions
): void {
if (
options.verifyRuntimeBundle !== undefined &&
options.verificationEnvironment !== 'test'
) {
throw new Error(
'Injected Runtime bundle verification is allowed only in test verification'
)
}
}
async function readCanonicalMetadata<T>(
filePathInput: string,
schema: { parse(value: unknown): T },
label: string
): Promise<CanonicalMetadata<T>> {
const filePath = resolve(filePathInput)
const pathStat = await lstat(filePath)
if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
throw new Error(`${label} is not a regular file`)
}
if (
pathStat.size === 0 ||
pathStat.size > MAXIMUM_METADATA_BYTES
) {
throw new Error(`${label} exceeds its safety limit`)
}
const handle = await open(
filePath,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
)
let bytes: Buffer
try {
const openedStat = await handle.stat()
if (
!openedStat.isFile() ||
openedStat.dev !== pathStat.dev ||
openedStat.ino !== pathStat.ino ||
openedStat.size === 0 ||
openedStat.size > MAXIMUM_METADATA_BYTES
) {
throw new Error(`${label} changed while it was being opened`)
}
const bounded = Buffer.alloc(MAXIMUM_METADATA_BYTES + 1)
let offset = 0
while (offset < bounded.byteLength) {
const { bytesRead } = await handle.read(
bounded,
offset,
bounded.byteLength - offset,
offset
)
if (bytesRead === 0) {
break
}
offset += bytesRead
}
bytes = bounded.subarray(0, offset)
} finally {
await handle.close()
}
if (
bytes.byteLength === 0 ||
bytes.byteLength > MAXIMUM_METADATA_BYTES
) {
throw new Error(`${label} exceeds its safety limit`)
}
let parsedJson: unknown
try {
parsedJson = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
)
} catch (error) {
throw new Error(`${label} is invalid JSON`, { cause: error })
}
let value: T
try {
value = schema.parse(parsedJson)
} catch (error) {
throw new Error(`${label} contract is invalid`, { cause: error })
}
const canonicalBytes = Buffer.from(
`${JSON.stringify(value, null, 2)}\n`,
'utf8'
)
if (!canonicalBytes.equals(bytes)) {
throw new Error(`${label} is not canonical`)
}
return { bytes: canonicalBytes, value }
}
async function resolveSingleDigestDirectory(
runtimeRootInput: string
): Promise<string> {
const runtimeRoot = resolve(runtimeRootInput)
let rootStat
try {
rootStat = await lstat(runtimeRoot)
} catch (error) {
if (isMissingPathError(error)) {
throw new RemoteRuntimeBundleResourcesUnavailableError(error)
}
throw error
}
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
throw new Error('OpenCode Runtime resource root is not a real directory')
}
const entries = await readdir(runtimeRoot, { withFileTypes: true })
if (entries.length !== 1) {
throw new Error(
'OpenCode Runtime resource root must contain exactly one digest directory'
)
}
const entry = entries[0]
if (entry === undefined || !digestDirectoryPattern.test(entry.name)) {
throw new Error(
'OpenCode Runtime resource directory name is not a SHA-256 digest'
)
}
const bundleDirectory = join(runtimeRoot, entry.name)
const bundleStat = await lstat(bundleDirectory)
if (
entry.isSymbolicLink() ||
!bundleStat.isDirectory() ||
bundleStat.isSymbolicLink()
) {
throw new Error(
'OpenCode Runtime digest entry is not a real directory'
)
}
return bundleDirectory
}
function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'ENOENT'
)
}
async function validateVerifiedBundle(
verified: VerifiedRuntimeBundle,
bundleDirectory: string,
architecture: AgentArchitecture,
keyRegistryBytes: Buffer,
runtimeLockBytes: Buffer
): Promise<VerifiedRemoteRuntimeInstallationBundle> {
const manifest = remoteRuntimeBundleManifestSchema.parse(
verified.manifest
)
if (
verified.bundleDirectory !== bundleDirectory ||
manifest.runtimeId !== 'opencode' ||
manifest.provider !== 'opencode' ||
manifest.platform !== 'linux' ||
manifest.architecture !== architecture
) {
throw new Error(
'Verified Runtime bundle does not match the requested OpenCode target'
)
}
if (
manifest.bundleDigest !==
`sha256:${basename(bundleDirectory)}`
) {
throw new Error(
'Runtime bundle digest does not match its resource directory'
)
}
if (
verified.manifestDigest !==
await digestRemoteRuntimeBundleManifest(manifest)
) {
throw new Error('Verified Runtime manifest digest is invalid')
}
return {
bundleDirectory,
manifest,
manifestDigest: verified.manifestDigest,
canonicalReleaseKeyRegistryBytes: keyRegistryBytes,
canonicalRemoteRuntimeLockBytes: runtimeLockBytes
}
}
+379
View File
@@ -0,0 +1,379 @@
import { EventEmitter } from 'node:events'
import type { FileEntryWithStats } from 'ssh2'
import { describe, expect, it, vi } from 'vitest'
import {
listBoundedSftpDirectories,
type DirectorySftp,
type DirectorySftpOpener
} from './bounded-directory-sftp'
type TestSftp = DirectorySftp
function entry(
filename: string,
type: 'directory' | 'file' | 'symbolic-link' = 'directory'
): FileEntryWithStats {
return {
filename,
longname: filename,
attrs: {
size: 0,
uid: 1,
gid: 1,
mode: 0o755,
atime: 0,
mtime: 0,
isDirectory: () => type === 'directory',
isFile: () => type === 'file',
isSymbolicLink: () => type === 'symbolic-link',
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false
}
}
}
function createSftp(options: {
home?: string
canonicalPath?: string
batches?: FileEntryWithStats[][]
} = {}): TestSftp {
const emitter = new EventEmitter()
const batches = [...(options.batches ?? [[]])]
const sftp = Object.assign(emitter, {
realpath: vi.fn(
(
path: string,
callback: (
error: Error | undefined,
result: string
) => void
) => {
callback(
undefined,
path === '.'
? (options.home ?? '/home/builder')
: (options.canonicalPath ?? path)
)
}
),
opendir: vi.fn(
(
_path: string,
callback: (
error: Error | undefined,
handle: Buffer
) => void
) => callback(undefined, Buffer.from('directory-handle'))
),
readdir: vi.fn(
(
_handle: Buffer,
callback: (
error: Error | undefined,
entries: FileEntryWithStats[]
) => void
) => callback(undefined, batches.shift() ?? [])
),
close: vi.fn(
(
_handle: Buffer,
callback: (error?: Error | null) => void
) => callback()
),
end: vi.fn()
})
return sftp as unknown as TestSftp
}
function opener(sftp: TestSftp): DirectorySftpOpener {
return (callback) => callback(undefined, sftp)
}
describe('bounded directory SFTP', () => {
it('resolves home and requested paths, filters entries, and closes every resource', async () => {
const sftp = createSftp({
home: '/home/builder',
canonicalPath: '/srv/projects',
batches: [
[
entry('zeta'),
entry('alpha'),
entry('file.txt', 'file'),
entry('link', 'symbolic-link'),
entry('.'),
entry('..'),
entry('nested/name'),
entry('back\\slash'),
entry('control\u0001'),
entry('\ufffd'),
entry('x'.repeat(256)),
entry('alpha')
],
[]
]
})
await expect(
listBoundedSftpDirectories(
opener(sftp),
'/srv/requested'
)
).resolves.toEqual({
path: '/srv/projects',
homeDirectory: '/home/builder',
parentPath: '/srv',
entries: [
{ name: 'alpha', path: '/srv/projects/alpha' },
{ name: 'zeta', path: '/srv/projects/zeta' }
],
truncated: false
})
expect(sftp.realpath).toHaveBeenNthCalledWith(
1,
'.',
expect.any(Function)
)
expect(sftp.realpath).toHaveBeenNthCalledWith(
2,
'/srv/requested',
expect.any(Function)
)
expect(sftp.opendir).toHaveBeenCalledWith(
'/srv/projects',
expect.any(Function)
)
expect(sftp.readdir).toHaveBeenCalledTimes(2)
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('uses canonical home by default and reports root without a parent', async () => {
const sftp = createSftp({
home: '/',
canonicalPath: '/',
batches: [[entry('tmp')], []]
})
await expect(
listBoundedSftpDirectories(opener(sftp))
).resolves.toMatchObject({
path: '/',
homeDirectory: '/',
parentPath: null,
entries: [{ name: 'tmp', path: '/tmp' }]
})
expect(sftp.realpath).toHaveBeenNthCalledWith(
2,
'/',
expect.any(Function)
)
})
it('sorts names by their UTF-8 bytes rather than UTF-16 code units', async () => {
const sftp = createSftp({
batches: [[entry('\u{10000}'), entry('\ue000')], []]
})
const result = await listBoundedSftpDirectories(opener(sftp))
expect(result.entries.map(({ name }) => name)).toEqual([
'\ue000',
'\u{10000}'
])
})
it('returns at most 500 directories after scanning at most 2000 entries', async () => {
const entries = Array.from({ length: 2_100 }, (_, index) =>
entry(`dir-${index.toString().padStart(4, '0')}`)
)
const sftp = createSftp({ batches: [entries] })
const result = await listBoundedSftpDirectories(opener(sftp))
expect(result.entries).toHaveLength(500)
expect(result.entries[0]?.name).toBe('dir-0000')
expect(result.entries[499]?.name).toBe('dir-0499')
expect(result.truncated).toBe(true)
expect(sftp.readdir).toHaveBeenCalledOnce()
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('stops after 64 handle-based READDIR callbacks', async () => {
const sftp = createSftp({
batches: Array.from({ length: 65 }, (_, index) => [
entry(`dir-${index.toString().padStart(2, '0')}`)
])
})
const result = await listBoundedSftpDirectories(opener(sftp))
expect(sftp.readdir).toHaveBeenCalledTimes(64)
expect(result.entries).toHaveLength(64)
expect(result.truncated).toBe(true)
expect(sftp.readdir).toHaveBeenCalledWith(
Buffer.from('directory-handle'),
expect.any(Function)
)
})
it('times out the whole operation and ends a pending channel', async () => {
vi.useFakeTimers()
try {
const sftp = createSftp()
sftp.realpath = vi.fn()
const pending = listBoundedSftpDirectories(opener(sftp))
const expectation = expect(pending).rejects.toThrow(
'目录浏览超时'
)
await vi.advanceTimersByTimeAsync(30_000)
await expectation
expect(sftp.close).not.toHaveBeenCalled()
expect(sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('cancels a pending read and ignores its late callback', async () => {
const sftp = createSftp()
let callback:
| ((
error: Error | undefined,
entries: FileEntryWithStats[]
) => void)
| undefined
sftp.readdir = vi.fn((_handle, next) => {
callback = next
})
const controller = new AbortController()
const pending = listBoundedSftpDirectories(
opener(sftp),
undefined,
controller.signal
)
controller.abort()
await expect(pending).rejects.toMatchObject({
name: 'AbortError'
})
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
callback?.(undefined, [entry('late')])
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('closes the handle and channel when READDIR fails', async () => {
const sftp = createSftp()
sftp.readdir = vi.fn((_handle, callback) => {
callback(new Error('READDIR failed'), [])
})
await expect(
listBoundedSftpDirectories(opener(sftp))
).rejects.toThrow('READDIR failed')
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('treats SFTP status code 1 as the end of the directory', async () => {
const sftp = createSftp({
batches: [[entry('project')]]
})
let reads = 0
sftp.readdir = vi.fn((_handle, callback) => {
reads += 1
if (reads === 1) {
callback(undefined, [entry('project')])
return
}
callback(
Object.assign(new Error('End of file'), { code: 1 }),
[]
)
})
await expect(
listBoundedSftpDirectories(opener(sftp))
).resolves.toMatchObject({
entries: [{
name: 'project',
path: '/home/builder/project'
}],
truncated: false
})
expect(sftp.readdir).toHaveBeenCalledTimes(2)
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('closes a handle returned after cancellation', async () => {
const sftp = createSftp()
let callback:
| ((error: Error | undefined, handle: Buffer) => void)
| undefined
sftp.opendir = vi.fn((_path, next) => {
callback = next
})
const controller = new AbortController()
const pending = listBoundedSftpDirectories(
opener(sftp),
undefined,
controller.signal
)
controller.abort()
await expect(pending).rejects.toMatchObject({
name: 'AbortError'
})
callback?.(undefined, Buffer.from('late-handle'))
expect(sftp.close).toHaveBeenCalledWith(
Buffer.from('late-handle'),
expect.any(Function)
)
expect(sftp.end).toHaveBeenCalledOnce()
})
it('rejects non-canonical inputs and provider paths without opening directories', async () => {
const sftp = createSftp({ canonicalPath: '/bad/../path' })
expect(() =>
listBoundedSftpDirectories(opener(sftp), 'relative')
).toThrow('目录路径无效')
await expect(
listBoundedSftpDirectories(opener(sftp), '/requested')
).rejects.toThrow('目录路径无效')
expect(sftp.opendir).not.toHaveBeenCalled()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('ends an SFTP channel delivered after an open timeout', async () => {
vi.useFakeTimers()
try {
let callback:
| Parameters<DirectorySftpOpener>[0]
| undefined
const pending = listBoundedSftpDirectories((next) => {
callback = next
})
const expectation = expect(pending).rejects.toThrow(
'目录浏览超时'
)
await vi.advanceTimersByTimeAsync(30_000)
await expectation
const late = createSftp()
callback?.(undefined, late)
expect(late.end).toHaveBeenCalledOnce()
expect(late.realpath).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
+383
View File
@@ -0,0 +1,383 @@
import type {
FileEntryWithStats,
SFTPWrapper
} from 'ssh2'
import type {
SshHostDirectoryBrowseResult
} from '../../shared/ssh-host-contracts'
const MAXIMUM_RETURNED_DIRECTORIES = 500
const MAXIMUM_SCANNED_ENTRIES = 2_000
const MAXIMUM_READDIR_CALLBACKS = 64
const OPERATION_TIMEOUT_MS = 30_000
const MAXIMUM_REMOTE_PATH_BYTES = 4_096
const MAXIMUM_NAME_BYTES = 255
export type DirectorySftp = Pick<
SFTPWrapper,
| 'realpath'
| 'opendir'
| 'readdir'
| 'close'
| 'end'
| 'on'
| 'removeListener'
>
export type DirectorySftpOpener = (
callback: (
error: Error | undefined,
sftp?: DirectorySftp
) => void
) => void
function abortError(signal?: AbortSignal): unknown {
return (
signal?.reason ??
new DOMException('The operation was aborted', 'AbortError')
)
}
function isCanonicalAbsolutePosixPath(value: unknown): value is string {
return (
typeof value === 'string' &&
Buffer.byteLength(value, 'utf8') <= MAXIMUM_REMOTE_PATH_BYTES &&
value.startsWith('/') &&
(value === '/' || !value.endsWith('/')) &&
!value.includes('//') &&
!value.includes('\\') &&
!value
.split('/')
.some((part) => part === '.' || part === '..') &&
!/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(value)
)
}
function validateRequestedPath(path: string | undefined): void {
if (path !== undefined && !isCanonicalAbsolutePosixPath(path)) {
throw new Error('SSH 目录路径无效')
}
}
function parentPath(path: string): string | null {
if (path === '/') {
return null
}
const separator = path.lastIndexOf('/')
return separator === 0 ? '/' : path.slice(0, separator)
}
function validDirectoryName(entry: FileEntryWithStats): boolean {
const name = entry?.filename
if (
typeof name !== 'string' ||
name === '.' ||
name === '..' ||
name.length === 0 ||
Buffer.byteLength(name, 'utf8') > MAXIMUM_NAME_BYTES ||
name.includes('/') ||
name.includes('\\') ||
/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(name)
) {
return false
}
try {
return (
typeof entry.attrs?.isDirectory === 'function' &&
entry.attrs.isDirectory() === true &&
typeof entry.attrs.isSymbolicLink === 'function' &&
entry.attrs.isSymbolicLink() === false
)
} catch {
return false
}
}
function joinPath(parent: string, name: string): string {
return parent === '/' ? `/${name}` : `${parent}/${name}`
}
function compareUtf8(left: string, right: string): number {
return Buffer.compare(
Buffer.from(left, 'utf8'),
Buffer.from(right, 'utf8')
)
}
function isSftpEndOfDirectory(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
error.code === 1
)
}
export function listBoundedSftpDirectories(
openSftp: DirectorySftpOpener,
requestedPath?: string,
signal?: AbortSignal
): Promise<SshHostDirectoryBrowseResult> {
validateRequestedPath(requestedPath)
if (signal?.aborted) {
return Promise.reject(abortError(signal))
}
return new Promise((resolve, reject) => {
let sftp: DirectorySftp | undefined
let handle: Buffer | undefined
let handleCloseRequested = false
let channelEnded = false
let settled = false
let scannedEntries = 0
let readdirCallbacks = 0
let truncated = false
let homeDirectory: string | undefined
let listingPath: string | undefined
const directories = new Set<string>()
const endChannel = (): void => {
if (channelEnded || !sftp) {
return
}
channelEnded = true
sftp.removeListener('error', onChannelError)
sftp.end()
}
const closeHandleBestEffort = (
candidate: Buffer | undefined = handle,
afterClose?: (error?: Error | null) => void
): void => {
if (!candidate || !sftp) {
afterClose?.()
return
}
if (candidate === handle) {
if (handleCloseRequested) {
afterClose?.()
return
}
handleCloseRequested = true
}
try {
sftp.close(candidate, (error) => {
afterClose?.(error)
})
} catch (error) {
afterClose?.(
error instanceof Error
? error
: new Error('SFTP 目录句柄关闭失败')
)
}
}
const finishError = (error: unknown): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
closeHandleBestEffort()
endChannel()
reject(error)
}
const finishSuccess = (): void => {
if (settled || !listingPath) {
return
}
const sorted = [...directories].sort(compareUtf8)
const result: SshHostDirectoryBrowseResult = {
path: listingPath,
homeDirectory: homeDirectory!,
parentPath: parentPath(listingPath),
entries: sorted
.slice(0, MAXIMUM_RETURNED_DIRECTORIES)
.map((name) => ({
name,
path: joinPath(listingPath!, name)
})),
truncated:
truncated ||
sorted.length > MAXIMUM_RETURNED_DIRECTORIES
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
closeHandleBestEffort()
endChannel()
resolve(result)
}
const onAbort = (): void => {
finishError(abortError(signal))
}
const onChannelError = (error: Error): void => {
finishError(error)
}
const timeout = setTimeout(() => {
finishError(new Error('SSH 目录浏览超时'))
}, OPERATION_TIMEOUT_MS)
const readNextBatch = (): void => {
if (settled || !sftp || !handle) {
return
}
if (
scannedEntries >= MAXIMUM_SCANNED_ENTRIES ||
readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS
) {
truncated = true
finishSuccess()
return
}
readdirCallbacks += 1
try {
sftp.readdir(handle, (error, rawList) => {
if (settled) {
return
}
if (isSftpEndOfDirectory(error)) {
finishSuccess()
return
}
if (error) {
finishError(error)
return
}
const list = rawList as
| FileEntryWithStats[]
| false
| undefined
if (list === false || list === undefined) {
finishSuccess()
return
}
if (!Array.isArray(list)) {
finishError(new Error('SFTP 目录列表无效'))
return
}
if (list.length === 0) {
finishSuccess()
return
}
const remaining =
MAXIMUM_SCANNED_ENTRIES - scannedEntries
const inspected = list.slice(0, remaining)
scannedEntries += inspected.length
for (const entry of inspected) {
if (
validDirectoryName(entry) &&
isCanonicalAbsolutePosixPath(
joinPath(listingPath!, entry.filename)
)
) {
directories.add(entry.filename)
}
}
if (
list.length > inspected.length ||
scannedEntries >= MAXIMUM_SCANNED_ENTRIES ||
readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS
) {
truncated = true
finishSuccess()
} else {
readNextBatch()
}
})
} catch (error) {
finishError(error)
}
}
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) {
onAbort()
return
}
try {
openSftp((openError, openedSftp) => {
if (settled) {
openedSftp?.end()
return
}
if (openError || !openedSftp) {
finishError(openError ?? new Error('无法打开 SFTP 通道'))
return
}
sftp = openedSftp
sftp.on('error', onChannelError)
try {
sftp.realpath('.', (homeError, homePath) => {
if (settled) {
return
}
if (
homeError ||
!isCanonicalAbsolutePosixPath(homePath)
) {
finishError(
homeError ?? new Error('SSH 主目录路径无效')
)
return
}
homeDirectory = homePath
try {
sftp!.realpath(
requestedPath ?? homePath,
(pathError, canonicalPath) => {
if (settled) {
return
}
if (
pathError ||
!isCanonicalAbsolutePosixPath(canonicalPath)
) {
finishError(
pathError ?? new Error('SSH 目录路径无效')
)
return
}
listingPath = canonicalPath
try {
sftp!.opendir(
canonicalPath,
(directoryError, openedHandle) => {
if (settled) {
closeHandleBestEffort(openedHandle)
endChannel()
return
}
if (directoryError || !openedHandle) {
finishError(
directoryError ??
new Error('无法打开 SSH 目录')
)
return
}
handle = openedHandle
readNextBatch()
}
)
} catch (error) {
finishError(error)
}
}
)
} catch (error) {
finishError(error)
}
})
} catch (error) {
finishError(error)
}
})
} catch (error) {
finishError(error)
}
})
}
+900
View File
@@ -0,0 +1,900 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { BoundedStagedSftp } from './bounded-sftp'
type Entry = {
type: 'file' | 'directory' | 'symbolic-link'
contents?: Buffer
reportedSize?: number
}
function stats(entry: Entry) {
return {
mode:
entry.type === 'directory'
? 0o40700
: entry.type === 'symbolic-link'
? 0o120777
: 0o100600,
uid: 1000,
gid: 1000,
size: entry.reportedSize ?? entry.contents?.byteLength ?? 0,
atime: 1,
mtime: 2,
isDirectory: () => entry.type === 'directory',
isFile: () => entry.type === 'file',
isSymbolicLink: () => entry.type === 'symbolic-link',
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false
}
}
function missingError(): Error & { code: number } {
return Object.assign(new Error('not found'), { code: 2 })
}
function createSftp(root = '/safe/staging') {
const emitter = new EventEmitter()
const entries = new Map<string, Entry>([
[root, { type: 'directory' }]
])
const handles = new Map<string, Entry>()
let nextHandle = 0
const lookup = (
path: string,
callback: (error?: Error, value?: ReturnType<typeof stats>) => void
): void => {
const entry = entries.get(path)
if (!entry) {
callback(missingError())
return
}
callback(undefined, stats(entry))
}
const sftp = Object.assign(emitter, {
mkdir: vi.fn(
(
path: string,
_attributes: unknown,
callback: (error?: Error) => void
) => {
entries.set(path, { type: 'directory' })
callback()
}
),
writeFile: vi.fn(
(
path: string,
contents: Buffer,
_options: unknown,
callback: (error?: Error) => void
) => {
entries.set(path, {
type: 'file',
contents: Buffer.from(contents)
})
callback()
}
),
chmod: vi.fn(
(
_path: string,
_mode: number,
callback: (error?: Error) => void
) => callback()
),
rename: vi.fn(
(
source: string,
destination: string,
callback: (error?: Error) => void
) => {
const entry = entries.get(source)
if (!entry) {
callback(missingError())
return
}
entries.delete(source)
entries.set(destination, entry)
callback()
}
),
ext_openssh_rename: vi.fn(
(
source: string,
destination: string,
callback: (error?: Error) => void
) => {
const entry = entries.get(source)
if (!entry) {
callback(missingError())
return
}
entries.delete(source)
entries.set(destination, entry)
callback()
}
),
ext_openssh_hardlink: vi.fn(
(
source: string,
destination: string,
callback: (error?: Error) => void
) => {
const entry = entries.get(source)
if (!entry) {
callback(missingError())
return
}
if (entries.has(destination)) {
callback(new Error('destination exists'))
return
}
entries.set(destination, entry)
callback()
}
),
unlink: vi.fn(
(path: string, callback: (error?: Error) => void) => {
entries.delete(path)
callback()
}
),
rmdir: vi.fn(
(path: string, callback: (error?: Error) => void) => {
entries.delete(path)
callback()
}
),
lstat: vi.fn(lookup),
stat: vi.fn(lookup),
open: vi.fn(
(
path: string,
_mode: string,
callback: (error?: Error, handle?: Buffer) => void
) => {
const entry = entries.get(path)
if (!entry) {
callback(missingError())
return
}
const handle = Buffer.from(`handle-${nextHandle++}`)
handles.set(handle.toString(), entry)
callback(undefined, handle)
}
),
fstat: vi.fn(
(
handle: Buffer,
callback: (
error?: Error,
value?: ReturnType<typeof stats>
) => void
) => {
const entry = handles.get(handle.toString())
if (!entry) {
callback(missingError())
return
}
callback(undefined, stats(entry))
}
),
read: vi.fn(
(
handle: Buffer,
buffer: Buffer,
offset: number,
length: number,
position: number,
callback: (
error: Error | undefined,
bytesRead: number
) => void
) => {
const contents =
handles.get(handle.toString())?.contents ?? Buffer.alloc(0)
const bytesRead = Math.min(
length,
Math.max(0, contents.byteLength - position)
)
contents.copy(
buffer,
offset,
position,
position + bytesRead
)
callback(undefined, bytesRead)
}
),
close: vi.fn(
(handle: Buffer, callback: (error?: Error) => void) => {
handles.delete(handle.toString())
callback()
}
),
end: vi.fn()
})
return { sftp, entries }
}
describe('bounded staged SFTP', () => {
it('keeps writes inside staging and supports bounded metadata and reads', async () => {
const { sftp } = createSftp(
'/home/builder/.goodbuddy/staging/random'
)
const staged = new BoundedStagedSftp(
sftp as never,
'/home/builder/.goodbuddy/staging/random'
)
await staged.mkdir('bin')
await staged.writeFile(
'bin/goodbuddy-agent',
Buffer.from('agent')
)
await expect(
staged.readFile('bin/goodbuddy-agent')
).resolves.toEqual(Buffer.from('agent'))
await expect(
staged.lstat('bin/goodbuddy-agent')
).resolves.toMatchObject({
type: 'file',
size: 5,
mode: 0o600
})
await expect(
staged.stat('bin/goodbuddy-agent')
).resolves.toMatchObject({ type: 'file', size: 5 })
await staged.setExecutable('bin/goodbuddy-agent')
await staged.rename(
'bin/goodbuddy-agent',
'bin/agent-ready'
)
expect(sftp.mkdir).toHaveBeenCalledWith(
'/home/builder/.goodbuddy/staging/random/bin',
{ mode: 0o700 },
expect.any(Function)
)
expect(sftp.writeFile).toHaveBeenCalledWith(
'/home/builder/.goodbuddy/staging/random/bin/goodbuddy-agent',
Buffer.from('agent'),
{ mode: 0o600, flag: 'wx' },
expect.any(Function)
)
expect(sftp.chmod).toHaveBeenCalledWith(
'/home/builder/.goodbuddy/staging/random/bin/goodbuddy-agent',
0o700,
expect.any(Function)
)
})
it('pipelines bounded reads for large files', async () => {
const { sftp, entries } = createSftp()
const contents = Buffer.alloc(4 * 64 * 1024, 0x5a)
entries.set('/safe/staging/large', {
type: 'file',
contents
})
const pending: Array<{
buffer: Buffer
offset: number
length: number
position: number
callback: (
error: Error | undefined,
bytesRead: number
) => void
}> = []
sftp.read.mockImplementation(
(
_handle,
buffer,
offset,
length,
position,
callback
) => {
pending.push({
buffer,
offset,
length,
position,
callback
})
}
)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: contents.byteLength,
maximumTotalBytes: contents.byteLength
}
)
const read = staged.readFile('large')
await vi.waitFor(() => expect(pending).toHaveLength(4))
for (const request of pending.splice(0)) {
contents.copy(
request.buffer,
request.offset,
request.position,
request.position + request.length
)
request.callback(undefined, request.length)
}
await vi.waitFor(() => expect(pending).toHaveLength(1))
pending[0]!.callback(undefined, 0)
await expect(read).resolves.toEqual(contents)
})
it('allows only exact safe modes and removes only real directories', async () => {
const { sftp } = createSftp()
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging'
)
await staged.mkdir('empty')
await staged.chmod('empty', 0o755)
await staged.writeFile('public-file', Buffer.from('ok'))
await staged.chmod('public-file', 0o644)
await expect(
staged.chmod('public-file', 0o666 as never)
).rejects.toThrow('允许列表')
await expect(
staged.chmod('empty', 0o600)
).rejects.toThrow('目录权限')
await staged.rmdir('empty')
expect(sftp.chmod).toHaveBeenCalledWith(
'/safe/staging/empty',
0o755,
expect.any(Function)
)
expect(sftp.rmdir).toHaveBeenCalledWith(
'/safe/staging/empty',
expect.any(Function)
)
})
it('atomically replaces only a regular managed file', async () => {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/current', {
type: 'file',
contents: Buffer.from('old')
})
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging'
)
await staged.writeFile('replacement', Buffer.from('new'))
await staged.replaceFile('replacement', 'current')
expect(entries.get('/safe/staging/current')?.contents).toEqual(
Buffer.from('new')
)
expect(sftp.ext_openssh_rename).toHaveBeenCalledWith(
'/safe/staging/replacement',
'/safe/staging/current',
expect.any(Function)
)
expect(sftp.rename).not.toHaveBeenCalled()
entries.set('/safe/staging/link', {
type: 'symbolic-link'
})
await staged.writeFile('other', Buffer.from('other'))
await expect(
staged.replaceFile('other', 'link')
).rejects.toThrow('符号链接')
})
it('hard-links only regular files to absent managed destinations', async () => {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/current', {
type: 'directory'
})
entries.set('/safe/staging/current/node', {
type: 'file',
contents: Buffer.from('node')
})
entries.set('/safe/staging/current/link', {
type: 'symbolic-link'
})
entries.set('/safe/staging/linked-source', {
type: 'symbolic-link'
})
entries.set('/safe/staging/linked-source/node', {
type: 'file',
contents: Buffer.from('escaped')
})
entries.set('/safe/staging/candidate', {
type: 'directory'
})
entries.set('/safe/staging/linked-destination', {
type: 'symbolic-link'
})
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: 1,
maximumTotalBytes: 1,
maximumOperations: 6
}
)
await staged.hardLink(
'current/node',
'candidate/node'
)
expect(entries.get('/safe/staging/candidate/node')).toBe(
entries.get('/safe/staging/current/node')
)
expect(sftp.ext_openssh_hardlink).toHaveBeenCalledWith(
'/safe/staging/current/node',
'/safe/staging/candidate/node',
expect.any(Function)
)
await expect(
staged.hardLink('current/link', 'candidate/other')
).rejects.toThrow('符号链接')
await expect(
staged.hardLink(
'linked-source/node',
'candidate/escaped-source'
)
).rejects.toThrow('符号链接')
await expect(
staged.hardLink(
'current/node',
'linked-destination/escaped-destination'
)
).rejects.toThrow('符号链接')
await expect(
staged.hardLink('current/node', 'candidate/node')
).rejects.toThrow('目标已存在')
await expect(
staged.hardLink('../current/node', 'candidate/escape')
).rejects.toThrow('相对路径无效')
expect(sftp.ext_openssh_hardlink).toHaveBeenCalledOnce()
})
it('propagates hard-link extension errors without transferring bytes', async () => {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/current', {
type: 'file',
contents: Buffer.alloc(8)
})
sftp.ext_openssh_hardlink.mockImplementation(
(
_source,
_destination,
callback
) => callback(new Error('unsupported extension'))
)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: 1,
maximumTotalBytes: 1,
maximumOperations: 2
}
)
await expect(
staged.hardLink('current', 'candidate')
).rejects.toThrow('unsupported extension')
await expect(staged.mkdir('still-available')).resolves
.toBeUndefined()
await expect(staged.mkdir('operation-limit')).rejects.toThrow(
'操作数量'
)
expect(sftp.writeFile).not.toHaveBeenCalled()
expect(sftp.open).not.toHaveBeenCalled()
})
it('fails closed for symlinks and unsupported remote types', async () => {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/link', {
type: 'symbolic-link'
})
entries.set('/safe/staging/link/agent', {
type: 'file',
contents: Buffer.from('escaped')
})
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging'
)
await expect(staged.stat('link')).rejects.toThrow(
'符号链接'
)
await expect(staged.lstat('link')).resolves.toMatchObject({
type: 'symbolic-link'
})
await expect(staged.readFile('link')).rejects.toThrow(
'符号链接'
)
await expect(staged.unlink('link')).rejects.toThrow(
'符号链接'
)
await expect(staged.readFile('link/agent')).rejects.toThrow(
'符号链接'
)
expect(sftp.open).not.toHaveBeenCalled()
expect(sftp.unlink).not.toHaveBeenCalled()
})
it('rejects reads that exceed metadata or grow during transfer', async () => {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/large', {
type: 'file',
contents: Buffer.alloc(5)
})
entries.set('/safe/staging/growing', {
type: 'file',
contents: Buffer.alloc(5),
reportedSize: 4
})
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: 4,
maximumTotalBytes: 8
}
)
await expect(staged.readFile('large')).rejects.toThrow(
'读取大小'
)
await expect(staged.readFile('growing')).rejects.toThrow(
'读取大小'
)
expect(sftp.close).toHaveBeenCalledOnce()
})
it('rejects traversal, absolute relative paths, and unsafe staging roots', async () => {
const { sftp } = createSftp()
expect(
() =>
new BoundedStagedSftp(
sftp as never,
'relative/staging'
)
).toThrow('暂存目录无效')
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging'
)
await expect(staged.mkdir('../escape')).rejects.toThrow(
'相对路径无效'
)
await expect(
staged.writeFile('/escape', Buffer.alloc(1))
).rejects.toThrow('相对路径无效')
expect(sftp.mkdir).not.toHaveBeenCalled()
expect(sftp.writeFile).not.toHaveBeenCalled()
})
it('bounds total bytes, operation count, timeout, and cancellation', async () => {
const firstHarness = createSftp()
const staged = new BoundedStagedSftp(
firstHarness.sftp as never,
'/safe/staging',
{
maximumFileBytes: 4,
maximumTotalBytes: 5,
maximumOperations: 2
}
)
await staged.writeFile('first', Buffer.alloc(3))
await expect(
staged.writeFile('total', Buffer.alloc(3))
).rejects.toThrow('传输总量')
await expect(staged.mkdir('third')).rejects.toThrow(
'操作数量'
)
const pendingHarness = createSftp()
pendingHarness.sftp.writeFile.mockImplementation(
() => undefined
)
const cancellable = new BoundedStagedSftp(
pendingHarness.sftp as never,
'/safe/staging'
)
const controller = new AbortController()
const write = cancellable.writeFile(
'agent',
Buffer.alloc(1),
controller.signal
)
await vi.waitFor(() =>
expect(
pendingHarness.sftp.writeFile
).toHaveBeenCalledOnce()
)
controller.abort()
await expect(write).rejects.toMatchObject({
name: 'AbortError'
})
expect(pendingHarness.sftp.end).toHaveBeenCalledOnce()
vi.useFakeTimers()
try {
const timeoutHarness = createSftp()
timeoutHarness.sftp.writeFile.mockImplementation(
() => undefined
)
const timed = new BoundedStagedSftp(
timeoutHarness.sftp as never,
'/safe/staging',
{ operationTimeoutMs: 1 }
)
const timedWrite = timed.writeFile(
'agent',
Buffer.alloc(1)
)
const timedRejection = expect(timedWrite).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
expect(
timeoutHarness.sftp.writeFile
).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
await timedRejection
expect(timeoutHarness.sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('scales large transfer timeouts by the bounded file size', async () => {
vi.useFakeTimers()
try {
const writeHarness = createSftp()
writeHarness.sftp.writeFile.mockImplementation(
() => undefined
)
const writeSftp = new BoundedStagedSftp(
writeHarness.sftp as never,
'/safe/staging',
{
maximumFileBytes: 64 * 1024,
operationTimeoutMs: 1
}
)
const write = writeSftp.writeFile(
'large',
Buffer.alloc(64 * 1024)
)
const writeRejection = expect(write).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(999)
expect(writeHarness.sftp.end).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await writeRejection
expect(writeHarness.sftp.end).toHaveBeenCalledOnce()
const readHarness = createSftp()
readHarness.entries.set('/safe/staging/large', {
type: 'file',
contents: Buffer.alloc(64 * 1024)
})
readHarness.sftp.read.mockImplementation(
() => undefined
)
const readSftp = new BoundedStagedSftp(
readHarness.sftp as never,
'/safe/staging',
{
maximumFileBytes: 64 * 1024,
maximumTotalBytes: 64 * 1024,
operationTimeoutMs: 1
}
)
const read = readSftp.readFile('large')
const readRejection = expect(read).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(999)
expect(readHarness.sftp.end).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await readRejection
expect(readHarness.sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('starts operation deadlines only after queued work reaches the head', async () => {
vi.useFakeTimers()
try {
const { sftp } = createSftp()
let finishWrite: ((error?: Error) => void) | undefined
sftp.writeFile.mockImplementation(
(
_path: string,
_contents: Buffer,
_options: unknown,
callback: (error?: Error) => void
) => {
finishWrite = callback
}
)
sftp.mkdir.mockImplementation(() => undefined)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{ operationTimeoutMs: 10 }
)
const first = staged.writeFile('first', Buffer.alloc(1))
const second = staged.mkdir('second')
const secondRejection = expect(second).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
expect(sftp.writeFile).toHaveBeenCalledOnce()
expect(sftp.mkdir).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(9)
finishWrite?.()
await expect(first).resolves.toBeUndefined()
await vi.advanceTimersByTimeAsync(0)
expect(sftp.mkdir).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(sftp.end).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(9)
await secondRejection
expect(sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('cancels queued work promptly without waiting for the active operation', async () => {
vi.useFakeTimers()
try {
const { sftp } = createSftp()
sftp.writeFile.mockImplementation(() => undefined)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{ operationTimeoutMs: 10 }
)
const active = staged.writeFile('active', Buffer.alloc(1))
const activeRejection = expect(active).rejects.toThrow(
'操作超时'
)
const controller = new AbortController()
const queued = staged.mkdir('queued', controller.signal)
await vi.advanceTimersByTimeAsync(0)
controller.abort()
await expect(queued).rejects.toMatchObject({
name: 'AbortError'
})
expect(sftp.end).toHaveBeenCalledOnce()
expect(sftp.mkdir).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(10)
await activeRejection
} finally {
vi.useRealTimers()
}
})
it('uses the opened file size instead of the configured read maximum', async () => {
vi.useFakeTimers()
try {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/small', {
type: 'file',
contents: Buffer.alloc(64 * 1024)
})
sftp.read.mockImplementation(() => undefined)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: 128 * 1024 * 1024,
operationTimeoutMs: 1
}
)
const read = staged.readFile('small')
const rejection = expect(read).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
expect(sftp.read).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(999)
expect(sftp.end).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await rejection
expect(sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('keeps stalled read metadata checks on the fixed operation timeout', async () => {
vi.useFakeTimers()
try {
const { sftp } = createSftp()
sftp.lstat.mockImplementation(() => undefined)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: 128 * 1024 * 1024,
operationTimeoutMs: 1
}
)
const read = staged.readFile('small')
const rejection = expect(read).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
expect(sftp.lstat).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
await rejection
expect(sftp.end).toHaveBeenCalledOnce()
expect(sftp.open).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('caps transfer deadlines at the revised 60-minute size boundary', async () => {
vi.useFakeTimers()
try {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/capped', {
type: 'file',
reportedSize: 64 * 1024 * 60 * 60 + 1
})
sftp.read.mockImplementation(() => undefined)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{ operationTimeoutMs: 1 }
)
const read = staged.readFile('capped')
const rejection = expect(read).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
expect(sftp.read).toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(60 * 60_000 - 1)
expect(sftp.end).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await rejection
expect(sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
})
+846
View File
@@ -0,0 +1,846 @@
import type { SFTPWrapper, Stats } from 'ssh2'
const DEFAULT_OPERATION_TIMEOUT_MS = 30_000
const MAX_OPERATION_TIMEOUT_MS = 60_000
const MINIMUM_TRANSFER_BYTES_PER_SECOND = 64 * 1024
const MAX_TRANSFER_TIMEOUT_MS = 60 * 60_000
const DEFAULT_MAX_FILE_BYTES = 256 * 1024 * 1024
const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024
const DEFAULT_MAX_OPERATIONS = 2_048
const MAX_REMOTE_PATH_BYTES = 4_096
const MAX_REMOTE_PATH_SEGMENTS = 128
const READ_CHUNK_BYTES = 64 * 1024
const MAXIMUM_CONCURRENT_READS = 32
const ALLOWED_MODES = [0o600, 0o644, 0o700, 0o755] as const
export type BoundedSftpLimits = {
maximumFileBytes?: number
maximumTotalBytes?: number
maximumOperations?: number
operationTimeoutMs?: number
}
export type SftpEntryType =
| 'file'
| 'directory'
| 'symbolic-link'
export type SftpEntryMetadata = {
type: SftpEntryType
size: number
mode: number
uid: number
gid: number
atime: number
mtime: number
}
export type AllowedSftpMode = (typeof ALLOWED_MODES)[number]
export type StagedSftp = {
readonly stagingDirectory: string
mkdir(relativePath: string, signal?: AbortSignal): Promise<void>
writeFile(
relativePath: string,
contents: Buffer,
signal?: AbortSignal
): Promise<void>
readFile(
relativePath: string,
signal?: AbortSignal
): Promise<Buffer>
lstat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata>
stat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata>
chmod(
relativePath: string,
mode: AllowedSftpMode,
signal?: AbortSignal
): Promise<void>
setExecutable(
relativePath: string,
signal?: AbortSignal
): Promise<void>
rename(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void>
replaceFile(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void>
hardLink?(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void>
unlink(relativePath: string, signal?: AbortSignal): Promise<void>
rmdir(relativePath: string, signal?: AbortSignal): Promise<void>
close(): void
}
type SftpLike = Pick<
SFTPWrapper,
| 'mkdir'
| 'writeFile'
| 'chmod'
| 'rename'
| 'ext_openssh_rename'
| 'ext_openssh_hardlink'
| 'unlink'
| 'rmdir'
| 'lstat'
| 'stat'
| 'open'
| 'read'
| 'close'
| 'fstat'
| 'end'
| 'on'
>
function validatePositiveLimit(
value: number | undefined,
fallback: number,
maximum: number
): number {
const resolved = value ?? fallback
if (
!Number.isSafeInteger(resolved) ||
resolved <= 0 ||
resolved > maximum
) {
throw new Error('SFTP 安全限制无效')
}
return resolved
}
function validateStagingDirectory(value: string): string {
if (
Buffer.byteLength(value, 'utf8') > MAX_REMOTE_PATH_BYTES ||
value.includes('\0') ||
value.includes('\\') ||
value.includes('//') ||
!(value.startsWith('/') || /^[A-Za-z]:\//u.test(value))
) {
throw new Error('SFTP 暂存目录无效')
}
const normalized = value.replace(/\/+$/u, '')
const pathWithoutRoot = normalized.replace(
/^(?:\/|[A-Za-z]:\/)/u,
''
)
const parts = pathWithoutRoot.split('/')
if (
!pathWithoutRoot ||
parts.length > MAX_REMOTE_PATH_SEGMENTS ||
parts.some((part) => !part || part === '.' || part === '..')
) {
throw new Error('SFTP 暂存目录无效')
}
return normalized
}
function validateRelativePath(value: string): string {
if (
!value ||
Buffer.byteLength(value, 'utf8') > MAX_REMOTE_PATH_BYTES ||
value.includes('\0') ||
value.includes('\\') ||
value.startsWith('/') ||
/^[A-Za-z]:/u.test(value)
) {
throw new Error('SFTP 相对路径无效')
}
const parts = value.split('/')
if (
parts.length > MAX_REMOTE_PATH_SEGMENTS ||
parts.some((part) => !part || part === '.' || part === '..')
) {
throw new Error('SFTP 相对路径无效')
}
return value
}
function abortError(signal?: AbortSignal): unknown {
return (
signal?.reason ??
new DOMException('The operation was aborted', 'AbortError')
)
}
function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
('code' in error &&
((error as { code?: unknown }).code === 2 ||
(error as { code?: unknown }).code === 'ENOENT'))
)
}
function metadataFromStats(stats: Stats): SftpEntryMetadata {
if (
!Number.isSafeInteger(stats.size) ||
stats.size < 0 ||
!Number.isSafeInteger(stats.mode) ||
!Number.isSafeInteger(stats.uid) ||
!Number.isSafeInteger(stats.gid) ||
!Number.isFinite(stats.atime) ||
!Number.isFinite(stats.mtime)
) {
throw new Error('SFTP 文件元数据无效')
}
const predicates = [
['file', stats.isFile],
['directory', stats.isDirectory],
['symbolic-link', stats.isSymbolicLink]
] as const
const types = predicates
.filter(([, predicate]) => {
if (typeof predicate !== 'function') {
throw new Error('SFTP 文件类型元数据无效')
}
return predicate.call(stats)
})
.map(([type]) => type)
if (types.length !== 1) {
throw new Error('SFTP 文件类型不受支持')
}
return {
type: types[0]!,
size: stats.size,
mode: stats.mode & 0o7777,
uid: stats.uid,
gid: stats.gid,
atime: stats.atime,
mtime: stats.mtime
}
}
export class BoundedStagedSftp implements StagedSftp {
readonly stagingDirectory: string
private readonly maximumFileBytes: number
private readonly maximumTotalBytes: number
private readonly maximumOperations: number
private readonly operationTimeoutMs: number
private totalBytes = 0
private operations = 0
private closed = false
private queue: Promise<void> = Promise.resolve()
constructor(
private readonly sftp: SftpLike,
stagingDirectory: string,
limits: BoundedSftpLimits = {}
) {
this.stagingDirectory =
validateStagingDirectory(stagingDirectory)
this.maximumFileBytes = validatePositiveLimit(
limits.maximumFileBytes,
DEFAULT_MAX_FILE_BYTES,
DEFAULT_MAX_FILE_BYTES
)
this.maximumTotalBytes = validatePositiveLimit(
limits.maximumTotalBytes,
DEFAULT_MAX_TOTAL_BYTES,
DEFAULT_MAX_TOTAL_BYTES
)
this.maximumOperations = validatePositiveLimit(
limits.maximumOperations,
DEFAULT_MAX_OPERATIONS,
DEFAULT_MAX_OPERATIONS
)
this.operationTimeoutMs = validatePositiveLimit(
limits.operationTimeoutMs,
DEFAULT_OPERATION_TIMEOUT_MS,
MAX_OPERATION_TIMEOUT_MS
)
this.sftp.on('error', () => {
this.closed = true
})
}
async mkdir(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
await this.call<void>((done) => {
this.sftp.mkdir(remotePath, { mode: 0o700 }, done)
})
})
}
async writeFile(
relativePath: string,
contents: Buffer,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
if (contents.byteLength > this.maximumFileBytes) {
throw new Error('SFTP 文件大小超过安全限制')
}
return this.enqueue(
signal,
async () => {
if (
this.totalBytes + contents.byteLength >
this.maximumTotalBytes
) {
throw new Error('SFTP 传输总量超过安全限制')
}
await this.assertSafeAncestors(validated)
await this.call<void>((done) => {
this.sftp.writeFile(
remotePath,
contents,
{ mode: 0o600, flag: 'wx' },
done
)
})
this.totalBytes += contents.byteLength
},
this.transferTimeoutMs(contents.byteLength)
)
}
async readFile(
relativePath: string,
signal?: AbortSignal
): Promise<Buffer> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(
signal,
async (resetTimeout) => {
const metadata = await this.safeStat(validated)
this.assertType(metadata, 'file')
const remainingTotal =
this.maximumTotalBytes - this.totalBytes
const maximumReadBytes = Math.min(
this.maximumFileBytes,
remainingTotal
)
if (
maximumReadBytes <= 0 ||
metadata.size > maximumReadBytes
) {
throw new Error('SFTP 读取大小超过安全限制')
}
const handle = await this.call<Buffer>((done) => {
this.sftp.open(remotePath, 'r', done)
})
try {
const openedMetadata = metadataFromStats(
await this.call<Stats>((done) => {
this.sftp.fstat(handle, done)
})
)
this.assertType(openedMetadata, 'file')
if (openedMetadata.size > maximumReadBytes) {
throw new Error('SFTP 读取大小超过安全限制')
}
resetTimeout(
this.transferTimeoutMs(openedMetadata.size)
)
const contents = await this.readHandle(
handle,
openedMetadata.size,
maximumReadBytes
)
this.totalBytes += contents.byteLength
return contents
} finally {
await this.closeHandle(handle)
}
}
)
}
async lstat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata> {
const validated = validateRelativePath(relativePath)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
return this.getLstat(this.resolveValidated(validated))
})
}
async stat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata> {
const validated = validateRelativePath(relativePath)
return this.enqueue(signal, () => this.safeStat(validated))
}
async chmod(
relativePath: string,
mode: AllowedSftpMode,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
if (!(ALLOWED_MODES as readonly number[]).includes(mode)) {
throw new Error('SFTP 文件权限不在安全允许列表中')
}
return this.enqueue(signal, async () => {
const metadata = await this.safeStat(validated)
if (
metadata.type === 'directory' &&
mode !== 0o700 &&
mode !== 0o755
) {
throw new Error('SFTP 目录权限必须保留执行权限')
}
await this.call<void>((done) => {
this.sftp.chmod(remotePath, mode, done)
})
})
}
setExecutable(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
return this.chmod(relativePath, 0o700, signal)
}
async rename(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void> {
const sourceValidated = validateRelativePath(sourceRelativePath)
const destinationValidated = validateRelativePath(
destinationRelativePath
)
const source = this.resolveValidated(sourceValidated)
const destination = this.resolveValidated(destinationValidated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(sourceValidated)
const sourceMetadata = await this.getLstat(source)
this.assertNotLink(sourceMetadata)
await this.assertSafeAncestors(destinationValidated)
try {
await this.getLstat(destination)
throw new Error('SFTP 重命名目标已存在')
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
await this.call<void>((done) => {
this.sftp.rename(source, destination, done)
})
})
}
async replaceFile(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void> {
const sourceValidated = validateRelativePath(sourceRelativePath)
const destinationValidated = validateRelativePath(
destinationRelativePath
)
const source = this.resolveValidated(sourceValidated)
const destination = this.resolveValidated(destinationValidated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(sourceValidated)
this.assertType(await this.getLstat(source), 'file')
await this.assertSafeAncestors(destinationValidated)
try {
this.assertType(await this.getLstat(destination), 'file')
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
try {
await this.call<void>((done) => {
this.sftp.ext_openssh_rename(
source,
destination,
done
)
})
} catch (error) {
throw new Error('SFTP 原子文件替换失败', {
cause: error
})
}
})
}
async hardLink(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void> {
const sourceValidated = validateRelativePath(sourceRelativePath)
const destinationValidated = validateRelativePath(
destinationRelativePath
)
const source = this.resolveValidated(sourceValidated)
const destination = this.resolveValidated(destinationValidated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(sourceValidated)
this.assertType(await this.getLstat(source), 'file')
await this.assertSafeAncestors(destinationValidated)
try {
await this.getLstat(destination)
throw new Error('SFTP 硬链接目标已存在')
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
await this.call<void>((done) => {
this.sftp.ext_openssh_hardlink(
source,
destination,
done
)
})
this.assertType(await this.getLstat(destination), 'file')
})
}
async unlink(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
this.assertType(await this.getLstat(remotePath), 'file')
await this.call<void>((done) => {
this.sftp.unlink(remotePath, done)
})
})
}
async rmdir(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
this.assertType(
await this.getLstat(remotePath),
'directory'
)
await this.call<void>((done) => {
this.sftp.rmdir(remotePath, done)
})
})
}
close(): void {
if (this.closed) {
return
}
this.closed = true
this.sftp.end()
}
private resolveValidated(relativePath: string): string {
return `${this.stagingDirectory}/${relativePath}`
}
private async assertSafeAncestors(
relativePath: string
): Promise<void> {
const parts = relativePath.split('/')
const paths = [this.stagingDirectory]
let current = this.stagingDirectory
for (const part of parts.slice(0, -1)) {
current = `${current}/${part}`
paths.push(current)
}
for (const path of paths) {
this.assertType(await this.getLstat(path), 'directory')
}
}
private async safeStat(
relativePath: string
): Promise<SftpEntryMetadata> {
await this.assertSafeAncestors(relativePath)
const remotePath = this.resolveValidated(relativePath)
const linkMetadata = await this.getLstat(remotePath)
this.assertNotLink(linkMetadata)
const metadata = metadataFromStats(
await this.call<Stats>((done) => {
this.sftp.stat(remotePath, done)
})
)
this.assertNotLink(metadata)
if (metadata.type !== linkMetadata.type) {
throw new Error('SFTP 文件类型在操作期间发生变化')
}
return metadata
}
private getLstat(remotePath: string): Promise<SftpEntryMetadata> {
return this.call<Stats>((done) => {
this.sftp.lstat(remotePath, done)
}).then(metadataFromStats)
}
private assertNotLink(metadata: SftpEntryMetadata): void {
if (metadata.type === 'symbolic-link') {
throw new Error('SFTP 不允许操作符号链接')
}
}
private assertType(
metadata: SftpEntryMetadata,
expected: Exclude<SftpEntryType, 'symbolic-link'>
): void {
this.assertNotLink(metadata)
if (metadata.type !== expected) {
throw new Error(
expected === 'file'
? 'SFTP 目标不是普通文件'
: 'SFTP 目标不是目录'
)
}
}
private async readHandle(
handle: Buffer,
expectedSize: number,
maximumReadBytes: number
): Promise<Buffer> {
const contents = Buffer.allocUnsafe(expectedSize)
let nextPosition = 0
let discoveredSize = expectedSize
const readSegment = async (): Promise<void> => {
while (true) {
const position = nextPosition
nextPosition += READ_CHUNK_BYTES
if (
position >= expectedSize ||
position >= discoveredSize
) {
return
}
const segmentLength = Math.min(
READ_CHUNK_BYTES,
expectedSize - position
)
let segmentOffset = 0
while (
segmentOffset < segmentLength &&
position + segmentOffset < discoveredSize
) {
const length = segmentLength - segmentOffset
const bytesRead = await this.call<number>((done) => {
this.sftp.read(
handle,
contents,
position + segmentOffset,
length,
position + segmentOffset,
(error, count) => done(error, count)
)
})
if (
!Number.isSafeInteger(bytesRead) ||
bytesRead < 0 ||
bytesRead > length
) {
throw new Error('SFTP 读取结果无效')
}
if (bytesRead === 0) {
discoveredSize = Math.min(
discoveredSize,
position + segmentOffset
)
return
}
segmentOffset += bytesRead
}
}
}
const workerCount = Math.min(
MAXIMUM_CONCURRENT_READS,
Math.ceil(expectedSize / READ_CHUNK_BYTES)
)
await Promise.all(
Array.from({ length: workerCount }, readSegment)
)
const extra = Buffer.allocUnsafe(1)
const extraBytes = await this.call<number>((done) => {
this.sftp.read(
handle,
extra,
0,
1,
expectedSize,
(error, count) => done(error, count)
)
})
if (extraBytes !== 0) {
throw new Error('SFTP 读取大小超过安全限制')
}
const actualSize = Math.min(expectedSize, discoveredSize)
if (actualSize > maximumReadBytes) {
throw new Error('SFTP 读取大小超过安全限制')
}
return actualSize === contents.byteLength
? contents
: contents.subarray(0, actualSize)
}
private async closeHandle(handle: Buffer): Promise<void> {
try {
await this.call<void>((done) => {
this.sftp.close(handle, done)
})
} catch {
this.close()
}
}
private call<T>(
begin: (
callback: (error?: Error | null, value?: T) => void
) => void
): Promise<T> {
if (this.closed) {
return Promise.reject(new Error('SFTP 通道已关闭'))
}
return new Promise((resolve, reject) => {
try {
begin((error, value) => {
if (error) {
reject(error)
} else {
resolve(value as T)
}
})
} catch (error) {
reject(error)
}
})
}
private transferTimeoutMs(byteLength: number): number {
const scaled = Math.ceil(
(byteLength / MINIMUM_TRANSFER_BYTES_PER_SECOND) * 1_000
)
return Math.min(
MAX_TRANSFER_TIMEOUT_MS,
Math.max(this.operationTimeoutMs, scaled)
)
}
private enqueue<T>(
signal: AbortSignal | undefined,
operation: (
resetTimeout: (timeoutMs: number) => void
) => Promise<T>,
timeoutMs = this.operationTimeoutMs
): Promise<T> {
if (this.closed) {
return Promise.reject(new Error('SFTP 通道已关闭'))
}
if (signal?.aborted) {
return Promise.reject(abortError(signal))
}
this.operations += 1
if (this.operations > this.maximumOperations) {
return Promise.reject(
new Error('SFTP 操作数量超过安全限制')
)
}
const previous = this.queue
let releaseQueue!: () => void
this.queue = new Promise<void>((resolve) => {
releaseQueue = resolve
})
return new Promise<T>((resolve, reject) => {
let settled = false
let timeout: ReturnType<typeof setTimeout> | undefined
const finish = (value?: T, error?: unknown): void => {
if (settled) {
return
}
settled = true
if (timeout !== undefined) {
clearTimeout(timeout)
}
signal?.removeEventListener('abort', abort)
releaseQueue()
if (error) {
reject(error)
} else {
resolve(value as T)
}
}
const abort = (): void => {
this.close()
finish(undefined, abortError(signal))
}
const resetTimeout = (nextTimeoutMs: number): void => {
if (settled) {
return
}
if (timeout !== undefined) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
this.close()
finish(undefined, new Error('SFTP 操作超时'))
}, nextTimeoutMs)
}
const start = (): void => {
if (settled) {
return
}
if (this.closed) {
finish(undefined, new Error('SFTP 通道已关闭'))
return
}
if (signal?.aborted) {
abort()
return
}
resetTimeout(timeoutMs)
Promise.resolve()
.then(() => operation(resetTimeout))
.then(
(value) => finish(value),
(error: unknown) => finish(undefined, error)
)
}
signal?.addEventListener('abort', abort, { once: true })
if (signal?.aborted) {
abort()
return
}
previous.then(start, start)
})
}
}
+252
View File
@@ -0,0 +1,252 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_BOOTSTRAP_PROBE_COMMAND,
buildFixedAgentCliArgv,
buildFixedAgentSshCommand,
parseAgentBootstrapProbeOutput,
verifyAgentInstallationId
} from './ssh-agent-command'
function bootstrapOutput(
overrides: Partial<Record<string, string>> = {}
): string {
const values = {
home: '/home/builder',
uid: '1001',
os: 'Linux',
arch: 'x86_64',
shell: '/bin/bash',
procfs: 'ready',
...overrides
}
return [
'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1',
...Object.entries(values).map(
([key, value]) => `${key}=${value}`
),
''
].join('\n')
}
describe('fixed Agent SSH commands', () => {
it('uses one exact, fixed, non-mutating bootstrap probe command', () => {
expect(AGENT_BOOTSTRAP_PROBE_COMMAND).toBe(
`printf 'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1\\n'; ` +
`if home=$(cd "$HOME" 2>/dev/null && pwd -P); then printf 'home=%s\\n' "$home"; else printf 'home=unknown\\n'; fi; ` +
`if uid=$(id -u 2>/dev/null); then printf 'uid=%s\\n' "$uid"; else uid=unknown; printf 'uid=unknown\\n'; fi; ` +
`if os=$(uname -s 2>/dev/null); then printf 'os=%s\\n' "$os"; else printf 'os=unknown\\n'; fi; ` +
`if arch=$(uname -m 2>/dev/null); then printf 'arch=%s\\n' "$arch"; else printf 'arch=unknown\\n'; fi; ` +
`printf 'shell=%s\\n' "\${SHELL:-unknown}"; ` +
`if [ -r /proc/self/stat ] && [ -r "/proc/$$/stat" ]; then printf 'procfs=ready\\n'; else printf 'procfs=unavailable\\n'; fi`
)
expect(AGENT_BOOTSTRAP_PROBE_COMMAND).not.toMatch(
/\b(?:sudo|env|printenv|set|export|install|mkdir|touch|rm)\b/u
)
expect(AGENT_BOOTSTRAP_PROBE_COMMAND).not.toMatch(
/\b(?:systemctl|loginctl)\b|Linger/u
)
})
it('builds only fixed commands from a separately verified installation ID', () => {
const installationId = verifyAgentInstallationId(
'agent_2026-08-20_abcdef012345'
)
expect(
buildFixedAgentSshCommand(installationId, { kind: 'attach' })
).toBe(
'exec "$HOME/.goodbuddy/agent/installations/' +
'agent_2026-08-20_abcdef012345/goodbuddy-agent" ' +
'attach-or-bootstrap --installation-id ' +
'agent_2026-08-20_abcdef012345'
)
expect(
buildFixedAgentSshCommand(installationId, { kind: 'doctor' })
).toContain(
'/goodbuddy-agent" doctor --installation-id ' +
'agent_2026-08-20_abcdef012345'
)
expect(
buildFixedAgentSshCommand(installationId, {
kind: 'lifecycle',
action: 'bootstrap'
})
).toContain(
'/goodbuddy-agent" bootstrap --installation-id ' +
'agent_2026-08-20_abcdef012345'
)
expect(
buildFixedAgentCliArgv(installationId, {
kind: 'lifecycle',
action: 'bootstrap'
})
).toEqual([
'bootstrap',
'--installation-id',
'agent_2026-08-20_abcdef012345'
])
expect(
buildFixedAgentSshCommand(installationId, {
kind: 'lifecycle',
action: 'health'
})
).toContain(
'/goodbuddy-agent" health --installation-id ' +
'agent_2026-08-20_abcdef012345'
)
})
it('rejects shell syntax and path traversal in installation IDs', () => {
for (const value of [
'../current',
'agent/latest',
'agent; shutdown',
'$(whoami)',
'agent.v1',
'',
'a'.repeat(129)
]) {
expect(() => verifyAgentInstallationId(value)).toThrow(
'installation ID 无效'
)
}
})
it('builds a closed digest-addressed OpenCode activation command', () => {
const installationId = verifyAgentInstallationId('agent-v1')
const bundleDigest = `sha256:${'a'.repeat(64)}`
expect(
buildFixedAgentCliArgv(installationId, {
kind: 'runtime-activate',
runtimeId: 'opencode',
bundleDigest,
architecture: 'arm64'
})
).toEqual([
'runtime',
'activate',
'--installation-id',
'agent-v1',
'--runtime-id',
'opencode',
'--bundle-digest',
bundleDigest,
'--architecture',
'arm64'
])
expect(
buildFixedAgentSshCommand(installationId, {
kind: 'runtime-activate',
runtimeId: 'opencode',
bundleDigest,
architecture: 'arm64'
})
).toBe(
'exec "$HOME/.goodbuddy/agent/installations/agent-v1/' +
'goodbuddy-agent" runtime activate --installation-id agent-v1 ' +
`--runtime-id opencode --bundle-digest ${bundleDigest} ` +
'--architecture arm64'
)
})
it('rejects unchecked Runtime activation argv before command construction', () => {
const installationId = verifyAgentInstallationId('agent-v1')
const valid = {
kind: 'runtime-activate',
runtimeId: 'opencode',
bundleDigest: `sha256:${'a'.repeat(64)}`,
architecture: 'x64'
} as const
for (const action of [
{ ...valid, runtimeId: 'opencode; id' },
{ ...valid, bundleDigest: `sha256:${'a'.repeat(63)};id` },
{ ...valid, architecture: 'x64;id' }
]) {
expect(() =>
buildFixedAgentSshCommand(
installationId,
action as never
)
).toThrow(/Runtime/iu)
}
})
it('fails closed if an untyped caller supplies an unknown lifecycle action', () => {
const installationId = verifyAgentInstallationId('agent-v1')
expect(() =>
buildFixedAgentSshCommand(installationId, {
kind: 'lifecycle',
action: 'arbitrary command'
} as never)
).toThrow('lifecycle action 无效')
expect(() =>
buildFixedAgentSshCommand(
'../unverified' as typeof installationId,
{ kind: 'attach' }
)
).toThrow('installation ID 无效')
})
it('parses supported Linux x64 and arm64 bootstrap results', () => {
expect(parseAgentBootstrapProbeOutput(bootstrapOutput())).toEqual({
ready: true,
platform: 'linux',
architecture: 'x64',
canonicalHomeDirectory: '/home/builder',
uid: 1_001,
shell: '/bin/bash',
procfs: 'ready'
})
expect(
parseAgentBootstrapProbeOutput(
bootstrapOutput({ arch: 'aarch64' })
)
).toMatchObject({
ready: true,
architecture: 'arm64'
})
})
it('returns explicit incompatible bootstrap reasons', () => {
const cases: Array<
[Partial<Record<string, string>>, string]
> = [
[{ os: 'Darwin' }, 'non-linux'],
[{ arch: 'riscv64' }, 'unsupported-architecture'],
[{ home: 'unknown' }, 'home-directory-unavailable'],
[{ uid: 'unknown' }, 'uid-unavailable'],
[{ shell: 'unknown' }, 'shell-unavailable'],
[{ procfs: 'unavailable' }, 'procfs-unavailable']
]
for (const [overrides, reason] of cases) {
expect(
parseAgentBootstrapProbeOutput(bootstrapOutput(overrides))
).toEqual({ ready: false, reason })
}
})
it('rejects malformed, reordered, incomplete, and unsafe output', () => {
const valid = bootstrapOutput()
for (const output of [
valid.slice(0, -1),
valid.replace('home=', 'uid='),
valid.replace('home=/home/builder', 'home=relative/path'),
valid.replace('/home/builder', '/home/../root'),
valid.replace('/bin/bash', '/bin/ba\u0000sh'),
valid.replace('/bin/bash', '/bin/ba\u0085sh'),
bootstrapOutput({ uid: '1001x' }),
bootstrapOutput({ procfs: 'maybe' }),
valid.replace('uid=1001', 'uid=1001\nuid=1001'),
`noise\n${valid}`,
valid.replace('\n', '\r\n'),
'x'.repeat(16 * 1024 + 1)
]) {
expect(() =>
parseAgentBootstrapProbeOutput(output)
).toThrow('无效')
}
})
})
+252
View File
@@ -0,0 +1,252 @@
import {
agentBootstrapProbeResultSchema,
canonicalRemotePathSchema,
type AgentBootstrapIncompatibleReason,
type AgentBootstrapProbeResult
} from '../../shared/ssh-host-contracts'
import { sha256DigestSchema } from '../../shared/agent-protocol/contracts'
const INSTALLATION_ID_PATTERN =
/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u
const AGENT_BOOTSTRAP_PROBE_MARKER =
'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1'
const MAX_PROBE_FIELD_LENGTH = 4_096
const MAX_LINUX_UID = 4_294_967_294
export const AGENT_BOOTSTRAP_PROBE_COMMAND = [
`printf '${AGENT_BOOTSTRAP_PROBE_MARKER}\\n'`,
`if home=$(cd "$HOME" 2>/dev/null && pwd -P); then printf 'home=%s\\n' "$home"; else printf 'home=unknown\\n'; fi`,
`if uid=$(id -u 2>/dev/null); then printf 'uid=%s\\n' "$uid"; else uid=unknown; printf 'uid=unknown\\n'; fi`,
`if os=$(uname -s 2>/dev/null); then printf 'os=%s\\n' "$os"; else printf 'os=unknown\\n'; fi`,
`if arch=$(uname -m 2>/dev/null); then printf 'arch=%s\\n' "$arch"; else printf 'arch=unknown\\n'; fi`,
`printf 'shell=%s\\n' "\${SHELL:-unknown}"`,
`if [ -r /proc/self/stat ] && [ -r "/proc/$$/stat" ]; then printf 'procfs=ready\\n'; else printf 'procfs=unavailable\\n'; fi`
].join('; ')
const lifecycleActions = [
'bootstrap',
'health',
'status',
'retire',
'stop'
] as const
const runtimeArchitectures = ['x64', 'arm64'] as const
const RUNTIME_ID = 'opencode' as const
declare const verifiedInstallationIdBrand: unique symbol
export type VerifiedAgentInstallationId = string & {
readonly [verifiedInstallationIdBrand]: true
}
export type AgentLifecycleAction = (typeof lifecycleActions)[number]
export type AgentRuntimeArchitecture =
(typeof runtimeArchitectures)[number]
export type AgentRuntimeActivationAction = {
kind: 'runtime-activate'
runtimeId: typeof RUNTIME_ID
bundleDigest: string
architecture: AgentRuntimeArchitecture
}
export type FixedAgentSshAction =
| { kind: 'attach' }
| { kind: 'doctor' }
| { kind: 'lifecycle'; action: AgentLifecycleAction }
| AgentRuntimeActivationAction
export type FixedAgentCliArgv = readonly string[]
const probeFields = [
'home',
'uid',
'os',
'arch',
'shell',
'procfs'
] as const
function incompatible(
reason: AgentBootstrapIncompatibleReason
): AgentBootstrapProbeResult {
return agentBootstrapProbeResultSchema.parse({
ready: false,
reason
})
}
function isSafeProbeField(value: string): boolean {
return (
value.length > 0 &&
value.length <= MAX_PROBE_FIELD_LENGTH &&
!/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(value)
)
}
function parseLinuxUid(value: string): number | undefined {
if (!/^(?:0|[1-9][0-9]{0,9})$/u.test(value)) {
return undefined
}
const uid = Number(value)
return Number.isSafeInteger(uid) && uid <= MAX_LINUX_UID
? uid
: undefined
}
export function parseAgentBootstrapProbeOutput(
stdout: string
): AgentBootstrapProbeResult {
if (
!stdout.endsWith('\n') ||
stdout.length > 16 * 1024 ||
stdout.includes('\r') ||
stdout.includes('\0')
) {
throw new Error('Agent 启动探针返回了无效结果')
}
const lines = stdout.slice(0, -1).split('\n')
if (
lines.length !== probeFields.length + 1 ||
lines[0] !== AGENT_BOOTSTRAP_PROBE_MARKER
) {
throw new Error('Agent 启动探针返回了无效结果')
}
const values = new Map<string, string>()
for (const [index, field] of probeFields.entries()) {
const prefix = `${field}=`
const line = lines[index + 1]
if (!line?.startsWith(prefix)) {
throw new Error('Agent 启动探针返回了无效结果')
}
const value = line.slice(prefix.length)
if (!isSafeProbeField(value)) {
throw new Error('Agent 启动探针返回了无效结果')
}
values.set(field, value)
}
const os = values.get('os')
if (os !== 'Linux') {
return incompatible('non-linux')
}
const rawArchitecture = values.get('arch')
const architecture =
rawArchitecture === 'x86_64'
? 'x64'
: rawArchitecture === 'aarch64'
? 'arm64'
: undefined
if (!architecture) {
return incompatible('unsupported-architecture')
}
const home = values.get('home') ?? ''
if (home === 'unknown') {
return incompatible('home-directory-unavailable')
}
if (!canonicalRemotePathSchema.safeParse(home).success) {
throw new Error('Agent 启动探针返回了无效路径')
}
const rawUid = values.get('uid')
if (rawUid === 'unknown') {
return incompatible('uid-unavailable')
}
const uid = parseLinuxUid(rawUid ?? '')
if (uid === undefined) {
throw new Error('Agent 启动探针返回了无效 UID')
}
const shell = values.get('shell') ?? ''
if (shell === 'unknown') {
return incompatible('shell-unavailable')
}
if (!canonicalRemotePathSchema.safeParse(shell).success) {
throw new Error('Agent 启动探针返回了无效路径')
}
const procfs = values.get('procfs')
if (procfs !== 'ready' && procfs !== 'unavailable') {
throw new Error('Agent 启动探针返回了无效结果')
}
if (procfs === 'unavailable') {
return incompatible('procfs-unavailable')
}
return agentBootstrapProbeResultSchema.parse({
ready: true,
platform: 'linux',
architecture,
canonicalHomeDirectory: home,
uid,
shell,
procfs: 'ready'
})
}
export function verifyAgentInstallationId(
value: string
): VerifiedAgentInstallationId {
if (
value.length > 128 ||
!INSTALLATION_ID_PATTERN.test(value)
) {
throw new Error('GoodBuddy Agent installation ID 无效')
}
return value as VerifiedAgentInstallationId
}
export function buildFixedAgentCliArgv(
installationId: VerifiedAgentInstallationId,
action: FixedAgentSshAction
): FixedAgentCliArgv {
const verifiedInstallationId =
verifyAgentInstallationId(installationId)
const installationOption = [
'--installation-id',
verifiedInstallationId
] as const
switch (action.kind) {
case 'attach':
return ['attach-or-bootstrap', ...installationOption]
case 'doctor':
return ['doctor', ...installationOption]
case 'lifecycle':
if (!lifecycleActions.includes(action.action)) {
throw new Error('GoodBuddy Agent lifecycle action 无效')
}
return [action.action, ...installationOption]
case 'runtime-activate': {
if (action.runtimeId !== RUNTIME_ID) {
throw new Error('GoodBuddy Agent Runtime ID 无效')
}
if (!sha256DigestSchema.safeParse(action.bundleDigest).success) {
throw new Error('GoodBuddy Agent Runtime bundle digest 无效')
}
if (!runtimeArchitectures.includes(action.architecture)) {
throw new Error('GoodBuddy Agent Runtime architecture 无效')
}
return [
'runtime',
'activate',
...installationOption,
'--runtime-id',
action.runtimeId,
'--bundle-digest',
action.bundleDigest,
'--architecture',
action.architecture
]
}
}
}
export function buildFixedAgentSshCommand(
installationId: VerifiedAgentInstallationId,
action: FixedAgentSshAction
): string {
const verifiedInstallationId =
verifyAgentInstallationId(installationId)
const argv = buildFixedAgentCliArgv(verifiedInstallationId, action)
const executable =
`"$HOME/.goodbuddy/agent/installations/${verifiedInstallationId}` +
'/goodbuddy-agent"'
return `exec ${executable} ${argv.join(' ')}`
}
+610
View File
@@ -0,0 +1,610 @@
import { EventEmitter } from 'node:events'
import type { ConnectConfig } from 'ssh2'
import { describe, expect, it, vi } from 'vitest'
import type { ResolvedSshHost } from './ssh-host-store'
import {
AGENT_BOOTSTRAP_PROBE_COMMAND,
verifyAgentInstallationId
} from './ssh-agent-command'
import {
Ssh2AuthenticatedConnection,
SshConnectionPool,
type AuthenticatedSshConnection,
type SshConnectionFactory,
type SshConnectionPoolTarget,
type SshPoolConnectionIdentity
} from './ssh-connection-pool'
const sshEd25519Algorithm = Buffer.from('ssh-ed25519')
const pinnedHostKey = Buffer.concat([
Buffer.from([0, 0, 0, sshEd25519Algorithm.byteLength]),
sshEd25519Algorithm,
Buffer.from([0, 0, 0, 32]),
Buffer.alloc(32, 7)
])
const hostKey = {
algorithm: 'ssh-ed25519',
publicKeyBase64: pinnedHostKey.toString('base64'),
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
generation: 3
}
function createTarget(
overrides: Partial<ResolvedSshHost> = {},
identityOverrides: Partial<
Pick<
SshConnectionPoolTarget,
'hostRevision' | 'hostKeyGeneration'
>
> = {}
): SshConnectionPoolTarget {
return {
host: {
id: '00000000-0000-4000-8000-000000000201',
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password',
password: 'private password',
hostKey,
...overrides
},
hostRevision: 7,
hostKeyGeneration: 3,
...identityOverrides
}
}
function createConnection(identity: SshPoolConnectionIdentity) {
return {
identity,
isUsable: vi.fn(() => true),
openAgentAttach: vi.fn(),
runAgentDoctor: vi.fn(),
runAgentBootstrapProbe: vi.fn(),
runAgentLifecycleAction: vi.fn(),
runAgentRuntimeAction: vi.fn(),
openStagedSftp: vi.fn(),
listDirectories: vi.fn(),
dispose: vi.fn()
} satisfies AuthenticatedSshConnection
}
describe('SSH connection pool', () => {
it('reuses one authenticated connection for matching identities and refcounts leases', async () => {
vi.useFakeTimers()
try {
const connections: ReturnType<typeof createConnection>[] = []
const factory: SshConnectionFactory = vi.fn(
async (_target, identity) => {
const connection = createConnection(identity)
connections.push(connection)
return connection
}
)
const pool = new SshConnectionPool(factory, {
idleTimeoutMs: 100
})
const [first, second] = await Promise.all([
pool.acquire(createTarget()),
pool.acquire(createTarget())
])
expect(factory).toHaveBeenCalledOnce()
expect(first.identity).toEqual(second.identity)
first.release()
await vi.advanceTimersByTimeAsync(100)
expect(connections[0]?.dispose).not.toHaveBeenCalled()
second.release()
await vi.advanceTimersByTimeAsync(99)
expect(connections[0]?.dispose).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(connections[0]?.dispose).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('isolates revision, host-key generation, credential, and host identities', async () => {
const factory: SshConnectionFactory = vi.fn(
async (_target, identity) => createConnection(identity)
)
const pool = new SshConnectionPool(factory)
const targets = [
createTarget(),
createTarget({}, { hostRevision: 8 }),
createTarget(
{ hostKey: { ...hostKey, generation: 4 } },
{ hostKeyGeneration: 4 }
),
createTarget({ password: 'rotated password' }),
createTarget({
id: '00000000-0000-4000-8000-000000000202'
})
]
const leases = await Promise.all(
targets.map((target) => pool.acquire(target))
)
expect(factory).toHaveBeenCalledTimes(5)
expect(
new Set(
leases.map(
(lease) =>
`${lease.identity.hostId}:${lease.identity.hostRevision}:` +
`${lease.identity.hostKeyGeneration}:` +
lease.identity.authenticationIdentity
)
).size
).toBe(5)
for (const lease of leases) {
lease.release()
}
pool.dispose()
})
it('lets one canceled waiter leave without canceling a shared connection', async () => {
let resolveConnection:
| ((connection: AuthenticatedSshConnection) => void)
| undefined
const factory: SshConnectionFactory = vi.fn(
(_target, identity) =>
new Promise<AuthenticatedSshConnection>((resolve) => {
resolveConnection = resolve
expect(identity.authenticationIdentity).toMatch(
/^[a-f0-9]{64}$/u
)
})
)
const pool = new SshConnectionPool(factory)
const controller = new AbortController()
const canceled = pool.acquire(createTarget(), controller.signal)
const surviving = pool.acquire(createTarget())
controller.abort()
await expect(canceled).rejects.toMatchObject({
name: 'AbortError'
})
const identity = (
factory as ReturnType<typeof vi.fn>
).mock.calls[0]?.[1] as SshPoolConnectionIdentity
resolveConnection?.(createConnection(identity))
const lease = await surviving
expect(lease.isUsable()).toBe(true)
lease.release()
pool.dispose()
})
it('disposes all host entries and prevents use after release', async () => {
const connection = createConnection({
hostId: createTarget().host.id,
hostRevision: 7,
hostKeyGeneration: 3,
authenticationIdentity: 'a'.repeat(64)
})
const pool = new SshConnectionPool(async () => connection)
const lease = await pool.acquire(createTarget())
const signal = new AbortController().signal
void lease.runAgentBootstrapProbe(signal)
expect(
connection.runAgentBootstrapProbe
).toHaveBeenCalledWith(signal)
void lease.listDirectories('/srv', signal)
expect(connection.listDirectories).toHaveBeenCalledWith(
'/srv',
signal
)
void lease.runAgentRuntimeAction(
verifyAgentInstallationId('agent-v1'),
{
kind: 'runtime-activate',
runtimeId: 'opencode',
bundleDigest: `sha256:${'a'.repeat(64)}`,
architecture: 'x64'
},
signal
)
expect(connection.runAgentRuntimeAction).toHaveBeenCalledWith(
'agent-v1',
expect.objectContaining({ kind: 'runtime-activate' }),
signal
)
lease.release()
expect(() =>
lease.openAgentAttach(
verifyAgentInstallationId('agent-v1')
)
).toThrow('租约已释放')
expect(() =>
lease.runAgentBootstrapProbe()
).toThrow('租约已释放')
expect(() =>
lease.listDirectories()
).toThrow('租约已释放')
expect(lease).not.toHaveProperty('exec')
expect(lease).not.toHaveProperty('sftp')
pool.disposeHost(createTarget().host.id)
expect(connection.dispose).toHaveBeenCalledOnce()
})
})
describe('authenticated ssh2 connection', () => {
it('uses pinned algorithms, keepalive, no forwarding, and only fixed Agent exec', async () => {
let connectConfig: ConnectConfig | undefined
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
destroy: vi.fn()
})
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(
this: EventEmitter,
config: ConnectConfig
) {
connectConfig = config
expect(
(
config.hostVerifier as
| ((key: Buffer) => boolean)
| undefined
)?.(pinnedHostKey)
).toBe(true)
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
_options: unknown,
callback: (
error: Error | undefined,
stream: typeof channel
) => void
) => callback(undefined, channel)
),
sftp: vi.fn()
})
const target = createTarget()
const identity: SshPoolConnectionIdentity = {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
}
const connection = await Ssh2AuthenticatedConnection.connect(
target,
identity,
new AbortController().signal,
{
createClient: () => client as never,
supportedOpenSslCiphers: () => ['aes-256-gcm']
}
)
await connection.openAgentAttach(
verifyAgentInstallationId('agent-v1')
)
expect(connectConfig).toMatchObject({
host: 'build.example.com',
port: 22,
username: 'builder',
password: 'private password',
agentForward: false,
keepaliveInterval: 5_000,
keepaliveCountMax: 2,
algorithms: {
cipher: ['aes256-gcm@openssh.com']
}
})
expect(client.exec).toHaveBeenCalledWith(
'exec "$HOME/.goodbuddy/agent/installations/' +
'agent-v1/goodbuddy-agent" attach-or-bootstrap ' +
'--installation-id agent-v1',
{
env: {},
pty: false,
x11: false,
allowHalfOpen: false
},
expect.any(Function)
)
expect(connection).not.toHaveProperty('exec')
})
it('hard-fails a host-key mismatch and aborts pending authentication', async () => {
let authenticationAttempted = false
const mismatchedClient = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(
this: EventEmitter,
config: ConnectConfig
) {
const accepted = (
config.hostVerifier as
| ((key: Buffer) => boolean)
| undefined
)?.(Buffer.from('different-key'))
if (accepted) {
authenticationAttempted = true
}
this.emit(
'error',
Object.assign(new Error('key rejected'), {
level: 'handshake'
})
)
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(),
sftp: vi.fn()
})
const target = createTarget()
const identity = {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
}
expect(() =>
Ssh2AuthenticatedConnection.connect(
target,
{ ...identity, hostRevision: identity.hostRevision + 1 },
new AbortController().signal,
{ createClient: () => mismatchedClient as never }
)
).toThrow('认证目标不匹配')
await expect(
Ssh2AuthenticatedConnection.connect(
target,
identity,
new AbortController().signal,
{ createClient: () => mismatchedClient as never }
)
).rejects.toThrow('主机密钥已变化')
expect(authenticationAttempted).toBe(false)
expect(mismatchedClient.destroy).toHaveBeenCalledOnce()
const pendingClient = Object.assign(new EventEmitter(), {
connect: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(),
sftp: vi.fn()
})
const controller = new AbortController()
const pending = Ssh2AuthenticatedConnection.connect(
target,
identity,
controller.signal,
{ createClient: () => pendingClient as never }
)
controller.abort()
await expect(pending).rejects.toMatchObject({
name: 'AbortError'
})
expect(pendingClient.destroy).toHaveBeenCalledOnce()
})
it('bounds fixed diagnostic output and destroys an overflowing channel', async () => {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
destroy: vi.fn()
})
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(this: EventEmitter) {
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
_options: unknown,
callback: (
error: Error | undefined,
stream: typeof channel
) => void
) => {
callback(undefined, channel)
setTimeout(() => {
channel.emit('data', Buffer.alloc(64 * 1024 + 1))
}, 0)
}
),
sftp: vi.fn()
})
const target = createTarget()
const connection = await Ssh2AuthenticatedConnection.connect(
target,
{
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
},
new AbortController().signal,
{ createClient: () => client as never }
)
await expect(
connection.runAgentDoctor(
verifyAgentInstallationId('agent-v1')
)
).rejects.toThrow('输出超过安全限制')
expect(client.exec.mock.calls[0]?.[0]).toContain(
'/goodbuddy-agent" doctor'
)
expect(channel.destroy).toHaveBeenCalledOnce()
})
it('bounds bootstrap probe output and destroys an overflowing channel', async () => {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
destroy: vi.fn()
})
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(this: EventEmitter) {
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
_options: unknown,
callback: (
error: Error | undefined,
stream: typeof channel
) => void
) => {
callback(undefined, channel)
setTimeout(() => {
channel.emit('data', Buffer.alloc(64 * 1024 + 1))
}, 0)
}
),
sftp: vi.fn()
})
const target = createTarget()
const connection = await Ssh2AuthenticatedConnection.connect(
target,
{
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
},
new AbortController().signal,
{ createClient: () => client as never }
)
await expect(
connection.runAgentBootstrapProbe()
).rejects.toThrow('输出超过安全限制')
expect(client.exec.mock.calls[0]?.[0]).toBe(
AGENT_BOOTSTRAP_PROBE_COMMAND
)
expect(channel.destroy).toHaveBeenCalledOnce()
})
it('runs the fixed bootstrap probe and collects split output', async () => {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
destroy: vi.fn()
})
const output = [
'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1',
'home=/home/builder',
'uid=1001',
'os=Linux',
'arch=aarch64',
'shell=/bin/bash',
'procfs=ready',
''
].join('\n')
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(this: EventEmitter) {
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
_options: unknown,
callback: (
error: Error | undefined,
stream: typeof channel
) => void
) => {
callback(undefined, channel)
setTimeout(() => {
channel.emit('data', output.slice(0, 17))
channel.emit('data', Buffer.from(output.slice(17)))
channel.emit('close', 0)
}, 0)
}
),
sftp: vi.fn()
})
const target = createTarget()
const connection = await Ssh2AuthenticatedConnection.connect(
target,
{
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
},
new AbortController().signal,
{ createClient: () => client as never }
)
await expect(connection.runAgentBootstrapProbe()).resolves.toMatchObject({
ready: true,
architecture: 'arm64'
})
expect(client.exec).toHaveBeenCalledWith(
AGENT_BOOTSTRAP_PROBE_COMMAND,
{
env: {},
pty: false,
x11: false,
allowHalfOpen: false
},
expect.any(Function)
)
})
it('cancels a running bootstrap probe and destroys its channel', async () => {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
destroy: vi.fn()
})
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(this: EventEmitter) {
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
_options: unknown,
callback: (
error: Error | undefined,
stream: typeof channel
) => void
) => callback(undefined, channel)
),
sftp: vi.fn()
})
const target = createTarget()
const connection = await Ssh2AuthenticatedConnection.connect(
target,
{
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
},
new AbortController().signal,
{ createClient: () => client as never }
)
const controller = new AbortController()
const result = connection.runAgentBootstrapProbe(controller.signal)
controller.abort()
await expect(result).rejects.toMatchObject({
name: 'AbortError'
})
expect(channel.destroy).toHaveBeenCalledOnce()
})
})
+878
View File
@@ -0,0 +1,878 @@
import { createHmac, getCiphers, randomBytes } from 'node:crypto'
import { Client } from 'ssh2'
import type {
ClientChannel,
ConnectConfig,
SFTPWrapper
} from 'ssh2'
import {
BoundedStagedSftp,
type BoundedSftpLimits,
type StagedSftp
} from './bounded-sftp'
import { listBoundedSftpDirectories } from './bounded-directory-sftp'
import type { ResolvedSshHost } from './ssh-host-store'
import {
AGENT_BOOTSTRAP_PROBE_COMMAND,
buildFixedAgentSshCommand,
parseAgentBootstrapProbeOutput,
type AgentLifecycleAction,
type AgentRuntimeActivationAction,
type VerifiedAgentInstallationId
} from './ssh-agent-command'
import type {
AgentBootstrapProbeResult,
SshHostDirectoryBrowseResult
} from '../../shared/ssh-host-contracts'
import {
buildAuthenticatedSshConnectConfig,
createStrongSshAlgorithms,
defaultSystemAgent,
safeSshError,
SSH_CONNECTION_TIMEOUT_MS
} from './ssh-transport'
const FIXED_COMMAND_TIMEOUT_MS = 15_000
const CHANNEL_OPEN_TIMEOUT_MS = 10_000
const MAX_DIAGNOSTIC_OUTPUT_BYTES = 64 * 1024
const DEFAULT_IDLE_TIMEOUT_MS = 60_000
const MAX_POOL_ENTRIES = 32
export type SshConnectionPoolTarget = {
host: ResolvedSshHost
hostRevision: number
hostKeyGeneration: number
}
export type SshPoolConnectionIdentity = {
hostId: string
hostRevision: number
hostKeyGeneration: number
authenticationIdentity: string
}
export type AgentDiagnosticResult = {
exitCode: number | null
stdout: string
stderr: string
}
export interface AuthenticatedSshConnection {
readonly identity: SshPoolConnectionIdentity
isUsable(): boolean
openAgentAttach(
installationId: VerifiedAgentInstallationId,
signal?: AbortSignal
): Promise<ClientChannel>
runAgentDoctor(
installationId: VerifiedAgentInstallationId,
signal?: AbortSignal
): Promise<AgentDiagnosticResult>
runAgentBootstrapProbe(
signal?: AbortSignal
): Promise<AgentBootstrapProbeResult>
runAgentLifecycleAction(
installationId: VerifiedAgentInstallationId,
action: AgentLifecycleAction,
signal?: AbortSignal
): Promise<AgentDiagnosticResult>
runAgentRuntimeAction(
installationId: VerifiedAgentInstallationId,
action: AgentRuntimeActivationAction,
signal?: AbortSignal
): Promise<AgentDiagnosticResult>
openStagedSftp(
stagingDirectory: string,
limits?: BoundedSftpLimits,
signal?: AbortSignal
): Promise<StagedSftp>
listDirectories(
path?: string,
signal?: AbortSignal
): Promise<SshHostDirectoryBrowseResult>
dispose(): void
}
export interface SshConnectionLease
extends Omit<AuthenticatedSshConnection, 'dispose'> {
release(): void
}
type ClientLike = Pick<
Client,
| 'connect'
| 'end'
| 'destroy'
| 'exec'
| 'sftp'
| 'on'
| 'once'
>
type SshConnectionDependencies = {
createClient: () => ClientLike
supportedOpenSslCiphers: () => readonly string[]
systemAgent: () => string | undefined
}
export type SshConnectionFactory = (
target: SshConnectionPoolTarget,
identity: SshPoolConnectionIdentity,
signal: AbortSignal
) => Promise<AuthenticatedSshConnection>
type PoolEntry = {
key: string
identity: SshPoolConnectionIdentity
controller: AbortController
connectionPromise: Promise<AuthenticatedSshConnection>
connection?: AuthenticatedSshConnection
leases: number
waiters: number
idleTimer?: ReturnType<typeof setTimeout>
}
function createAbortError(signal?: AbortSignal): unknown {
return (
signal?.reason ??
new DOMException('The operation was aborted', 'AbortError')
)
}
function validatePoolTarget(target: SshConnectionPoolTarget): void {
if (
!target.host.id ||
!Number.isSafeInteger(target.hostRevision) ||
target.hostRevision <= 0 ||
!Number.isSafeInteger(target.hostKeyGeneration) ||
target.hostKeyGeneration <= 0
) {
throw new Error('SSH 连接身份无效')
}
if (!target.host.hostKey) {
throw new Error('请先验证并接受 SSH 主机密钥')
}
if (
target.host.hostKey.generation !==
target.hostKeyGeneration
) {
throw new Error('SSH 主机密钥代际不匹配')
}
}
function validateConnectionIdentity(
target: SshConnectionPoolTarget,
identity: SshPoolConnectionIdentity
): void {
if (
identity.hostId !== target.host.id ||
identity.hostRevision !== target.hostRevision ||
identity.hostKeyGeneration !== target.hostKeyGeneration ||
!/^[a-f0-9]{64}$/u.test(identity.authenticationIdentity)
) {
throw new Error('SSH 连接身份与认证目标不匹配')
}
}
function createSshAuthenticationIdentity(
host: ResolvedSshHost,
systemAgent: string | undefined,
identityKey: Buffer
): string {
const credentialIdentity =
host.authentication === 'password'
? host.password
: systemAgent
if (!credentialIdentity) {
throw new Error(
host.authentication === 'password'
? 'SSH 主机尚未配置密码'
: '当前系统未检测到可用的 SSH Agent'
)
}
return createHmac('sha256', identityKey)
.update(
JSON.stringify([
'goodbuddy-ssh-auth-v1',
host.id,
host.hostname,
host.port,
host.username,
host.authentication,
credentialIdentity
])
)
.digest('hex')
}
function poolKey(identity: SshPoolConnectionIdentity): string {
return [
identity.hostId,
identity.hostRevision,
identity.hostKeyGeneration,
identity.authenticationIdentity
].join('\0')
}
function waitForSharedConnection(
promise: Promise<AuthenticatedSshConnection>,
signal?: AbortSignal
): Promise<AuthenticatedSshConnection> {
if (!signal) {
return promise
}
if (signal.aborted) {
return Promise.reject(createAbortError(signal))
}
return new Promise((resolve, reject) => {
const abort = (): void => {
reject(createAbortError(signal))
}
signal.addEventListener('abort', abort, { once: true })
promise.then(
(connection) => {
signal.removeEventListener('abort', abort)
resolve(connection)
},
(error: unknown) => {
signal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
export class Ssh2AuthenticatedConnection
implements AuthenticatedSshConnection
{
private usable = true
private disposed = false
private constructor(
readonly identity: SshPoolConnectionIdentity,
private readonly client: ClientLike
) {
const disconnected = (): void => {
this.usable = false
}
this.client.on('close', disconnected)
this.client.on('end', disconnected)
this.client.on('error', disconnected)
}
static connect(
target: SshConnectionPoolTarget,
identity: SshPoolConnectionIdentity,
signal: AbortSignal,
dependencies: Partial<SshConnectionDependencies> = {}
): Promise<Ssh2AuthenticatedConnection> {
validatePoolTarget(target)
validateConnectionIdentity(target, identity)
const resolvedDependencies: SshConnectionDependencies = {
createClient: () => new Client(),
supportedOpenSslCiphers: getCiphers,
systemAgent: defaultSystemAgent,
...dependencies
}
const algorithms = createStrongSshAlgorithms(
resolvedDependencies.supportedOpenSslCiphers()
)
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(createAbortError(signal))
return
}
const client = resolvedDependencies.createClient()
let settled = false
let hostKeyMismatch = false
const settle = (
connection?: Ssh2AuthenticatedConnection,
error?: unknown
): void => {
if (settled) {
connection?.dispose()
return
}
settled = true
clearTimeout(timeout)
signal.removeEventListener('abort', abort)
if (error) {
client.destroy()
reject(error)
} else if (connection) {
resolve(connection)
}
}
const abort = (): void => {
settle(undefined, createAbortError(signal))
}
const timeout = setTimeout(() => {
settle(undefined, new Error('SSH 连接超时'))
}, SSH_CONNECTION_TIMEOUT_MS)
signal.addEventListener('abort', abort, { once: true })
client.once('error', (error) => {
settle(
undefined,
hostKeyMismatch
? new Error(
'SSH 主机密钥已变化,请重新验证主机身份'
)
: safeSshError(error)
)
})
client.once('ready', () => {
settle(new Ssh2AuthenticatedConnection(identity, client))
})
let config: ConnectConfig
try {
config = buildAuthenticatedSshConnectConfig(
target.host,
algorithms,
resolvedDependencies.systemAgent(),
() => {
hostKeyMismatch = true
}
)
} catch (error) {
settle(undefined, error)
return
}
client.connect(config)
})
}
isUsable(): boolean {
return this.usable
}
async openAgentAttach(
installationId: VerifiedAgentInstallationId,
signal?: AbortSignal
): Promise<ClientChannel> {
const channel = await this.openFixedChannel(
buildFixedAgentSshCommand(installationId, {
kind: 'attach'
}),
signal
)
if (signal?.aborted) {
channel.destroy()
throw createAbortError(signal)
}
if (signal) {
const abort = (): void => {
channel.destroy()
}
signal.addEventListener('abort', abort, { once: true })
channel.once('close', () => {
signal.removeEventListener('abort', abort)
})
}
return channel
}
async runAgentBootstrapProbe(
signal?: AbortSignal
): Promise<AgentBootstrapProbeResult> {
const result = await this.runBoundedCommand(
AGENT_BOOTSTRAP_PROBE_COMMAND,
signal
)
if (
result.exitCode !== 0 ||
result.stderr.length !== 0
) {
throw new Error('Agent 启动探针执行失败')
}
return parseAgentBootstrapProbeOutput(result.stdout)
}
runAgentDoctor(
installationId: VerifiedAgentInstallationId,
signal?: AbortSignal
): Promise<AgentDiagnosticResult> {
return this.runBoundedCommand(
buildFixedAgentSshCommand(installationId, {
kind: 'doctor'
}),
signal
)
}
runAgentLifecycleAction(
installationId: VerifiedAgentInstallationId,
action: AgentLifecycleAction,
signal?: AbortSignal
): Promise<AgentDiagnosticResult> {
return this.runBoundedCommand(
buildFixedAgentSshCommand(installationId, {
kind: 'lifecycle',
action
}),
signal
)
}
runAgentRuntimeAction(
installationId: VerifiedAgentInstallationId,
action: AgentRuntimeActivationAction,
signal?: AbortSignal
): Promise<AgentDiagnosticResult> {
return this.runBoundedCommand(
buildFixedAgentSshCommand(installationId, action),
signal
)
}
openStagedSftp(
stagingDirectory: string,
limits?: BoundedSftpLimits,
signal?: AbortSignal
): Promise<StagedSftp> {
this.assertUsable()
if (signal?.aborted) {
return Promise.reject(createAbortError(signal))
}
return new Promise((resolve, reject) => {
let settled = false
const finish = (
error?: unknown,
sftp?: SFTPWrapper
): void => {
if (settled) {
sftp?.end()
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', abort)
if (error || !sftp) {
reject(error ?? new Error('无法打开 SFTP 通道'))
return
}
try {
resolve(
new BoundedStagedSftp(
sftp,
stagingDirectory,
limits
)
)
} catch (validationError) {
sftp.end()
reject(validationError)
}
}
const abort = (): void => {
finish(createAbortError(signal))
}
const timeout = setTimeout(() => {
finish(new Error('SFTP 通道打开超时'))
}, CHANNEL_OPEN_TIMEOUT_MS)
signal?.addEventListener('abort', abort, { once: true })
this.client.sftp((error, sftp) => {
finish(error, sftp)
})
})
}
listDirectories(
path?: string,
signal?: AbortSignal
): Promise<SshHostDirectoryBrowseResult> {
this.assertUsable()
return listBoundedSftpDirectories(
(callback) => {
this.client.sftp((error, sftp) => {
callback(error, sftp)
})
},
path,
signal
)
}
dispose(): void {
if (this.disposed) {
return
}
this.disposed = true
this.usable = false
this.client.end()
this.client.destroy()
}
private assertUsable(): void {
if (!this.usable) {
throw new Error('SSH 连接已关闭')
}
}
private openFixedChannel(
command: string,
signal?: AbortSignal
): Promise<ClientChannel> {
this.assertUsable()
if (signal?.aborted) {
return Promise.reject(createAbortError(signal))
}
return new Promise((resolve, reject) => {
let settled = false
const finish = (
error?: unknown,
channel?: ClientChannel
): void => {
if (settled) {
channel?.destroy()
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', abort)
if (error || !channel) {
reject(error ?? new Error('无法打开 Agent SSH 通道'))
} else {
resolve(channel)
}
}
const abort = (): void => {
finish(createAbortError(signal))
}
const timeout = setTimeout(() => {
finish(new Error('Agent SSH 通道打开超时'))
}, CHANNEL_OPEN_TIMEOUT_MS)
signal?.addEventListener('abort', abort, { once: true })
this.client.exec(
command,
{
env: {},
pty: false,
x11: false,
allowHalfOpen: false
},
(error, channel) => finish(error, channel)
)
})
}
private async runBoundedCommand(
command: string,
signal?: AbortSignal
): Promise<AgentDiagnosticResult> {
const channel = await this.openFixedChannel(command, signal)
if (signal?.aborted) {
channel.destroy()
throw createAbortError(signal)
}
return new Promise((resolve, reject) => {
const stdout: Buffer[] = []
const stderr: Buffer[] = []
let outputBytes = 0
let settled = false
const finish = (
result?: AgentDiagnosticResult,
error?: unknown
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', abort)
if (error) {
channel.destroy()
reject(error)
} else if (result) {
resolve(result)
}
}
const collect = (
destination: Buffer[],
chunk: Buffer | string
): void => {
const buffer = Buffer.from(chunk)
outputBytes += buffer.byteLength
if (outputBytes > MAX_DIAGNOSTIC_OUTPUT_BYTES) {
finish(
undefined,
new Error('Agent 诊断输出超过安全限制')
)
return
}
destination.push(buffer)
}
const abort = (): void => {
finish(undefined, createAbortError(signal))
}
const timeout = setTimeout(() => {
finish(undefined, new Error('Agent 诊断超时'))
}, FIXED_COMMAND_TIMEOUT_MS)
signal?.addEventListener('abort', abort, { once: true })
channel.on('data', (chunk: Buffer | string) => {
collect(stdout, chunk)
})
channel.stderr.on('data', (chunk: Buffer | string) => {
collect(stderr, chunk)
})
channel.once('error', (error: Error) => {
finish(undefined, error)
})
channel.once(
'close',
(code: number | null) => {
finish({
exitCode: typeof code === 'number' ? code : null,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8')
})
}
)
})
}
}
export class SshConnectionPool {
private readonly entries = new Map<string, PoolEntry>()
private readonly factory: SshConnectionFactory
private readonly systemAgent: () => string | undefined
private readonly idleTimeoutMs: number
private readonly authenticationIdentityKey = randomBytes(32)
private disposed = false
constructor(
factory?: SshConnectionFactory,
options: {
systemAgent?: () => string | undefined
idleTimeoutMs?: number
} = {}
) {
this.systemAgent = options.systemAgent ?? defaultSystemAgent
this.factory =
factory ??
((target, identity, signal) =>
Ssh2AuthenticatedConnection.connect(
target,
identity,
signal,
{ systemAgent: this.systemAgent }
))
this.idleTimeoutMs =
options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
if (
!Number.isSafeInteger(this.idleTimeoutMs) ||
this.idleTimeoutMs < 0
) {
throw new Error('SSH 连接池空闲超时无效')
}
}
async acquire(
target: SshConnectionPoolTarget,
signal?: AbortSignal
): Promise<SshConnectionLease> {
if (this.disposed) {
throw new Error('SSH 连接池已关闭')
}
signal?.throwIfAborted()
validatePoolTarget(target)
const identity: SshPoolConnectionIdentity = {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: createSshAuthenticationIdentity(
target.host,
this.systemAgent(),
this.authenticationIdentityKey
)
}
const key = poolKey(identity)
let entry = this.entries.get(key)
if (entry?.connection && !entry.connection.isUsable()) {
this.disposeEntry(entry)
entry = undefined
}
if (!entry) {
if (this.entries.size >= MAX_POOL_ENTRIES) {
throw new Error('SSH 连接池已达到安全上限')
}
const controller = new AbortController()
const newEntry: PoolEntry = {
key,
identity,
controller,
connectionPromise: Promise.resolve(undefined as never),
leases: 0,
waiters: 0
}
newEntry.connectionPromise = this.factory(
target,
identity,
controller.signal
).then(
(connection) => {
if (
this.disposed ||
this.entries.get(key) !== newEntry
) {
connection.dispose()
throw new Error('SSH 连接请求已失效')
}
newEntry.connection = connection
return connection
},
(error: unknown) => {
if (this.entries.get(key) === newEntry) {
this.entries.delete(key)
}
throw error
}
)
this.entries.set(key, newEntry)
entry = newEntry
}
if (entry.idleTimer) {
clearTimeout(entry.idleTimer)
entry.idleTimer = undefined
}
entry.waiters += 1
try {
const connection = await waitForSharedConnection(
entry.connectionPromise,
signal
)
if (!connection.isUsable()) {
this.disposeEntry(entry)
throw new Error('SSH 连接已关闭')
}
entry.leases += 1
return this.createLease(entry, connection)
} finally {
entry.waiters -= 1
if (entry.waiters === 0 && entry.leases === 0) {
this.scheduleIdleDisposal(entry)
}
}
}
disposeHost(hostId: string): void {
for (const entry of this.entries.values()) {
if (entry.identity.hostId === hostId) {
this.disposeEntry(entry)
}
}
}
dispose(): void {
if (this.disposed) {
return
}
this.disposed = true
for (const entry of [...this.entries.values()]) {
this.disposeEntry(entry)
}
}
private createLease(
entry: PoolEntry,
connection: AuthenticatedSshConnection
): SshConnectionLease {
let released = false
const assertActive = (): void => {
if (released) {
throw new Error('SSH 连接租约已释放')
}
}
return {
identity: connection.identity,
isUsable: () => !released && connection.isUsable(),
openAgentAttach: (installationId, signal) => {
assertActive()
return connection.openAgentAttach(installationId, signal)
},
runAgentDoctor: (installationId, signal) => {
assertActive()
return connection.runAgentDoctor(installationId, signal)
},
runAgentBootstrapProbe: (signal) => {
assertActive()
return connection.runAgentBootstrapProbe(signal)
},
runAgentLifecycleAction: (
installationId,
action,
signal
) => {
assertActive()
return connection.runAgentLifecycleAction(
installationId,
action,
signal
)
},
runAgentRuntimeAction: (
installationId,
action,
signal
) => {
assertActive()
return connection.runAgentRuntimeAction(
installationId,
action,
signal
)
},
openStagedSftp: (stagingDirectory, limits, signal) => {
assertActive()
return connection.openStagedSftp(
stagingDirectory,
limits,
signal
)
},
listDirectories: (path, signal) => {
assertActive()
return connection.listDirectories(path, signal)
},
release: () => {
if (released) {
return
}
released = true
entry.leases -= 1
if (entry.leases === 0 && entry.waiters === 0) {
this.scheduleIdleDisposal(entry)
}
}
}
}
private scheduleIdleDisposal(entry: PoolEntry): void {
if (
this.entries.get(entry.key) !== entry ||
entry.idleTimer
) {
return
}
if (!entry.connection || this.idleTimeoutMs === 0) {
this.disposeEntry(entry)
return
}
entry.idleTimer = setTimeout(() => {
if (entry.leases === 0 && entry.waiters === 0) {
this.disposeEntry(entry)
}
}, this.idleTimeoutMs)
}
private disposeEntry(entry: PoolEntry): void {
if (this.entries.get(entry.key) === entry) {
this.entries.delete(entry.key)
}
if (entry.idleTimer) {
clearTimeout(entry.idleTimer)
entry.idleTimer = undefined
}
entry.controller.abort()
entry.connection?.dispose()
}
}
@@ -0,0 +1,264 @@
import { describe, expect, it, vi } from 'vitest'
import type { SshConnectionTarget } from './ssh-host-store'
import type {
SshConnectionLease,
SshPoolConnectionIdentity
} from './ssh-connection-pool'
import { SshHostDirectoryBrowser } from './ssh-host-directory-browser'
const hostId = '00000000-0000-4000-8000-000000000301'
function createTarget(
overrides: Partial<SshConnectionTarget> = {}
): SshConnectionTarget {
return {
host: {
id: hostId,
name: 'Browse host',
hostname: 'browse.example.com',
port: 22,
username: 'browser',
authentication: 'password',
password: 'secret',
hostKey: {
algorithm: 'ssh-ed25519',
publicKeyBase64: 'key',
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
generation: 4
}
},
hostRevision: 9,
hostKeyGeneration: 4,
...overrides
}
}
function createLease(
identity: Partial<SshPoolConnectionIdentity> = {},
listDirectories = vi.fn(async () => ({
path: '/home/browser',
homeDirectory: '/home/browser',
parentPath: '/home',
entries: [],
truncated: false
}))
): SshConnectionLease {
return {
identity: {
hostId,
hostRevision: 9,
hostKeyGeneration: 4,
authenticationIdentity: 'a'.repeat(64),
...identity
},
isUsable: vi.fn(() => true),
openAgentAttach: vi.fn(),
runAgentDoctor: vi.fn(),
runAgentBootstrapProbe: vi.fn(),
runAgentLifecycleAction: vi.fn(),
runAgentRuntimeAction: vi.fn(),
openStagedSftp: vi.fn(),
listDirectories,
release: vi.fn()
}
}
function createBrowser(options: {
target?: SshConnectionTarget
lease?: ReturnType<typeof createLease>
events?: string[]
}) {
const events = options.events ?? []
const target = options.target ?? createTarget()
const lease = options.lease ?? createLease()
const resolveConnectionTarget = vi.fn(async () => {
events.push('resolve')
return target
})
const assertConnectionTargetCurrent = vi.fn(() => {
events.push('current')
})
const acquire = vi.fn(async () => {
events.push('acquire')
return lease
})
const originalList = lease.listDirectories
lease.listDirectories = vi.fn(async (path, signal) => {
events.push('list')
return originalList(path, signal)
})
const originalRelease = lease.release
lease.release = vi.fn(() => {
events.push('release')
originalRelease()
})
const browser = new SshHostDirectoryBrowser({
sshHosts: {
resolveConnectionTarget,
assertConnectionTargetCurrent
},
sshPool: { acquire }
})
return {
browser,
events,
target,
lease,
resolveConnectionTarget,
assertConnectionTargetCurrent,
acquire
}
}
describe('SSH host directory browser', () => {
it('checks the current target before and after browsing and always releases', async () => {
const test = createBrowser({})
const signal = new AbortController().signal
await expect(
test.browser.listDirectories(
hostId,
'/srv/projects',
signal
)
).resolves.toMatchObject({
path: '/home/browser',
homeDirectory: '/home/browser'
})
expect(test.events).toEqual([
'resolve',
'current',
'acquire',
'list',
'current',
'release'
])
expect(
test.assertConnectionTargetCurrent
).toHaveBeenNthCalledWith(1, {
hostId,
hostRevision: 9,
hostKeyGeneration: 4,
username: 'browser'
})
expect(
test.assertConnectionTargetCurrent
).toHaveBeenNthCalledWith(2, {
hostId,
hostRevision: 9,
hostKeyGeneration: 4,
username: 'browser'
})
expect(test.acquire).toHaveBeenCalledWith(test.target, signal)
expect(test.lease.listDirectories).toHaveBeenCalledWith(
'/srv/projects',
signal
)
expect(test.lease.release).toHaveBeenCalledOnce()
})
it.each([
['host id', { hostId: 'other-host' }],
['host revision', { hostRevision: 10 }],
['host-key generation', { hostKeyGeneration: 5 }]
])(
'rejects a lease with a mismatched %s and releases it',
async (_label, identity) => {
const test = createBrowser({
lease: createLease(identity)
})
await expect(
test.browser.listDirectories(hostId)
).rejects.toThrow('连接身份不匹配')
expect(test.lease.listDirectories).not.toHaveBeenCalled()
expect(test.lease.release).toHaveBeenCalledOnce()
expect(
test.assertConnectionTargetCurrent
).toHaveBeenCalledOnce()
}
)
it('rejects a target resolved for another host before acquiring', async () => {
const target = createTarget()
target.host.id = '00000000-0000-4000-8000-000000000302'
const test = createBrowser({ target })
await expect(
test.browser.listDirectories(hostId)
).rejects.toThrow('连接目标无效')
expect(test.acquire).not.toHaveBeenCalled()
expect(
test.assertConnectionTargetCurrent
).not.toHaveBeenCalled()
})
it('rejects a host change after listing and releases the lease', async () => {
const test = createBrowser({})
test.assertConnectionTargetCurrent
.mockImplementationOnce(() => {
test.events.push('current')
})
.mockImplementationOnce(() => {
test.events.push('current')
throw new Error('SSH 主机配置已变化,请重新验证')
})
await expect(
test.browser.listDirectories(hostId)
).rejects.toThrow('主机配置已变化')
expect(test.events.at(-1)).toBe('release')
expect(test.lease.release).toHaveBeenCalledOnce()
})
it('releases the lease when directory browsing fails', async () => {
const test = createBrowser({
lease: createLease(
{},
vi.fn(async () => {
throw new Error('SFTP unavailable')
})
)
})
await expect(
test.browser.listDirectories(hostId)
).rejects.toThrow('SFTP unavailable')
expect(test.lease.release).toHaveBeenCalledOnce()
expect(
test.assertConnectionTargetCurrent
).toHaveBeenCalledOnce()
})
it('honors cancellation before target resolution and after acquisition', async () => {
const alreadyCanceled = new AbortController()
alreadyCanceled.abort()
const early = createBrowser({})
await expect(
early.browser.listDirectories(
hostId,
undefined,
alreadyCanceled.signal
)
).rejects.toMatchObject({ name: 'AbortError' })
expect(early.resolveConnectionTarget).not.toHaveBeenCalled()
const controller = new AbortController()
const late = createBrowser({})
late.acquire.mockImplementationOnce(async () => {
late.events.push('acquire')
controller.abort()
return late.lease
})
await expect(
late.browser.listDirectories(
hostId,
undefined,
controller.signal
)
).rejects.toMatchObject({ name: 'AbortError' })
expect(late.lease.listDirectories).not.toHaveBeenCalled()
expect(late.lease.release).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,99 @@
import type {
SshHostDirectoryBrowseResult
} from '../../shared/ssh-host-contracts'
import type {
CurrentSshConnectionTarget,
SshConnectionTarget,
SshHostStore
} from './ssh-host-store'
import type {
SshConnectionLease,
SshConnectionPool
} from './ssh-connection-pool'
export type SshHostDirectoryBrowserOptions = {
sshHosts: Pick<
SshHostStore,
'resolveConnectionTarget' | 'assertConnectionTargetCurrent'
>
sshPool: Pick<SshConnectionPool, 'acquire'>
}
function currentTarget(
target: SshConnectionTarget
): CurrentSshConnectionTarget {
return {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
username: target.host.username
}
}
function assertTarget(
requestedHostId: string,
target: SshConnectionTarget
): void {
if (
target.host.id !== requestedHostId ||
target.hostRevision < 1 ||
target.hostKeyGeneration < 1 ||
!target.host.hostKey ||
target.host.hostKey.generation !==
target.hostKeyGeneration
) {
throw new Error('SSH 主机连接目标无效')
}
}
function assertLeaseIdentity(
target: SshConnectionTarget,
lease: SshConnectionLease
): void {
if (
lease.identity.hostId !== target.host.id ||
lease.identity.hostRevision !== target.hostRevision ||
lease.identity.hostKeyGeneration !==
target.hostKeyGeneration
) {
throw new Error('SSH 目录浏览连接身份不匹配')
}
}
export class SshHostDirectoryBrowser {
readonly #sshHosts: SshHostDirectoryBrowserOptions['sshHosts']
readonly #sshPool: SshHostDirectoryBrowserOptions['sshPool']
constructor(options: SshHostDirectoryBrowserOptions) {
this.#sshHosts = options.sshHosts
this.#sshPool = options.sshPool
}
async listDirectories(
hostId: string,
path?: string,
signal?: AbortSignal
): Promise<SshHostDirectoryBrowseResult> {
signal?.throwIfAborted()
const target =
await this.#sshHosts.resolveConnectionTarget(hostId)
signal?.throwIfAborted()
assertTarget(hostId, target)
const expected = currentTarget(target)
this.#sshHosts.assertConnectionTargetCurrent(expected)
let lease: SshConnectionLease | undefined
try {
lease = await this.#sshPool.acquire(target, signal)
signal?.throwIfAborted()
assertLeaseIdentity(target, lease)
const listing = await lease.listDirectories(path, signal)
signal?.throwIfAborted()
assertLeaseIdentity(target, lease)
this.#sshHosts.assertConnectionTargetCurrent(expected)
return listing
} finally {
lease?.release()
}
}
}
@@ -0,0 +1,229 @@
import { describe, expect, it, vi } from 'vitest'
import type { StagedSftp } from './bounded-sftp'
import type {
SshConnectionLease,
SshConnectionPoolTarget
} from './ssh-connection-pool'
import {
SshHostRemoteEnvironmentInspector
} from './ssh-host-remote-environment'
const hostId = '00000000-0000-4000-8000-000000000401'
const digest = `sha256:${'a'.repeat(64)}`
function target(): SshConnectionPoolTarget {
return {
host: {
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
hostKey: {
algorithm: 'ssh-ed25519',
publicKeyBase64: 'AAAA',
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
generation: 2
}
},
hostRevision: 3,
hostKeyGeneration: 2
}
}
function agentEntry(version = '0.11.1', digestCharacter = 'a') {
const manifestSha256 = digestCharacter.repeat(64)
return {
installationId: `agent-${manifestSha256}`,
agentVersion: version,
manifestSha256,
arch: 'x64' as const
}
}
function runtimeEntry(version = '1.18.9') {
return {
runtimeId: 'opencode',
runtimeVersion: version,
architecture: 'x64' as const,
bundleDigest: digest,
manifestDigest: digest,
acpCapabilitiesDigest: digest
}
}
function missing(): Error & { code: number } {
return Object.assign(new Error('missing'), { code: 2 })
}
function harness(registries: {
agent?: unknown
runtime?: unknown
} = {}) {
const release = vi.fn()
const close = vi.fn()
const readFile = vi.fn(async (path: string) => {
const value =
path === '.goodbuddy/agent/registry.json'
? registries.agent
: path === '.goodbuddy/runtimes/registry.json'
? registries.runtime
: undefined
if (value === undefined) {
throw missing()
}
return Buffer.from(JSON.stringify(value))
})
const sftp = { readFile, close } as unknown as StagedSftp
const currentTarget = target()
const lease = {
identity: {
hostId,
hostRevision: 3,
hostKeyGeneration: 2,
authenticationIdentity: 'b'.repeat(64)
},
runAgentBootstrapProbe: vi.fn(async () => ({
ready: true as const,
platform: 'linux' as const,
architecture: 'x64' as const,
canonicalHomeDirectory: '/home/builder',
uid: 1_000,
shell: '/bin/bash',
procfs: 'ready' as const
})),
openStagedSftp: vi.fn(async () => sftp),
release
} as unknown as SshConnectionLease
const assertConnectionTargetCurrent = vi.fn()
const inspector = new SshHostRemoteEnvironmentInspector({
sshHosts: {
resolveConnectionTarget: vi.fn(async () => currentTarget),
assertConnectionTargetCurrent
},
sshPool: {
acquire: vi.fn(async () => lease)
},
agentRuntimeLockPath: 'unused',
remoteRuntimeLockPath: 'unused',
loadExpectedCatalog: async () => ({
agent: { version: '0.11.1' },
runtimes: [{
runtimeId: 'opencode',
provider: 'opencode',
version: '1.18.9'
}]
}),
now: () => new Date('2030-01-01T00:00:00.000Z')
})
return {
inspector,
lease,
close,
release,
assertConnectionTargetCurrent
}
}
describe('SshHostRemoteEnvironmentInspector', () => {
it('reports installed Agent and Runtime versions against the desktop catalog', async () => {
const value = harness({
agent: {
formatVersion: 1,
minimumTrustedReleaseSequence: 1,
current: {
...agentEntry(),
productVersion: '0.11.0',
releaseSequence: 4,
binaryDigest: `sha256:${'a'.repeat(64)}`,
protocol: { major: 1, minor: 0 },
signingKeyId: 'test-key',
previouslyVerified: true
},
draining: []
},
runtime: {
formatVersion: 1,
minimumTrustedReleaseSequence: 1,
current: [{
...runtimeEntry(),
provider: 'opencode',
releaseSequence: 3,
signingKeyId: 'test-key'
}]
}
})
await expect(value.inspector.inspect(hostId)).resolves.toEqual({
hostId,
checkedAt: '2030-01-01T00:00:00.000Z',
architecture: 'x64',
agent: {
state: 'current',
expected: {
version: '0.11.1',
architecture: 'x64'
},
installed: {
version: '0.11.1',
architecture: 'x64'
}
},
runtimes: [{
runtimeId: 'opencode',
provider: 'opencode',
state: 'current',
expected: {
version: '1.18.9',
architecture: 'x64'
},
installed: {
version: '1.18.9',
architecture: 'x64'
}
}]
})
expect(value.close).toHaveBeenCalledOnce()
expect(value.release).toHaveBeenCalledOnce()
expect(value.assertConnectionTargetCurrent).toHaveBeenCalledTimes(2)
})
it('reports an older Agent and a missing Runtime without installing either', async () => {
const value = harness({
agent: {
formatVersion: 1,
current: agentEntry('0.11.0', 'b')
}
})
await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({
agent: {
state: 'update-available',
installed: { version: '0.11.0' }
},
runtimes: [{
runtimeId: 'opencode',
state: 'not-installed',
installed: null
}]
})
expect(value.lease.runAgentBootstrapProbe).toHaveBeenCalledOnce()
expect(value.lease.openStagedSftp).toHaveBeenCalledOnce()
})
it('rejects corrupt remote metadata and still closes every lease', async () => {
const value = harness({
agent: {
formatVersion: 1,
current: { command: 'untrusted' }
}
})
await expect(value.inspector.inspect(hostId)).rejects.toThrow(
'元数据已损坏'
)
expect(value.close).toHaveBeenCalledOnce()
expect(value.release).toHaveBeenCalledOnce()
})
})
+341
View File
@@ -0,0 +1,341 @@
import { readFile } from 'node:fs/promises'
import {
agentRuntimeLockSchema,
type AgentRuntimeLock
} from '../../shared/agent-installation-contracts'
import {
remoteRuntimeLockSchema,
type RemoteRuntimeLock
} from '../../shared/remote-runtime-launch-contracts'
import {
sshHostRemoteEnvironmentSchema,
type SshHostRemoteEnvironment
} from '../../shared/ssh-host-contracts'
import {
parseInstallationRegistryState,
parseRuntimeRegistryState,
type InstallationRegistryEntry,
type RuntimeRegistryEntry
} from '../../shared/remote-environment-registry-contracts'
import type { StagedSftp } from './bounded-sftp'
import type {
CurrentSshConnectionTarget,
SshConnectionTarget,
SshHostStore
} from './ssh-host-store'
import type {
SshConnectionLease,
SshConnectionPool
} from './ssh-connection-pool'
const AGENT_REGISTRY_PATH = '.goodbuddy/agent/registry.json'
const RUNTIME_REGISTRY_PATH = '.goodbuddy/runtimes/registry.json'
const MAXIMUM_REGISTRY_BYTES = 256 * 1024
type ExpectedCatalog = {
agent: {
version: string
}
runtimes: Array<{
runtimeId: 'opencode'
provider: 'opencode'
version: string
}>
}
export type SshHostRemoteEnvironmentInspectorOptions = {
sshHosts: Pick<
SshHostStore,
'resolveConnectionTarget' | 'assertConnectionTargetCurrent'
>
sshPool: Pick<SshConnectionPool, 'acquire'>
agentRuntimeLockPath: string
remoteRuntimeLockPath: string
loadExpectedCatalog?: () => Promise<ExpectedCatalog>
now?: () => Date
}
export class SshHostRemoteEnvironmentInspector {
readonly #sshHosts: SshHostRemoteEnvironmentInspectorOptions['sshHosts']
readonly #sshPool: SshHostRemoteEnvironmentInspectorOptions['sshPool']
readonly #loadExpectedCatalog: () => Promise<ExpectedCatalog>
readonly #now: () => Date
#expectedCatalog?: Promise<ExpectedCatalog>
constructor(options: SshHostRemoteEnvironmentInspectorOptions) {
this.#sshHosts = options.sshHosts
this.#sshPool = options.sshPool
this.#loadExpectedCatalog =
options.loadExpectedCatalog ??
(() =>
loadExpectedCatalog(
options.agentRuntimeLockPath,
options.remoteRuntimeLockPath
))
this.#now = options.now ?? (() => new Date())
}
async inspect(
hostId: string,
signal?: AbortSignal
): Promise<SshHostRemoteEnvironment> {
signal?.throwIfAborted()
const target =
await this.#sshHosts.resolveConnectionTarget(hostId)
signal?.throwIfAborted()
assertTarget(hostId, target)
const expectedTarget = currentTarget(target)
this.#sshHosts.assertConnectionTargetCurrent(expectedTarget)
let lease: SshConnectionLease | undefined
let sftp: StagedSftp | undefined
try {
lease = await this.#sshPool.acquire(target, signal)
assertLeaseIdentity(target, lease)
const probe = await lease.runAgentBootstrapProbe(signal)
signal?.throwIfAborted()
if (!probe.ready) {
throw new Error(
`远端系统不能运行 GoodBuddy Agent:${probe.reason}`
)
}
sftp = await lease.openStagedSftp(
probe.canonicalHomeDirectory,
{
maximumFileBytes: MAXIMUM_REGISTRY_BYTES,
maximumTotalBytes: MAXIMUM_REGISTRY_BYTES * 2,
maximumOperations: 8,
operationTimeoutMs: 15_000
},
signal
)
const [catalog, agentRegistry, runtimeRegistry] =
await Promise.all([
this.#expected(),
readOptionalRegistry(
sftp,
AGENT_REGISTRY_PATH,
{ parse: parseInstallationRegistryState },
signal
),
readOptionalRegistry(
sftp,
RUNTIME_REGISTRY_PATH,
{ parse: parseRuntimeRegistryState },
signal
)
])
signal?.throwIfAborted()
assertLeaseIdentity(target, lease)
this.#sshHosts.assertConnectionTargetCurrent(expectedTarget)
return sshHostRemoteEnvironmentSchema.parse({
hostId,
checkedAt: this.#now().toISOString(),
architecture: probe.architecture,
agent: agentStatus(
catalog.agent,
agentRegistry?.current,
probe.architecture
),
runtimes: catalog.runtimes.map((runtime) =>
runtimeStatus(
runtime,
runtimeRegistry?.current.find(
(installed) =>
installed.runtimeId === runtime.runtimeId &&
installed.architecture === probe.architecture
),
probe.architecture
)
)
})
} finally {
sftp?.close()
lease?.release()
}
}
#expected(): Promise<ExpectedCatalog> {
this.#expectedCatalog ??= this.#loadExpectedCatalog()
return this.#expectedCatalog
}
}
function currentTarget(
target: SshConnectionTarget
): CurrentSshConnectionTarget {
return {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
username: target.host.username
}
}
function assertTarget(
requestedHostId: string,
target: SshConnectionTarget
): void {
if (
target.host.id !== requestedHostId ||
target.hostRevision < 1 ||
target.hostKeyGeneration < 1 ||
!target.host.hostKey ||
target.host.hostKey.generation !== target.hostKeyGeneration
) {
throw new Error('SSH 主机连接目标无效')
}
}
function assertLeaseIdentity(
target: SshConnectionTarget,
lease: SshConnectionLease
): void {
if (
lease.identity.hostId !== target.host.id ||
lease.identity.hostRevision !== target.hostRevision ||
lease.identity.hostKeyGeneration !== target.hostKeyGeneration
) {
throw new Error('SSH 远端运行环境连接身份不匹配')
}
}
async function readOptionalRegistry<T>(
sftp: StagedSftp,
path: string,
schema: { parse(value: unknown): T },
signal?: AbortSignal
): Promise<T | undefined> {
let bytes: Buffer
try {
bytes = await sftp.readFile(path, signal)
} catch (error) {
if (isMissingPathError(error)) {
return undefined
}
throw error
}
let value: unknown
try {
value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
)
} catch (error) {
throw new Error('远端 GoodBuddy 运行环境元数据无效', {
cause: error
})
}
try {
return schema.parse(value)
} catch (error) {
throw new Error('远端 GoodBuddy 运行环境元数据已损坏', {
cause: error
})
}
}
function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(
(error as { code?: unknown }).code === 2 ||
(error as { code?: unknown }).code === 'ENOENT'
)
)
}
function environmentState(
installedVersion: string | undefined,
expectedVersion: string
): 'current' | 'update-available' | 'not-installed' {
if (!installedVersion) {
return 'not-installed'
}
return installedVersion === expectedVersion
? 'current'
: 'update-available'
}
function agentStatus(
expected: ExpectedCatalog['agent'],
installed: InstallationRegistryEntry | undefined,
architecture: 'x64' | 'arm64'
) {
if (installed && installed.arch !== architecture) {
throw new Error('远端 Agent registry 架构与主机不匹配')
}
const installedVersion = installed
? {
version: installed.agentVersion,
architecture: installed.arch
}
: null
return {
state: environmentState(installed?.agentVersion, expected.version),
expected: { ...expected, architecture },
installed: installedVersion
}
}
function runtimeStatus(
expected: ExpectedCatalog['runtimes'][number],
installed: RuntimeRegistryEntry | undefined,
architecture: 'x64' | 'arm64'
) {
const installedVersion = installed
? {
version: installed.runtimeVersion,
architecture: installed.architecture
}
: null
return {
runtimeId: expected.runtimeId,
provider: expected.provider,
state: environmentState(installed?.runtimeVersion, expected.version),
expected: {
version: expected.version,
architecture
},
installed: installedVersion
}
}
async function loadExpectedCatalog(
agentRuntimeLockPath: string,
remoteRuntimeLockPath: string
): Promise<ExpectedCatalog> {
const [agentBytes, runtimeBytes] = await Promise.all([
readFile(agentRuntimeLockPath),
readFile(remoteRuntimeLockPath)
])
let agentLock: AgentRuntimeLock
let runtimeLock: RemoteRuntimeLock
try {
agentLock = agentRuntimeLockSchema.parse(
JSON.parse(agentBytes.toString('utf8'))
)
runtimeLock = remoteRuntimeLockSchema.parse(
JSON.parse(runtimeBytes.toString('utf8'))
)
} catch (error) {
throw new Error('GoodBuddy 内置远端运行环境版本元数据无效', {
cause: error
})
}
return {
agent: {
version: agentLock.agentVersion
},
runtimes: Object.entries(runtimeLock.runtimes)
.map(([runtimeId, runtime]) => ({
runtimeId: runtimeId as 'opencode',
provider: runtime.provider,
version: runtime.version
}))
.sort((left, right) =>
left.runtimeId.localeCompare(right.runtimeId, 'en')
)
}
}
+736
View File
@@ -0,0 +1,736 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SettingsCredentialCipher } from '../settings-credential-cipher'
import {
SshHostService,
type SshHostLifecycleHooks
} from './ssh-host-service'
import { SshHostStore } from './ssh-host-store'
import type {
SshHostKeyCandidate,
SshTransport
} from './ssh-transport'
const roots: string[] = []
const keyA: SshHostKeyCandidate = {
algorithm: 'ssh-ed25519',
publicKeyBase64: Buffer.from('key-a').toString('base64'),
fingerprintSha256: `SHA256:${'A'.repeat(43)}`
}
const keyB: SshHostKeyCandidate = {
algorithm: 'ssh-ed25519',
publicKeyBase64: Buffer.from('key-b').toString('base64'),
fingerprintSha256: `SHA256:${'B'.repeat(43)}`
}
const decrypt = vi.fn((value: Buffer) => value.toString())
const cipher: SettingsCredentialCipher = {
isAvailable: () => true,
encrypt: (value) => Buffer.from(value),
decrypt
}
const newPasswordHost = {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password' as const,
password: {
action: 'replace' as const,
value: 'private password'
}
}
async function createHarness(
now = 1_000,
lifecycleHooks: SshHostLifecycleHooks = {}
): Promise<{
service: SshHostService
store: SshHostStore
inspectHostKey: ReturnType<typeof vi.fn>
testConnection: ReturnType<typeof vi.fn>
setNow: (value: number) => void
}> {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ssh-service-'))
roots.push(root)
const store = new SshHostStore(join(root, 'ssh-hosts.json'), cipher)
const inspectHostKey = vi.fn(async () => keyA)
const testConnection = vi.fn(async () => ({
connected: true as const,
latencyMs: 12,
platform: 'linux' as const,
architecture: 'x64' as const,
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
}))
const transport: SshTransport = {
inspectHostKey,
testConnection
}
return {
service: new SshHostService(
store,
transport,
() => now,
lifecycleHooks
),
store,
inspectHostKey,
testConnection,
setNow: (value) => {
now = value
}
}
}
afterEach(async () => {
vi.clearAllMocks()
await Promise.all(
roots
.splice(0)
.map((root) => rm(root, { recursive: true, force: true }))
)
})
describe('SshHostService', () => {
it('inspects a new draft before authentication without persisting it', async () => {
const harness = await createHarness()
const inspection = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
expect(inspection).toMatchObject({
state: 'unverified',
algorithm: keyA.algorithm,
fingerprintSha256: keyA.fingerprintSha256
})
expect(inspection.candidateId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
)
expect(harness.inspectHostKey).toHaveBeenCalledWith({
hostname: 'build.example.com',
port: 22,
username: 'builder'
})
expect(harness.testConnection).not.toHaveBeenCalled()
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
hosts: []
})
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyB.fingerprintSha256,
input: newPasswordHost
})
).rejects.toThrow('指纹不匹配')
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
hosts: []
})
harness.setNow(301_000)
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
})
).rejects.toThrow('已过期')
})
it('persists a host only after authentication and the probe succeed', async () => {
const harness = await createHarness()
const inspection = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
harness.testConnection
.mockRejectedValueOnce(new Error('SSH 认证失败'))
.mockResolvedValueOnce({
connected: true,
latencyMs: 12,
platform: 'linux',
architecture: 'x64',
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
})
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
})
).rejects.toThrow('SSH 认证失败')
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
hosts: []
})
const result = await harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
password: {
action: 'replace',
value: 'corrected password'
}
}
})
expect(result).toMatchObject({
host: {
name: 'Build host',
hostKey: {
state: 'verified',
fingerprintSha256: keyA.fingerprintSha256,
generation: 1
}
},
connection: {
connected: true,
platform: 'linux'
}
})
expect(result.connection.hostId).toBe(result.host.id)
expect(harness.testConnection).toHaveBeenLastCalledWith(
expect.objectContaining({
password: 'corrected password',
hostKey: keyA
})
)
expect(JSON.stringify(await harness.service.getSnapshot())).not.toContain(
'corrected password'
)
})
it('binds candidates to the inspected target and discards them on request', async () => {
const harness = await createHarness()
const inspection = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
hostname: 'other.example.com'
}
})
).rejects.toThrow('配置已变化')
expect(harness.testConnection).not.toHaveBeenCalled()
harness.service.discardCandidate(inspection.candidateId)
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
})
).rejects.toThrow('重新检查')
})
it('supersedes older candidates for the same draft target', async () => {
const harness = await createHarness()
const first = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
const second = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
await expect(
harness.service.validateAndSave({
candidateId: first.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
})
).rejects.toThrow('重新检查')
await expect(
harness.service.validateAndSave({
candidateId: second.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
})
).resolves.toMatchObject({
host: { name: 'Build host' }
})
expect(harness.testConnection).toHaveBeenCalledOnce()
})
it('rejects duplicate in-flight inspections for the same target', async () => {
const harness = await createHarness()
let finishInspection:
| ((candidate: SshHostKeyCandidate) => void)
| undefined
harness.inspectHostKey.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishInspection = resolve
})
)
const request = {
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
}
const first = harness.service.inspectDraftHostKey(request)
await vi.waitFor(() =>
expect(harness.inspectHostKey).toHaveBeenCalledOnce()
)
await expect(
harness.service.inspectDraftHostKey(request)
).rejects.toThrow('正在检查')
finishInspection?.(keyA)
await expect(first).resolves.toMatchObject({
fingerprintSha256: keyA.fingerprintSha256
})
})
it('classifies changed keys and atomically replaces a validated edit', async () => {
const harness = await createHarness()
const host = await harness.store.commitValidated({
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password',
password: {
action: 'replace',
value: 'private password'
}
},
hostKey: keyA
})
const decryptionsBeforeInspection = decrypt.mock.calls.length
harness.inspectHostKey.mockResolvedValueOnce(keyB)
const inspection = await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: 'build.example.com',
port: 22,
username: 'builder'
})
expect(inspection).toMatchObject({
hostId: host.id,
state: 'changed',
fingerprintSha256: keyB.fingerprintSha256,
previousHostKey: {
algorithm: keyA.algorithm,
fingerprintSha256: keyA.fingerprintSha256
}
})
expect(decrypt).toHaveBeenCalledTimes(decryptionsBeforeInspection)
const resolveConnectionTarget = vi.spyOn(
harness.store,
'resolveConnectionTarget'
)
const result = await harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyB.fingerprintSha256,
input: {
...newPasswordHost,
name: 'Renamed host',
password: { action: 'keep' }
}
})
expect(result.host).toMatchObject({
id: host.id,
name: 'Renamed host',
hostKey: {
state: 'verified',
fingerprintSha256: keyB.fingerprintSha256,
generation: 2
}
})
expect(harness.testConnection).toHaveBeenCalledWith(
expect.objectContaining({
id: host.id,
password: 'private password',
hostKey: keyB
})
)
expect(resolveConnectionTarget).toHaveBeenCalledOnce()
})
it.each([
[
'hostname',
{
hostname: 'moved.example.com'
}
],
[
'port',
{
port: 2222
}
],
[
'username',
{
username: 'deployer'
}
],
[
'authentication',
{
authentication: 'system-agent' as const
}
]
])(
'rejects credential keep before resolution when %s changes',
async (_field, change) => {
const harness = await createHarness()
const host = await harness.store.commitValidated({
input: newPasswordHost,
hostKey: keyA
})
const draft = {
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username,
...change
}
const inspection =
await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: draft.hostname,
port: draft.port,
username: draft.username
})
const resolveConnectionTarget = vi.spyOn(
harness.store,
'resolveConnectionTarget'
)
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
...change,
password: { action: 'keep' }
}
})
).rejects.toThrow(
'地址、端口、用户名或认证方式已变化'
)
expect(resolveConnectionTarget).toHaveBeenCalledOnce()
expect(harness.testConnection).not.toHaveBeenCalled()
}
)
it('allows credential keep when target and authentication are unchanged', async () => {
const harness = await createHarness()
const host = await harness.store.commitValidated({
input: newPasswordHost,
hostKey: keyA
})
const inspection = await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: host.hostname,
port: host.port,
username: host.username
})
const resolveConnectionTarget = vi.spyOn(
harness.store,
'resolveConnectionTarget'
)
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
name: 'Renamed host',
password: { action: 'keep' }
}
})
).resolves.toMatchObject({
host: {
id: host.id,
name: 'Renamed host'
}
})
expect(resolveConnectionTarget).toHaveBeenCalledOnce()
expect(harness.testConnection).toHaveBeenCalledWith(
expect.objectContaining({
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: 'password',
password: 'private password'
})
)
})
it('rejects an edit when the durable host changes after inspection', async () => {
const harness = await createHarness()
const host = await harness.store.commitValidated({
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
const inspection = await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: host.hostname,
port: host.port,
username: host.username
})
const identity = await harness.store.getHostIdentity(host.id)
await harness.store.commitValidated({
hostId: host.id,
expectedRevision: identity.revision,
input: {
name: 'Changed elsewhere',
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
name: 'Stale edit',
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: 'system-agent',
password: { action: 'clear' }
}
})
).rejects.toThrow('配置已变化')
expect(harness.testConnection).not.toHaveBeenCalled()
})
it('validates target edits against the draft before replacing the durable host', async () => {
const harness = await createHarness()
const host = await harness.store.commitValidated({
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password',
password: {
action: 'replace',
value: 'old password'
}
},
hostKey: keyA
})
harness.inspectHostKey.mockResolvedValueOnce(keyB)
const inspection = await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: 'moved.example.com',
port: 2222,
username: 'deployer'
})
expect(inspection).toMatchObject({
state: 'unverified',
fingerprintSha256: keyB.fingerprintSha256
})
const result = await harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyB.fingerprintSha256,
input: {
name: 'Moved host',
hostname: 'moved.example.com',
port: 2222,
username: 'deployer',
authentication: 'password',
password: {
action: 'replace',
value: 'new password'
}
}
})
expect(result.host).toMatchObject({
id: host.id,
hostname: 'moved.example.com',
port: 2222,
username: 'deployer',
hostKey: {
fingerprintSha256: keyB.fingerprintSha256,
generation: 2
}
})
expect(harness.testConnection).toHaveBeenCalledWith(
expect.objectContaining({
hostname: 'moved.example.com',
port: 2222,
username: 'deployer',
password: 'new password'
})
)
})
it('fires credential-free lifecycle hooks only after successful edit and removal commits', async () => {
const onHostEdited = vi.fn()
const onHostRemoved = vi.fn()
const harness = await createHarness(1_000, {
onHostEdited,
onHostRemoved
})
const host = await harness.store.commitValidated({
input: newPasswordHost,
hostKey: keyA
})
const inspection = await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: host.hostname,
port: host.port,
username: host.username
})
harness.testConnection.mockRejectedValueOnce(
new Error('SSH 认证失败')
)
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
name: 'Renamed host',
password: { action: 'keep' }
}
})
).rejects.toThrow('SSH 认证失败')
expect(onHostEdited).not.toHaveBeenCalled()
expect(onHostRemoved).not.toHaveBeenCalled()
await harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
name: 'Renamed host',
password: { action: 'keep' }
}
})
expect(onHostEdited).toHaveBeenCalledWith(host.id)
expect(onHostEdited.mock.calls[0]).toEqual([host.id])
expect(onHostRemoved).not.toHaveBeenCalled()
await harness.service.remove(host.id)
expect(onHostRemoved).toHaveBeenCalledWith(host.id)
expect(onHostRemoved.mock.calls[0]).toEqual([host.id])
await expect(
harness.service.remove(host.id)
).rejects.toThrow('不存在')
expect(onHostRemoved).toHaveBeenCalledOnce()
})
it('bounds the number of pending host-key candidates', async () => {
const harness = await createHarness()
for (let index = 0; index < 100; index += 1) {
await harness.service.inspectDraftHostKey({
hostname: `build-${index}.example.com`,
port: 22,
username: 'builder'
})
}
await expect(
harness.service.inspectDraftHostKey({
hostname: 'one-too-many.example.com',
port: 22,
username: 'builder'
})
).rejects.toThrow('检查过多')
expect(harness.inspectHostKey).toHaveBeenCalledTimes(100)
})
it('allows only one validation to consume a candidate', async () => {
const harness = await createHarness()
const inspection = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
let finishConnection:
| ((result: {
connected: true
latencyMs: number
platform: 'linux'
architecture: 'x64'
shell: string
homeDirectory: string
detail: string
}) => void)
| undefined
harness.testConnection.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishConnection = resolve
})
)
const request = {
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
}
const first = harness.service.validateAndSave(request)
await vi.waitFor(() =>
expect(harness.testConnection).toHaveBeenCalledOnce()
)
await expect(
harness.service.validateAndSave(request)
).rejects.toThrow('正在验证')
finishConnection?.({
connected: true,
latencyMs: 12,
platform: 'linux',
architecture: 'x64',
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
})
await expect(first).resolves.toMatchObject({
host: { name: 'Build host' }
})
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
hosts: [{ name: 'Build host' }]
})
})
})
+346
View File
@@ -0,0 +1,346 @@
import { randomUUID } from 'node:crypto'
import type {
SshHostDraftInspectionRequest,
SshHostKeyInspection,
SshHostsSnapshot,
SshHostValidationRequest,
SshHostValidationResult
} from '../../shared/ssh-host-contracts'
import {
sshHostCreateInputSchema,
sshHostDraftInspectionRequestSchema,
sshHostUpdateInputSchema,
sshHostValidationRequestSchema
} from '../../shared/ssh-host-contracts'
import type { SshHostStore } from './ssh-host-store'
import type {
SshHostKeyCandidate,
SshTransport
} from './ssh-transport'
const CANDIDATE_TTL_MS = 5 * 60_000
const MAX_PENDING_CANDIDATES = 100
type PendingHostKey = {
candidate: SshHostKeyCandidate
hostId?: string
expectedRevision?: number
sourceTargetKey?: string
targetKey: string
expiresAt: number
}
function targetKey(host: {
hostname: string
port: number
username: string
}): string {
return `${host.hostname}\0${host.port}\0${host.username}`
}
export type SshHostLifecycleHooks = {
onHostEdited?: (hostId: string) => void
onHostRemoved?: (hostId: string) => void
}
export class SshHostService {
private readonly pendingHostKeys = new Map<
string,
PendingHostKey
>()
private activeInspections = 0
private readonly activeInspectionKeys = new Set<string>()
private readonly activeValidations = new Set<string>()
private readonly activeValidationKeys = new Set<string>()
private readonly now: () => number
private readonly lifecycleHooks: SshHostLifecycleHooks
constructor(
private readonly store: SshHostStore,
private readonly transport: SshTransport,
nowOrHooks: (() => number) | SshHostLifecycleHooks = Date.now,
lifecycleHooks: SshHostLifecycleHooks = {}
) {
this.now =
typeof nowOrHooks === 'function' ? nowOrHooks : Date.now
this.lifecycleHooks =
typeof nowOrHooks === 'function'
? lifecycleHooks
: nowOrHooks
}
getSnapshot(): Promise<SshHostsSnapshot> {
return this.store.getSnapshot()
}
async remove(hostId: string): Promise<void> {
await this.store.remove(hostId)
for (const [candidateId, pending] of this.pendingHostKeys) {
if (pending.hostId === hostId) {
this.pendingHostKeys.delete(candidateId)
}
}
this.lifecycleHooks.onHostRemoved?.(hostId)
}
async inspectDraftHostKey(
request: SshHostDraftInspectionRequest
): Promise<SshHostKeyInspection> {
const parsed = sshHostDraftInspectionRequestSchema.parse(request)
const requestedTargetKey = targetKey(parsed)
const inspectionKeys = [
`target:${requestedTargetKey}`,
...(parsed.hostId ? [`host:${parsed.hostId}`] : [])
]
if (
inspectionKeys.some((key) =>
this.activeValidationKeys.has(key)
)
) {
throw new Error('SSH 主机正在验证,请等待当前操作完成')
}
if (
inspectionKeys.some((key) =>
this.activeInspectionKeys.has(key)
)
) {
throw new Error('SSH 主机密钥正在检查,请等待当前操作完成')
}
for (const [candidateId, pending] of this.pendingHostKeys) {
if (pending.expiresAt <= this.now()) {
this.pendingHostKeys.delete(candidateId)
}
}
if (
this.pendingHostKeys.size + this.activeInspections >=
MAX_PENDING_CANDIDATES
) {
throw new Error('SSH 主机密钥检查过多,请稍后重试')
}
this.activeInspections += 1
for (const key of inspectionKeys) {
this.activeInspectionKeys.add(key)
}
let existing:
| Awaited<ReturnType<SshHostStore['getHostIdentity']>>
| undefined
let candidate: SshHostKeyCandidate
try {
existing = parsed.hostId
? await this.store.getHostIdentity(parsed.hostId)
: undefined
candidate = await this.transport.inspectHostKey({
hostname: parsed.hostname,
port: parsed.port,
username: parsed.username
})
} finally {
this.activeInspections -= 1
for (const key of inspectionKeys) {
this.activeInspectionKeys.delete(key)
}
}
const sameEndpoint =
existing !== undefined &&
existing.hostname === parsed.hostname &&
existing.port === parsed.port
const existingHostKey = sameEndpoint
? existing?.hostKey
: undefined
const state =
!existingHostKey
? 'unverified'
: existingHostKey.fingerprintSha256 ===
candidate.fingerprintSha256
? 'verified'
: 'changed'
const candidateId = randomUUID()
for (const [existingId, pending] of this.pendingHostKeys) {
if (
!this.activeValidations.has(existingId) &&
(pending.targetKey === requestedTargetKey ||
(existing && pending.hostId === existing.id))
) {
this.pendingHostKeys.delete(existingId)
}
}
this.pendingHostKeys.set(candidateId, {
candidate,
...(existing
? {
hostId: existing.id,
expectedRevision: existing.revision,
sourceTargetKey: targetKey(existing)
}
: {}),
targetKey: requestedTargetKey,
expiresAt: this.now() + CANDIDATE_TTL_MS
})
return {
candidateId,
...(existing ? { hostId: existing.id } : {}),
state,
algorithm: candidate.algorithm,
fingerprintSha256: candidate.fingerprintSha256,
...(state === 'changed' && existing?.hostKey
? {
previousHostKey: {
algorithm: existing.hostKey.algorithm,
fingerprintSha256:
existing.hostKey.fingerprintSha256
}
}
: {})
}
}
discardCandidate(candidateId: string): void {
if (!this.activeValidations.has(candidateId)) {
this.pendingHostKeys.delete(candidateId)
}
}
async validateAndSave(
request: SshHostValidationRequest
): Promise<SshHostValidationResult> {
const parsed = sshHostValidationRequestSchema.parse(request)
const pending = this.pendingHostKeys.get(parsed.candidateId)
if (!pending || pending.expiresAt <= this.now()) {
this.pendingHostKeys.delete(parsed.candidateId)
throw new Error('SSH 主机密钥检查已过期,请重新检查')
}
if (pending.targetKey !== targetKey(parsed.input)) {
throw new Error('SSH 主机配置已变化,请重新检查主机密钥')
}
if (
pending.candidate.fingerprintSha256 !==
parsed.fingerprintSha256
) {
throw new Error('SSH 主机密钥指纹不匹配')
}
if (this.activeValidations.has(parsed.candidateId)) {
throw new Error('SSH 主机正在验证,请等待当前操作完成')
}
const validationKeys = [
`target:${pending.targetKey}`,
...(pending.hostId ? [`host:${pending.hostId}`] : [])
]
if (
validationKeys.some((key) =>
this.activeValidationKeys.has(key)
)
) {
throw new Error('SSH 主机正在验证,请等待当前操作完成')
}
this.activeValidations.add(parsed.candidateId)
for (const key of validationKeys) {
this.activeValidationKeys.add(key)
}
try {
let resolvedExisting:
| Awaited<
ReturnType<SshHostStore['resolveConnectionTarget']>
>
| undefined
if (pending.hostId) {
resolvedExisting =
await this.store.resolveConnectionTarget(
pending.hostId
)
const existing = {
id: resolvedExisting.host.id,
hostname: resolvedExisting.host.hostname,
port: resolvedExisting.host.port,
username: resolvedExisting.host.username,
revision: resolvedExisting.hostRevision
}
if (
existing.revision !== pending.expectedRevision ||
targetKey(existing) !== pending.sourceTargetKey
) {
this.pendingHostKeys.delete(parsed.candidateId)
throw new Error(
'SSH 主机配置已变化,请重新检查主机密钥'
)
}
sshHostUpdateInputSchema.parse(parsed.input)
if (
parsed.input.password.action === 'keep' &&
(existing.hostname !== parsed.input.hostname ||
existing.port !== parsed.input.port ||
existing.username !== parsed.input.username ||
parsed.input.authentication !== 'password')
) {
throw new Error(
'主机地址、端口、用户名或认证方式已变化,请重新输入密码'
)
}
} else {
sshHostCreateInputSchema.parse(parsed.input)
}
let password: string | undefined
if (parsed.input.authentication === 'password') {
if (parsed.input.password.action === 'replace') {
if (!this.store.isSecureStorageAvailable()) {
throw new Error(
'系统安全存储不可用,无法保存 SSH 密码'
)
}
password = parsed.input.password.value
} else if (
parsed.input.password.action === 'keep' &&
pending.hostId
) {
password = resolvedExisting?.host.password
} else {
throw new Error('SSH 密码不能为空')
}
}
const connection = await this.transport.testConnection({
id: pending.hostId ?? parsed.candidateId,
name: parsed.input.name,
hostname: parsed.input.hostname,
port: parsed.input.port,
username: parsed.input.username,
authentication: parsed.input.authentication,
...(password ? { password } : {}),
hostKey: pending.candidate
})
const host = await this.store.commitValidated({
...(pending.hostId ? { hostId: pending.hostId } : {}),
...(pending.expectedRevision !== undefined
? { expectedRevision: pending.expectedRevision }
: {}),
input: parsed.input,
hostKey: pending.candidate
})
if (pending.hostId) {
this.lifecycleHooks.onHostEdited?.(host.id)
}
for (const [candidateId, candidate] of this.pendingHostKeys) {
if (
candidateId === parsed.candidateId ||
candidate.targetKey === pending.targetKey ||
(pending.hostId && candidate.hostId === pending.hostId)
) {
this.pendingHostKeys.delete(candidateId)
}
}
return {
host,
connection: {
hostId: host.id,
...connection
}
}
} finally {
this.activeValidations.delete(parsed.candidateId)
for (const key of validationKeys) {
this.activeValidationKeys.delete(key)
}
}
}
}
+554
View File
@@ -0,0 +1,554 @@
import {
mkdtemp,
readFile,
readdir,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
SshHost,
SshHostUpdateInput
} from '../../shared/ssh-host-contracts'
import type { SettingsCredentialCipher } from '../settings-credential-cipher'
import {
SshHostStore,
type ValidatedSshHostCommit
} from './ssh-host-store'
const roots: string[] = []
const fingerprintA = `SHA256:${'A'.repeat(43)}`
const fingerprintB = `SHA256:${'B'.repeat(43)}`
const keyA = {
algorithm: 'ssh-ed25519',
publicKeyBase64: Buffer.from('first-key').toString('base64'),
fingerprintSha256: fingerprintA
}
const keyB = {
algorithm: 'ssh-ed25519',
publicKeyBase64: Buffer.from('second-key').toString('base64'),
fingerprintSha256: fingerprintB
}
function createCipher(available = true): SettingsCredentialCipher {
return {
isAvailable: () => available,
encrypt: (value) =>
Buffer.from(
`protected:${Buffer.from(value).toString('base64')}`
),
decrypt: (value) =>
Buffer.from(
value.toString().replace(/^protected:/u, ''),
'base64'
).toString()
}
}
async function createHarness(
cipher = createCipher()
): Promise<{
filePath: string
store: SshHostStore
}> {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ssh-hosts-'))
roots.push(root)
const filePath = join(root, 'ssh-hosts.json')
return {
filePath,
store: new SshHostStore(filePath, cipher)
}
}
function passwordHostInput(
overrides: Partial<SshHostUpdateInput> = {}
): SshHostUpdateInput {
return {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password',
password: {
action: 'replace',
value: 'private password'
},
...overrides
}
}
async function updateValidated(
store: SshHostStore,
host: SshHost,
input: SshHostUpdateInput,
hostKey: ValidatedSshHostCommit['hostKey'] = keyA
): Promise<SshHost> {
const identity = await store.getHostIdentity(host.id)
return store.commitValidated({
hostId: host.id,
expectedRevision: identity.revision,
input,
hostKey
})
}
afterEach(async () => {
await Promise.all(
roots
.splice(0)
.map((root) => rm(root, { recursive: true, force: true }))
)
})
describe('SshHostStore', () => {
it('atomically creates a validated host with an encrypted credential and pinned key', async () => {
const { filePath, store } = await createHarness()
const created = await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
expect(created).toMatchObject({
credentialConfigured: true,
credentialSource: 'encrypted',
hostKey: {
state: 'verified',
fingerprintSha256: fingerprintA,
generation: 1
},
lastValidatedAt: expect.any(String)
})
const persisted = await readFile(filePath, 'utf8')
expect(persisted).not.toContain('private password')
expect(persisted).toContain(fingerprintA)
expect(JSON.stringify(created)).not.toContain('ciphertextBase64')
await expect(store.resolveHost(created.id)).resolves.toMatchObject({
password: 'private password',
hostKey: {
fingerprintSha256: fingerprintA
}
})
})
it('resolves one atomic connection target snapshot with credentials and generations', async () => {
const { store } = await createHarness()
const created = await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
const identity = await store.getHostIdentity(created.id)
const targetPromise = store.resolveConnectionTarget(created.id)
const editPromise = store.commitValidated({
hostId: created.id,
expectedRevision: identity.revision,
input: passwordHostInput({
password: {
action: 'replace',
value: 'rotated password'
}
}),
hostKey: keyB
})
await expect(targetPromise).resolves.toMatchObject({
host: {
id: created.id,
password: 'private password',
hostKey: {
fingerprintSha256: fingerprintA,
generation: 1
}
},
hostRevision: 1,
hostKeyGeneration: 1
})
await expect(editPromise).resolves.toMatchObject({
hostKey: {
fingerprintSha256: fingerprintB,
generation: 2
}
})
await expect(
store.resolveConnectionTarget(created.id)
).resolves.toMatchObject({
host: {
password: 'rotated password',
hostKey: {
fingerprintSha256: fingerprintB,
generation: 2
}
},
hostRevision: 2,
hostKeyGeneration: 2
})
})
it('reuses credential validation across unchanged public snapshots', async () => {
const baseCipher = createCipher()
const decrypt = vi.fn(baseCipher.decrypt)
const { store } = await createHarness({
...baseCipher,
decrypt
})
await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
const decryptionsAfterCommit = decrypt.mock.calls.length
await store.getSnapshot()
await store.getSnapshot()
expect(decrypt).toHaveBeenCalledTimes(decryptionsAfterCommit)
})
it('keeps or replaces credentials only through validated edits', async () => {
const { store } = await createHarness()
const created = await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
const renamed = await updateValidated(
store,
created,
passwordHostInput({
name: 'Renamed host',
password: { action: 'keep' }
})
)
expect(renamed).toMatchObject({
name: 'Renamed host',
hostKey: {
fingerprintSha256: fingerprintA,
generation: 1
}
})
await expect(store.resolveHost(created.id)).resolves.toMatchObject({
password: 'private password'
})
const identity = await store.getHostIdentity(created.id)
await expect(
store.commitValidated({
hostId: created.id,
expectedRevision: identity.revision,
input: passwordHostInput({
hostname: 'moved.example.com',
password: { action: 'keep' }
}),
hostKey: keyB
})
).rejects.toThrow('重新输入密码')
const moved = await updateValidated(
store,
renamed,
passwordHostInput({
name: 'Moved host',
hostname: 'moved.example.com',
password: {
action: 'replace',
value: 'second password'
}
}),
keyB
)
expect(moved).toMatchObject({
hostname: 'moved.example.com',
hostKey: {
fingerprintSha256: fingerprintB,
generation: 2
}
})
await expect(store.resolveHost(created.id)).resolves.toMatchObject({
password: 'second password'
})
})
it('rejects stale validated edits with a monotonic revision', async () => {
const { store } = await createHarness()
const created = await store.commitValidated({
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
const identity = await store.getHostIdentity(created.id)
await store.commitValidated({
hostId: created.id,
expectedRevision: identity.revision,
input: {
name: 'First edit',
hostname: created.hostname,
port: created.port,
username: created.username,
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
await expect(
store.commitValidated({
hostId: created.id,
expectedRevision: identity.revision,
input: {
name: 'Stale edit',
hostname: created.hostname,
port: created.port,
username: created.username,
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
).rejects.toThrow('配置已变化')
})
it('binds encrypted credentials to the host id', async () => {
const { filePath, store } = await createHarness()
const first = await store.commitValidated({
input: passwordHostInput({
name: 'First',
hostname: 'first.example.com',
password: { action: 'replace', value: 'first secret' }
}),
hostKey: keyA
})
const second = await store.commitValidated({
input: passwordHostInput({
name: 'Second',
hostname: 'second.example.com',
password: { action: 'replace', value: 'second secret' }
}),
hostKey: keyA
})
const persisted = JSON.parse(
await readFile(filePath, 'utf8')
) as {
hosts: Array<{
id: string
credential?: unknown
}>
}
persisted.hosts[1]!.credential = persisted.hosts[0]!.credential
await writeFile(filePath, JSON.stringify(persisted), 'utf8')
const reloaded = new SshHostStore(filePath, createCipher())
const snapshot = await reloaded.getSnapshot()
expect(
snapshot.hosts.find((host) => host.id === first.id)
).toMatchObject({ credentialSource: 'encrypted' })
expect(
snapshot.hosts.find((host) => host.id === second.id)
).toMatchObject({
credentialConfigured: false,
credentialSource: 'unreadable'
})
await expect(reloaded.resolveHost(second.id)).rejects.toThrow(
'不匹配'
)
})
it('binds encrypted credentials to the target address and username', async () => {
const { filePath, store } = await createHarness()
const created = await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
const persisted = JSON.parse(
await readFile(filePath, 'utf8')
) as {
hosts: Array<{
hostname: string
}>
}
persisted.hosts[0]!.hostname = 'attacker.example.com'
await writeFile(filePath, JSON.stringify(persisted), 'utf8')
const reloaded = new SshHostStore(filePath, createCipher())
await expect(reloaded.getSnapshot()).resolves.toMatchObject({
hosts: [
expect.objectContaining({
id: created.id,
credentialConfigured: false,
credentialSource: 'unreadable'
})
]
})
await expect(reloaded.resolveHost(created.id)).rejects.toThrow(
'不匹配'
)
})
it('increments host-key generations only for new key material or targets', async () => {
const { store } = await createHarness()
const created = await store.commitValidated({
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
const same = await updateValidated(
store,
created,
{
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
keyA
)
const changed = await updateValidated(
store,
same,
{
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
keyB
)
const moved = await updateValidated(
store,
changed,
{
name: 'Build host',
hostname: 'moved.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
keyB
)
expect(created.hostKey.generation).toBe(1)
expect(same.hostKey.generation).toBe(1)
expect(changed.hostKey.generation).toBe(2)
expect(moved.hostKey.generation).toBe(3)
})
it('loads legacy revision-zero hosts and requires guided revalidation', async () => {
const { filePath } = await createHarness()
await writeFile(
filePath,
JSON.stringify({
version: 1,
hosts: [
{
id: '00000000-0000-4000-8000-000000000121',
name: 'Legacy host',
hostname: 'legacy.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
hostKey: {
...keyA,
acceptedAt: '2026-08-01T00:00:00.000Z',
generation: 1
},
hostKeyGeneration: 1,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
]
}),
'utf8'
)
const store = new SshHostStore(filePath, createCipher())
const snapshot = await store.getSnapshot()
expect(snapshot).toMatchObject({
hosts: [{ name: 'Legacy host', hostKey: { state: 'verified' } }]
})
expect(snapshot.hosts[0]).not.toHaveProperty('lastValidatedAt')
await expect(
store.getHostIdentity('00000000-0000-4000-8000-000000000121')
).resolves.toMatchObject({ revision: 0 })
})
it('requires secure storage for passwords and isolates corrupt settings', async () => {
const unavailable = await createHarness(createCipher(false))
await expect(
unavailable.store.commitValidated({
input: passwordHostInput({
password: { action: 'replace', value: 'secret' }
}),
hostKey: keyA
})
).rejects.toThrow('安全存储不可用')
const { filePath } = await createHarness()
await writeFile(filePath, '{invalid-json', 'utf8')
const recovered = new SshHostStore(filePath, createCipher())
await expect(recovered.getSnapshot()).resolves.toMatchObject({
hosts: []
})
expect(
(await readdir(join(filePath, '..'))).some((name) =>
name.startsWith('ssh-hosts.json.corrupt-')
)
).toBe(true)
})
it('asserts a loaded current target synchronously without resolving credentials', async () => {
const { store } = await createHarness()
expect(() =>
store.assertConnectionTargetCurrent({
hostId: '00000000-0000-4000-8000-000000000001',
hostRevision: 1,
hostKeyGeneration: 1,
username: 'builder'
})
).toThrow('尚未加载')
const created = await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
const target = await store.resolveConnectionTarget(created.id)
const current = {
hostId: created.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
username: target.host.username
}
expect(() =>
store.assertConnectionTargetCurrent(current)
).not.toThrow()
expect(() =>
store.assertConnectionTargetCurrent({
...current,
hostRevision: current.hostRevision + 1
})
).toThrow('配置已变化')
expect(() =>
store.assertConnectionTargetCurrent({
...current,
username: 'other-user'
})
).toThrow('配置已变化')
})
})
+669
View File
@@ -0,0 +1,669 @@
import { randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import {
SSH_HOST_LIMITS,
sshAuthenticationKindSchema,
sshHostCreateInputSchema,
sshHostUpdateInputSchema,
type SshHost,
type SshHostsSnapshot,
type SshHostUpdateInput
} from '../../shared/ssh-host-contracts'
import {
decryptSettingsCredential,
encryptedSettingsCredentialSchema,
encryptSettingsCredential,
type SettingsCredentialCipher
} from '../settings-credential-cipher'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from '../settings-file-utils'
const CURRENT_SETTINGS_VERSION = 1
const storedHostKeySchema = z
.object({
algorithm: z.string().min(1).max(64),
publicKeyBase64: z
.string()
.min(1)
.max(SSH_HOST_LIMITS.maximumHostKeyLength * 2)
.regex(/^[A-Za-z0-9+/]+={0,2}$/u),
fingerprintSha256: z
.string()
.regex(/^SHA256:[A-Za-z0-9+/]{43}$/u),
acceptedAt: z.string().datetime({ offset: true }),
generation: z.number().int().positive()
})
.strict()
const encryptedCredentialSchema = encryptedSettingsCredentialSchema
.extend({
ciphertextBase64: z
.string()
.min(1)
.max(SSH_HOST_LIMITS.maximumPasswordLength * 8)
.regex(/^[A-Za-z0-9+/]+={0,2}$/u)
})
.strict()
const storedHostSchema = z
.object({
id: z.string().uuid(),
name: sshHostCreateInputSchema.shape.name,
hostname: sshHostCreateInputSchema.shape.hostname,
port: sshHostCreateInputSchema.shape.port,
username: sshHostCreateInputSchema.shape.username,
authentication: sshAuthenticationKindSchema,
credential: encryptedCredentialSchema.optional(),
hostKey: storedHostKeySchema.optional(),
hostKeyGeneration: z.number().int().nonnegative(),
revision: z.number().int().nonnegative().default(0),
lastValidatedAt: z
.string()
.datetime({ offset: true })
.optional(),
createdAt: z.string().datetime({ offset: true }),
updatedAt: z.string().datetime({ offset: true })
})
.strict()
.superRefine((host, context) => {
if (
host.hostKey &&
host.hostKey.generation !== host.hostKeyGeneration
) {
context.addIssue({
code: 'custom',
path: ['hostKeyGeneration'],
message: 'SSH 主机密钥代际不一致'
})
}
})
const storedSettingsSchema = z
.object({
version: z.literal(CURRENT_SETTINGS_VERSION),
hosts: z.array(storedHostSchema).max(SSH_HOST_LIMITS.maximumHosts)
})
.strict()
const credentialPayloadSchema = z
.object({
version: z.literal(1),
hostId: z.string().uuid(),
authentication: z.literal('password'),
hostname: sshHostCreateInputSchema.shape.hostname,
port: sshHostCreateInputSchema.shape.port,
username: sshHostCreateInputSchema.shape.username,
password: z
.string()
.min(1)
.max(SSH_HOST_LIMITS.maximumPasswordLength)
})
.strict()
type StoredSettings = z.infer<typeof storedSettingsSchema>
type StoredHost = z.infer<typeof storedHostSchema>
type StoredHostKey = z.infer<typeof storedHostKeySchema>
type CredentialPayload = z.infer<typeof credentialPayloadSchema>
export type ResolvedSshHost = {
id: string
name: string
hostname: string
port: number
username: string
authentication: SshHost['authentication']
password?: string
hostKey?: Pick<
StoredHostKey,
'algorithm' | 'publicKeyBase64' | 'fingerprintSha256'
> &
Partial<Pick<StoredHostKey, 'acceptedAt' | 'generation'>>
}
export type SshHostIdentity = {
id: string
hostname: string
port: number
username: string
revision: number
hostKey?: {
algorithm: string
publicKeyBase64: string
fingerprintSha256: string
generation: number
}
}
export type SshConnectionTarget = {
host: ResolvedSshHost
hostRevision: number
hostKeyGeneration: number
}
export type CurrentSshConnectionTarget = Readonly<{
hostId: string
hostRevision: number
hostKeyGeneration: number
username: string
}>
export type ValidatedSshHostCommit = {
hostId?: string
expectedRevision?: number
input: SshHostUpdateInput
hostKey: {
algorithm: string
publicKeyBase64: string
fingerprintSha256: string
}
}
const defaultSettings: StoredSettings = {
version: CURRENT_SETTINGS_VERSION,
hosts: []
}
function cloneSettings(settings: StoredSettings): StoredSettings {
return structuredClone(settings)
}
function targetChanged(
current: StoredHost,
input: SshHostUpdateInput
): boolean {
return (
current.hostname !== input.hostname ||
current.port !== input.port ||
current.username !== input.username ||
current.authentication !== input.authentication
)
}
function credentialMatchesHost(
payload: CredentialPayload,
host: StoredHost
): boolean {
return (
payload.hostId === host.id &&
payload.authentication === host.authentication &&
payload.hostname === host.hostname &&
payload.port === host.port &&
payload.username === host.username
)
}
export class SshHostStore {
private settings?: StoredSettings
private settingsLoad?: Promise<StoredSettings>
private updateQueue: Promise<void> = Promise.resolve()
private readonly credentialSourceCache = new Map<
string,
{
credential: NonNullable<StoredHost['credential']>
secureStorageAvailable: boolean
hostname: string
port: number
username: string
source: Extract<
SshHost['credentialSource'],
'encrypted' | 'unreadable'
>
}
>()
constructor(
private readonly filePath: string,
private readonly cipher: SettingsCredentialCipher,
private readonly now: () => Date = () => new Date()
) {}
async getSnapshot(): Promise<SshHostsSnapshot> {
const settings = await this.load()
return {
hosts: settings.hosts.map((host) => this.toPublicHost(host)),
secureStorageAvailable: this.cipher.isAvailable()
}
}
isSecureStorageAvailable(): boolean {
return this.cipher.isAvailable()
}
commitValidated(
commit: ValidatedSshHostCommit
): Promise<SshHost> {
const parsed = commit.hostId
? sshHostUpdateInputSchema.parse(commit.input)
: sshHostCreateInputSchema.parse(commit.input)
return this.queue(async () => {
const settings = cloneSettings(await this.load())
const timestamp = this.now().toISOString()
if (!commit.hostId) {
if (settings.hosts.length >= SSH_HOST_LIMITS.maximumHosts) {
throw new Error(
`SSH 主机数量不能超过 ${SSH_HOST_LIMITS.maximumHosts}`
)
}
const id = randomUUID()
const host: StoredHost = storedHostSchema.parse({
id,
name: parsed.name,
hostname: parsed.hostname,
port: parsed.port,
username: parsed.username,
authentication: parsed.authentication,
...(parsed.authentication === 'password' &&
parsed.password.action === 'replace'
? {
credential: this.encryptPassword(
id,
{
hostname: parsed.hostname,
port: parsed.port,
username: parsed.username
},
parsed.password.value
)
}
: {}),
hostKey: {
...commit.hostKey,
acceptedAt: timestamp,
generation: 1
},
hostKeyGeneration: 1,
revision: 1,
lastValidatedAt: timestamp,
createdAt: timestamp,
updatedAt: timestamp
})
settings.hosts.push(host)
await this.persist(settings)
this.settings = settings
return this.toPublicHost(host)
}
const index = settings.hosts.findIndex(
(host) => host.id === commit.hostId
)
const current = settings.hosts[index]
if (!current) {
throw new Error('SSH 主机不存在')
}
if (
commit.expectedRevision === undefined ||
current.revision !== commit.expectedRevision
) {
throw new Error('SSH 主机配置已变化,请重新验证')
}
const changedTarget = targetChanged(current, parsed)
if (
current.credential &&
changedTarget &&
parsed.password.action === 'keep'
) {
throw new Error(
'主机地址、端口、用户名或认证方式已变化,请重新输入密码'
)
}
let credential =
parsed.password.action === 'keep'
? current.credential
: undefined
if (
parsed.authentication === 'password' &&
parsed.password.action === 'replace'
) {
credential = this.encryptPassword(
current.id,
{
hostname: parsed.hostname,
port: parsed.port,
username: parsed.username
},
parsed.password.value
)
}
if (parsed.authentication === 'password' && !credential) {
throw new Error('SSH 密码不能为空')
}
const generation =
!changedTarget &&
current.hostKey?.publicKeyBase64 ===
commit.hostKey.publicKeyBase64
? current.hostKey.generation
: current.hostKeyGeneration + 1
const next: StoredHost = storedHostSchema.parse({
...current,
name: parsed.name,
hostname: parsed.hostname,
port: parsed.port,
username: parsed.username,
authentication: parsed.authentication,
...(credential ? { credential } : { credential: undefined }),
hostKey: {
...commit.hostKey,
acceptedAt: timestamp,
generation
},
hostKeyGeneration: generation,
revision: current.revision + 1,
lastValidatedAt: timestamp,
updatedAt: timestamp
})
settings.hosts[index] = next
await this.persist(settings)
this.settings = settings
return this.toPublicHost(next)
})
}
remove(hostId: string): Promise<void> {
return this.queue(async () => {
const settings = cloneSettings(await this.load())
const index = settings.hosts.findIndex(
(host) => host.id === hostId
)
if (index === -1) {
throw new Error('SSH 主机不存在')
}
settings.hosts.splice(index, 1)
await this.persist(settings)
this.settings = settings
this.credentialSourceCache.delete(hostId)
})
}
async resolveHost(hostId: string): Promise<ResolvedSshHost> {
const host = await this.getStoredHost(hostId)
return this.resolveStoredHost(host)
}
resolveConnectionTarget(
hostId: string
): Promise<SshConnectionTarget> {
return this.queue(async () => {
const host = (await this.load()).hosts.find(
(candidate) => candidate.id === hostId
)
if (!host) {
throw new Error('SSH 主机不存在')
}
const snapshot = structuredClone(host)
return {
host: this.resolveStoredHost(snapshot),
hostRevision: snapshot.revision,
hostKeyGeneration: snapshot.hostKeyGeneration
}
})
}
/**
* Synchronous transaction precondition for callers that already resolved a
* target. It deliberately consults only loaded, credential-free metadata so
* it is safe to call from inside a SQLite transaction.
*/
assertConnectionTargetCurrent(
expected: CurrentSshConnectionTarget
): void {
const settings = this.settings
if (!settings) {
throw new Error('SSH 主机当前状态尚未加载')
}
const host = settings.hosts.find(
(candidate) => candidate.id === expected.hostId
)
if (
!host ||
host.revision !== expected.hostRevision ||
host.hostKeyGeneration !== expected.hostKeyGeneration ||
host.username !== expected.username ||
!host.hostKey ||
host.hostKey.generation !== expected.hostKeyGeneration
) {
throw new Error('SSH 主机配置已变化,请重新验证')
}
}
private resolveStoredHost(host: StoredHost): ResolvedSshHost {
if (host.authentication === 'system-agent') {
return {
id: host.id,
name: host.name,
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: host.authentication,
hostKey: host.hostKey
}
}
if (!host.credential) {
throw new Error('SSH 主机尚未配置密码')
}
const payload = credentialPayloadSchema.parse(
decryptSettingsCredential(this.cipher, host.credential)
)
if (!credentialMatchesHost(payload, host)) {
throw new Error('SSH 主机凭据与当前主机不匹配')
}
return {
id: host.id,
name: host.name,
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: host.authentication,
password: payload.password,
hostKey: host.hostKey
}
}
private async getStoredHost(hostId: string): Promise<StoredHost> {
const host = (await this.load()).hosts.find(
(candidate) => candidate.id === hostId
)
if (!host) {
throw new Error('SSH 主机不存在')
}
return structuredClone(host)
}
async getHostIdentity(hostId: string): Promise<SshHostIdentity> {
const host = await this.getStoredHost(hostId)
return {
id: host.id,
hostname: host.hostname,
port: host.port,
username: host.username,
revision: host.revision,
...(host.hostKey
? {
hostKey: {
algorithm: host.hostKey.algorithm,
publicKeyBase64: host.hostKey.publicKeyBase64,
fingerprintSha256:
host.hostKey.fingerprintSha256,
generation: host.hostKeyGeneration
}
}
: {})
}
}
private encryptPassword(
hostId: string,
target: {
hostname: string
port: number
username: string
},
password: string
): StoredHost['credential'] {
if (!password) {
throw new Error('SSH 密码不能为空')
}
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法保存 SSH 密码')
}
return encryptSettingsCredential(this.cipher, {
version: 1,
hostId,
authentication: 'password',
...target,
password
})
}
private toPublicHost(host: StoredHost): SshHost {
let credentialSource: SshHost['credentialSource']
if (host.authentication === 'system-agent') {
this.credentialSourceCache.delete(host.id)
credentialSource = 'system-agent'
} else if (!host.credential) {
this.credentialSourceCache.delete(host.id)
credentialSource = 'none'
} else {
const secureStorageAvailable = this.cipher.isAvailable()
const cached = this.credentialSourceCache.get(host.id)
if (
cached?.credential === host.credential &&
cached.secureStorageAvailable === secureStorageAvailable &&
cached.hostname === host.hostname &&
cached.port === host.port &&
cached.username === host.username
) {
credentialSource = cached.source
} else {
try {
const payload = credentialPayloadSchema.parse(
decryptSettingsCredential(this.cipher, host.credential)
)
credentialSource =
credentialMatchesHost(payload, host)
? 'encrypted'
: 'unreadable'
} catch {
credentialSource = 'unreadable'
}
this.credentialSourceCache.set(host.id, {
credential: host.credential,
secureStorageAvailable,
hostname: host.hostname,
port: host.port,
username: host.username,
source: credentialSource
})
}
}
return {
id: host.id,
name: host.name,
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: host.authentication,
credentialConfigured:
credentialSource === 'encrypted' ||
credentialSource === 'system-agent',
credentialSource,
hostKey: host.hostKey
? {
state: 'verified',
algorithm: host.hostKey.algorithm,
fingerprintSha256:
host.hostKey.fingerprintSha256,
generation: host.hostKeyGeneration
}
: {
state: 'unverified',
generation: host.hostKeyGeneration
},
...(host.lastValidatedAt
? { lastValidatedAt: host.lastValidatedAt }
: {}),
createdAt: host.createdAt,
updatedAt: host.updatedAt
}
}
private queue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.updateQueue.then(operation, operation)
this.updateQueue = result.then(
() => undefined,
() => undefined
)
return result
}
private async load(): Promise<StoredSettings> {
if (this.settings) {
return this.settings
}
if (!this.settingsLoad) {
this.settingsLoad = this.readStored().finally(() => {
this.settingsLoad = undefined
})
}
return this.settingsLoad
}
private async readStored(): Promise<StoredSettings> {
this.credentialSourceCache.clear()
try {
const contents = await readFile(this.filePath, 'utf8')
let parsed: unknown
try {
parsed = JSON.parse(contents) as unknown
} catch {
await this.isolateCorruptFile()
this.settings = cloneSettings(defaultSettings)
return this.settings
}
assertSupportedSettingsVersion(
parsed,
CURRENT_SETTINGS_VERSION,
(version) =>
`当前 GoodBuddy 不支持 SSH 主机设置版本 ${version},请升级应用后重试`
)
const result = storedSettingsSchema.safeParse(parsed)
if (!result.success) {
await this.isolateCorruptFile()
this.settings = cloneSettings(defaultSettings)
return this.settings
}
this.settings = result.data
return this.settings
} catch (error) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (!isMissingFileError(error)) {
throw new Error('SSH 主机设置读取失败', { cause: error })
}
this.settings = cloneSettings(defaultSettings)
return this.settings
}
}
private async isolateCorruptFile(): Promise<void> {
await isolateCorruptSettingsFile(
this.filePath,
'SSH 主机设置损坏且无法隔离'
)
}
private async persist(settings: StoredSettings): Promise<void> {
await writeJsonFileAtomically(
this.filePath,
storedSettingsSchema.parse(settings)
)
}
}
+432
View File
@@ -0,0 +1,432 @@
import { generateKeyPairSync } from 'node:crypto'
import { EventEmitter } from 'node:events'
import type { AddressInfo } from 'node:net'
import {
Server,
type AuthContext,
type Connection
} from 'ssh2'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedSshHost } from './ssh-host-store'
import {
buildAuthenticatedSshConnectConfig,
createStrongSshAlgorithms,
describeSshHostKey,
selectStrongSshCiphers,
Ssh2Transport
} from './ssh-transport'
type ServerHarness = {
server: Server
port: number
authenticationMethods: string[]
passwords: string[]
commands: string[]
}
const servers: Server[] = []
const { privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2_048,
privateKeyEncoding: {
type: 'pkcs1',
format: 'pem'
},
publicKeyEncoding: {
type: 'pkcs1',
format: 'pem'
}
})
function createHostKeyBlob(algorithm: string): Buffer {
const name = Buffer.from(algorithm)
const length = Buffer.alloc(4)
length.writeUInt32BE(name.length)
return Buffer.concat([length, name, Buffer.alloc(32, 7)])
}
function createResolvedHost(
publicKey: Buffer,
algorithm = 'metadata-is-not-trusted'
): ResolvedSshHost {
return {
id: '00000000-0000-4000-8000-000000000101',
name: 'Pinned test host',
hostname: '127.0.0.1',
port: 22,
username: 'builder',
authentication: 'password',
password: 'private password',
hostKey: {
algorithm,
publicKeyBase64: publicKey.toString('base64'),
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
acceptedAt: '2026-08-01T00:00:00.000Z',
generation: 1
}
}
}
const testAlgorithms = createStrongSshAlgorithms([
'aes-256-gcm'
])
async function createServer(): Promise<ServerHarness> {
const authenticationMethods: string[] = []
const passwords: string[] = []
const commands: string[] = []
const server = new Server(
{ hostKeys: [privateKey] },
(client: Connection) => {
client.on('error', () => undefined)
client.on('authentication', (context: AuthContext) => {
authenticationMethods.push(context.method)
if (context.method === 'password') {
passwords.push(context.password)
if (
context.username === 'builder' &&
context.password === 'private password'
) {
context.accept()
return
}
}
context.reject(['password'])
})
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept()
session.on('exec', (acceptExec, _reject, info) => {
commands.push(info.command)
const stream = acceptExec()
stream.write(
[
'GOODBUDDY_SSH_PROBE_V1',
'Linux',
'x86_64',
'/bin/bash',
'/home/builder',
''
].join('\n')
)
stream.exit(0)
stream.end()
})
})
})
}
)
servers.push(server)
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => resolve())
})
return {
server,
port: (server.address() as AddressInfo).port,
authenticationMethods,
passwords,
commands
}
}
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve())
})
)
)
})
describe('SSH transport', () => {
it('removes ChaCha20 when the current OpenSSL build cannot provide it', () => {
expect(
selectStrongSshCiphers([
'aes-256-gcm',
'aes-128-gcm',
'aes-256-ctr',
'aes-192-ctr',
'aes-128-ctr'
])
).toEqual([
'aes256-gcm@openssh.com',
'aes128-gcm@openssh.com',
'aes256-ctr',
'aes192-ctr',
'aes128-ctr'
])
})
it('fails closed when no approved cipher is available', () => {
expect(() => selectStrongSshCiphers(['des-cbc'])).toThrow(
'不支持 GoodBuddy 要求的安全 SSH 加密算法'
)
})
it('describes complete SSH key blobs with SHA-256 fingerprints', () => {
const blob = createHostKeyBlob('ssh-ed25519')
expect(describeSshHostKey(blob)).toMatchObject({
algorithm: 'ssh-ed25519',
publicKeyBase64: blob.toString('base64'),
fingerprintSha256: expect.stringMatching(
/^SHA256:[A-Za-z0-9+/]{43}$/u
)
})
expect(() => describeSshHostKey(Buffer.alloc(0))).toThrow(
'大小无效'
)
})
it('negotiates only the algorithm represented by a pinned ECDSA key blob', () => {
const config = buildAuthenticatedSshConnectConfig(
createResolvedHost(
createHostKeyBlob('ecdsa-sha2-nistp384'),
'ssh-ed25519'
),
testAlgorithms,
undefined,
vi.fn()
)
expect(config.algorithms?.serverHostKey).toEqual([
'ecdsa-sha2-nistp384'
])
expect(testAlgorithms.serverHostKey).toContain('ssh-ed25519')
})
it('maps a pinned RSA key blob to RSA SHA-2 negotiation only', () => {
const config = buildAuthenticatedSshConnectConfig(
createResolvedHost(createHostKeyBlob('ssh-rsa')),
testAlgorithms,
undefined,
vi.fn()
)
expect(config.algorithms?.serverHostKey).toEqual([
'rsa-sha2-512',
'rsa-sha2-256'
])
expect(config.algorithms?.serverHostKey).not.toContain('ssh-rsa')
})
it('fails closed for malformed or unsupported pinned key algorithms', () => {
const malformed = Buffer.alloc(4)
malformed.writeUInt32BE(12)
expect(() =>
buildAuthenticatedSshConnectConfig(
createResolvedHost(malformed),
testAlgorithms,
undefined,
vi.fn()
)
).toThrow('主机密钥格式无效')
expect(() =>
buildAuthenticatedSshConnectConfig(
createResolvedHost(createHostKeyBlob('ssh-dss')),
testAlgorithms,
undefined,
vi.fn()
)
).toThrow('主机密钥算法不受支持')
})
it('keeps broad strong Host Key negotiation while inspecting draft hosts', async () => {
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn()
})
const transport = new Ssh2Transport({
createClient: () => client as never,
supportedOpenSslCiphers: () => ['aes-256-gcm']
})
const inspection = transport.inspectHostKey({
hostname: '127.0.0.1',
port: 22,
username: 'builder'
})
expect(client.connect).toHaveBeenCalledWith(
expect.objectContaining({
algorithms: expect.objectContaining({
serverHostKey: [
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'rsa-sha2-512',
'rsa-sha2-256'
]
})
})
)
client.emit('error', new Error('stop inspection'))
await expect(inspection).rejects.toThrow('无法连接 SSH 主机')
})
it('captures the host key and terminates before sending any authentication method', async () => {
const harness = await createServer()
const transport = new Ssh2Transport()
const candidate = await transport.inspectHostKey({
hostname: '127.0.0.1',
port: harness.port,
username: 'builder'
})
expect(candidate).toMatchObject({
algorithm: 'ssh-rsa',
fingerprintSha256: expect.stringMatching(
/^SHA256:[A-Za-z0-9+/]{43}$/u
)
})
await new Promise((resolve) => setTimeout(resolve, 25))
expect(harness.authenticationMethods).toEqual([])
expect(harness.passwords).toEqual([])
})
it('pins the full key blob, authenticates, and executes only the bounded probe', async () => {
const harness = await createServer()
let now = 1_000
const transport = new Ssh2Transport({
now: () => {
now += 6
return now
}
})
const candidate = await transport.inspectHostKey({
hostname: '127.0.0.1',
port: harness.port,
username: 'builder'
})
const host: ResolvedSshHost = {
id: '00000000-0000-4000-8000-000000000102',
name: 'Local test host',
hostname: '127.0.0.1',
port: harness.port,
username: 'builder',
authentication: 'password',
password: 'private password',
hostKey: {
...candidate,
acceptedAt: '2026-08-01T00:00:00.000Z',
generation: 1
}
}
await expect(transport.testConnection(host)).resolves.toEqual({
connected: true,
latencyMs: 6,
platform: 'linux',
architecture: 'x64',
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
})
expect(harness.passwords).toEqual(['private password'])
expect(harness.commands).toHaveLength(1)
expect(harness.commands[0]).toContain(
"printf 'GOODBUDDY_SSH_PROBE_V1\\n'"
)
expect(harness.commands[0]).not.toContain('private password')
})
it('rejects a changed host key before authentication', async () => {
const harness = await createServer()
const transport = new Ssh2Transport()
const candidate = await transport.inspectHostKey({
hostname: '127.0.0.1',
port: harness.port,
username: 'builder'
})
const wrongKey = Buffer.from(
candidate.publicKeyBase64,
'base64'
)
const finalByte = wrongKey.readUInt8(wrongKey.length - 1)
wrongKey.writeUInt8(finalByte ^ 0xff, wrongKey.length - 1)
await expect(
transport.testConnection({
id: '00000000-0000-4000-8000-000000000103',
name: 'Local test host',
hostname: '127.0.0.1',
port: harness.port,
username: 'builder',
authentication: 'password',
password: 'private password',
hostKey: {
...candidate,
publicKeyBase64: wrongKey.toString('base64'),
acceptedAt: '2026-08-01T00:00:00.000Z',
generation: 1
}
})
).rejects.toThrow('主机密钥已变化')
await new Promise((resolve) => setTimeout(resolve, 25))
expect(harness.passwords).toEqual([])
})
it('starts a separate bounded probe deadline after authentication', async () => {
vi.useFakeTimers()
try {
const stream = Object.assign(new EventEmitter(), {
destroy: vi.fn()
})
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(this: EventEmitter) {
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
callback: (
error: Error | undefined,
output: typeof stream
) => void
) => callback(undefined, stream)
)
})
const transport = new Ssh2Transport({
createClient: () => client as never,
now: () => 0
})
const connection = transport.testConnection({
id: '00000000-0000-4000-8000-000000000106',
name: 'Timeout test host',
hostname: '127.0.0.1',
port: 22,
username: 'builder',
authentication: 'password',
password: 'private password',
hostKey: {
algorithm: 'ssh-ed25519',
publicKeyBase64:
createHostKeyBlob('ssh-ed25519').toString('base64'),
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
acceptedAt: '2026-08-01T00:00:00.000Z',
generation: 1
}
})
const rejected = vi.fn()
void connection.catch(rejected)
await vi.advanceTimersByTimeAsync(9_999)
expect(rejected).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(rejected).toHaveBeenCalledOnce()
await expect(connection).rejects.toThrow('远端系统探测超时')
expect(client.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
})
+551
View File
@@ -0,0 +1,551 @@
import { createHash, getCiphers } from 'node:crypto'
import {
Client,
type Algorithms,
type CipherAlgorithm,
type ConnectConfig,
type ServerHostKeyAlgorithm
} from 'ssh2'
import type {
SshHostConnectionTestResult
} from '../../shared/ssh-host-contracts'
import type { ResolvedSshHost } from './ssh-host-store'
export const SSH_CONNECTION_TIMEOUT_MS = 10_000
export const SSH_KEEPALIVE_INTERVAL_MS = 5_000
export const SSH_KEEPALIVE_COUNT_MAX = 2
const PROBE_TIMEOUT_MS = 10_000
const MAX_PROBE_OUTPUT_BYTES = 16 * 1024
const MAX_HOST_KEY_BYTES = 32 * 1024
const PROBE_MARKER = 'GOODBUDDY_SSH_PROBE_V1'
const PROBE_COMMAND = [
`printf '${PROBE_MARKER}\\n'`,
"uname -s 2>/dev/null || printf 'unknown\\n'",
"uname -m 2>/dev/null || printf 'unknown\\n'",
"printf '%s\\n' \"${SHELL:-unknown}\"",
"printf '%s\\n' \"${HOME:-unknown}\""
].join('; ')
const strongAlgorithms: Omit<Algorithms, 'cipher'> = {
kex: [
'curve25519-sha256',
'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group14-sha256',
'diffie-hellman-group15-sha512',
'diffie-hellman-group16-sha512',
'diffie-hellman-group17-sha512',
'diffie-hellman-group18-sha512'
],
serverHostKey: [
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'rsa-sha2-512',
'rsa-sha2-256'
],
hmac: [
'hmac-sha2-512-etm@openssh.com',
'hmac-sha2-256-etm@openssh.com',
'hmac-sha2-512',
'hmac-sha2-256'
]
}
const strongCipherCandidates = [
{
sshName: 'chacha20-poly1305@openssh.com',
openSslName: 'chacha20'
},
{
sshName: 'aes256-gcm@openssh.com',
openSslName: 'aes-256-gcm'
},
{
sshName: 'aes128-gcm@openssh.com',
openSslName: 'aes-128-gcm'
},
{ sshName: 'aes256-ctr', openSslName: 'aes-256-ctr' },
{ sshName: 'aes192-ctr', openSslName: 'aes-192-ctr' },
{ sshName: 'aes128-ctr', openSslName: 'aes-128-ctr' }
] as const
export function selectStrongSshCiphers(
supportedOpenSslCiphers: readonly string[]
): CipherAlgorithm[] {
const supported = new Set(supportedOpenSslCiphers)
const selected = strongCipherCandidates
.filter((candidate) => supported.has(candidate.openSslName))
.map((candidate) => candidate.sshName)
if (selected.length === 0) {
throw new Error('当前运行环境不支持 GoodBuddy 要求的安全 SSH 加密算法')
}
return selected
}
export function createStrongSshAlgorithms(
supportedOpenSslCiphers: readonly string[]
): Algorithms {
return {
...strongAlgorithms,
cipher: selectStrongSshCiphers(supportedOpenSslCiphers)
}
}
export type SshConnectionTarget = {
hostname: string
port: number
username: string
}
export type SshHostKeyCandidate = {
algorithm: string
publicKeyBase64: string
fingerprintSha256: string
}
export interface SshTransport {
inspectHostKey(
target: SshConnectionTarget
): Promise<SshHostKeyCandidate>
testConnection(
host: ResolvedSshHost
): Promise<Omit<SshHostConnectionTestResult, 'hostId'>>
}
type ClientLike = Pick<Client, 'connect' | 'end' | 'destroy' | 'exec'> & {
once: Client['once']
on: Client['on']
}
type SshTransportDependencies = {
createClient: () => ClientLike
now: () => number
supportedOpenSslCiphers: () => readonly string[]
systemAgent: () => string | undefined
}
function readSshString(
value: Buffer,
offset: number
): { value: string; offset: number } {
if (offset + 4 > value.length) {
throw new Error('SSH 主机密钥格式无效')
}
const length = value.readUInt32BE(offset)
const start = offset + 4
const end = start + length
if (length <= 0 || end > value.length) {
throw new Error('SSH 主机密钥格式无效')
}
return {
value: value.subarray(start, end).toString('ascii'),
offset: end
}
}
export function describeSshHostKey(
publicKey: Buffer
): SshHostKeyCandidate {
if (
publicKey.byteLength === 0 ||
publicKey.byteLength > MAX_HOST_KEY_BYTES
) {
throw new Error('SSH 主机密钥大小无效')
}
const algorithm = readSshString(publicKey, 0).value
if (
![
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'ssh-rsa'
].includes(algorithm)
) {
throw new Error('SSH 主机密钥算法不受支持')
}
return {
algorithm,
publicKeyBase64: publicKey.toString('base64'),
fingerprintSha256: `SHA256:${createHash('sha256')
.update(publicKey)
.digest('base64')
.replace(/=+$/u, '')}`
}
}
export function defaultSystemAgent(): string | undefined {
const configured = process.env.SSH_AUTH_SOCK?.trim()
if (configured) {
return configured
}
return process.platform === 'win32'
? '\\\\.\\pipe\\openssh-ssh-agent'
: undefined
}
export function safeSshError(error: unknown): Error {
if (
error !== null &&
typeof error === 'object' &&
'level' in error &&
typeof error.level === 'string'
) {
if (error.level.includes('client-authentication')) {
return new Error('SSH 认证失败,请检查用户名和认证凭据')
}
if (error.level.includes('client-timeout')) {
return new Error('SSH 连接超时')
}
}
const code =
error !== null &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string'
? error.code
: ''
if (code === 'ENOTFOUND') {
return new Error('无法解析 SSH 主机地址')
}
if (code === 'ECONNREFUSED') {
return new Error('SSH 主机拒绝连接')
}
if (code === 'ETIMEDOUT') {
return new Error('SSH 连接超时')
}
return new Error('无法连接 SSH 主机')
}
function selectPinnedServerHostKeyAlgorithms(
algorithm: string
): ServerHostKeyAlgorithm[] {
switch (algorithm) {
case 'ssh-ed25519':
case 'ecdsa-sha2-nistp256':
case 'ecdsa-sha2-nistp384':
case 'ecdsa-sha2-nistp521':
return [algorithm]
case 'ssh-rsa':
return ['rsa-sha2-512', 'rsa-sha2-256']
default:
throw new Error('SSH 主机密钥算法不受支持')
}
}
export function buildAuthenticatedSshConnectConfig(
host: ResolvedSshHost,
algorithms: Algorithms,
systemAgent: string | undefined,
onHostKeyMismatch: () => void
): ConnectConfig {
if (!host.hostKey) {
throw new Error('请先验证并接受 SSH 主机密钥')
}
const publicKey = Buffer.from(
host.hostKey.publicKeyBase64,
'base64'
)
if (publicKey.byteLength === 0) {
throw new Error('SSH 主机密钥无效')
}
const pinnedAlgorithm = describeSshHostKey(publicKey).algorithm
const pinnedServerHostKeyAlgorithms =
selectPinnedServerHostKeyAlgorithms(pinnedAlgorithm)
const config: ConnectConfig = {
host: host.hostname,
port: host.port,
username: host.username,
readyTimeout: SSH_CONNECTION_TIMEOUT_MS,
keepaliveInterval: SSH_KEEPALIVE_INTERVAL_MS,
keepaliveCountMax: SSH_KEEPALIVE_COUNT_MAX,
algorithms: {
...algorithms,
serverHostKey: [...pinnedServerHostKeyAlgorithms]
},
agentForward: false,
hostVerifier: (key: Buffer) => {
const matches = key.equals(publicKey)
if (!matches) {
onHostKeyMismatch()
}
return matches
}
}
if (host.authentication === 'password') {
if (!host.password) {
throw new Error('SSH 主机尚未配置密码')
}
config.password = host.password
} else {
if (!systemAgent) {
throw new Error('当前系统未检测到可用的 SSH Agent')
}
config.agent = systemAgent
}
return config
}
function parseProbeOutput(
stdout: Buffer,
latencyMs: number
): Omit<SshHostConnectionTestResult, 'hostId'> {
const lines = stdout
.toString('utf8')
.replaceAll('\r\n', '\n')
.trimEnd()
.split('\n')
if (lines[0] !== PROBE_MARKER || lines.length < 5) {
throw new Error('SSH 远端系统探测返回了无效结果')
}
const platform =
lines[1] === 'Linux'
? 'linux'
: lines[1] === 'Darwin'
? 'darwin'
: /^MINGW|^MSYS|^CYGWIN|^Windows_NT$/u.test(lines[1] ?? '')
? 'win32'
: 'unknown'
const architecture =
/^(x86_64|amd64)$/iu.test(lines[2] ?? '')
? 'x64'
: /^(aarch64|arm64)$/iu.test(lines[2] ?? '')
? 'arm64'
: 'unknown'
return {
connected: true,
latencyMs,
platform,
architecture,
shell: (lines[3] ?? 'unknown').slice(0, 256),
homeDirectory: (lines[4] ?? 'unknown').slice(0, 4_096),
detail: `SSH 已连接,远端系统为 ${platform}/${architecture}`
}
}
export class Ssh2Transport implements SshTransport {
private readonly dependencies: SshTransportDependencies
private readonly algorithms: Algorithms
constructor(
dependencies: Partial<SshTransportDependencies> = {}
) {
this.dependencies = {
createClient: () => new Client(),
now: Date.now,
supportedOpenSslCiphers: getCiphers,
systemAgent: defaultSystemAgent,
...dependencies
}
this.algorithms = createStrongSshAlgorithms(
this.dependencies.supportedOpenSslCiphers()
)
}
inspectHostKey(
target: SshConnectionTarget
): Promise<SshHostKeyCandidate> {
return new Promise((resolve, reject) => {
const client = this.dependencies.createClient()
let settled = false
const settle = (
outcome:
| { ok: true; value: SshHostKeyCandidate }
| { ok: false; error: Error }
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
client.destroy()
if (outcome.ok) {
resolve(outcome.value)
} else {
reject(outcome.error)
}
}
const timeout = setTimeout(
() =>
settle({
ok: false,
error: new Error('SSH 主机身份检查超时')
}),
SSH_CONNECTION_TIMEOUT_MS
)
client.once('error', (error) => {
settle({ ok: false, error: safeSshError(error) })
})
client.connect({
host: target.hostname,
port: target.port,
username: target.username,
readyTimeout: SSH_CONNECTION_TIMEOUT_MS,
algorithms: this.algorithms,
agentForward: false,
hostVerifier: (key: Buffer) => {
try {
settle({
ok: true,
value: describeSshHostKey(key)
})
} catch (error) {
settle({
ok: false,
error:
error instanceof Error
? error
: new Error('SSH 主机密钥无效')
})
}
return false
}
})
})
}
testConnection(
host: ResolvedSshHost
): Promise<Omit<SshHostConnectionTestResult, 'hostId'>> {
if (!host.hostKey) {
return Promise.reject(
new Error('请先验证并接受 SSH 主机密钥')
)
}
return new Promise((resolve, reject) => {
const client = this.dependencies.createClient()
const startedAt = this.dependencies.now()
let settled = false
let hostKeyMismatch = false
const settle = (
outcome:
| {
ok: true
value: Omit<SshHostConnectionTestResult, 'hostId'>
}
| { ok: false; error: Error }
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
client.end()
if (outcome.ok) {
resolve(outcome.value)
} else {
reject(outcome.error)
}
}
let timeout = setTimeout(
() =>
settle({
ok: false,
error: new Error('SSH 连接超时')
}),
SSH_CONNECTION_TIMEOUT_MS
)
client.once('error', (error) => {
settle({
ok: false,
error: hostKeyMismatch
? new Error('SSH 主机密钥已变化,请重新验证主机身份')
: safeSshError(error)
})
})
client.once('ready', () => {
clearTimeout(timeout)
timeout = setTimeout(
() =>
settle({
ok: false,
error: new Error('SSH 远端系统探测超时')
}),
PROBE_TIMEOUT_MS
)
client.exec(PROBE_COMMAND, (error, stream) => {
if (error) {
settle({
ok: false,
error: new Error('无法启动 SSH 远端系统探测')
})
return
}
const chunks: Buffer[] = []
let outputBytes = 0
stream.on('data', (chunk: Buffer | string) => {
const buffer = Buffer.from(chunk)
outputBytes += buffer.byteLength
if (outputBytes > MAX_PROBE_OUTPUT_BYTES) {
stream.destroy()
settle({
ok: false,
error: new Error('SSH 远端系统探测输出超过安全限制')
})
return
}
chunks.push(buffer)
})
stream.once('error', () => {
settle({
ok: false,
error: new Error('SSH 远端系统探测失败')
})
})
stream.once('close', (code: number | null) => {
if (code !== 0) {
settle({
ok: false,
error: new Error('SSH 远端系统探测失败')
})
return
}
try {
settle({
ok: true,
value: parseProbeOutput(
Buffer.concat(chunks),
Math.max(0, this.dependencies.now() - startedAt)
)
})
} catch (error) {
settle({
ok: false,
error:
error instanceof Error
? error
: new Error('SSH 远端系统探测失败')
})
}
})
})
})
let config: ConnectConfig
try {
config = buildAuthenticatedSshConnectConfig(
host,
this.algorithms,
this.dependencies.systemAgent(),
() => {
hostKeyMismatch = true
}
)
} catch (error) {
settle({
ok: false,
error:
error instanceof Error
? error
: new Error('SSH 认证配置无效')
})
return
}
client.connect(config)
})
}
}
+31
View File
@@ -0,0 +1,31 @@
export { LocalWorkspaceAccess } from './local-workspace-access'
export { RemoteWorkspaceAccess } from './remote-workspace-access'
export {
UnsupportedRemoteWorkspaceAccess
} from './unsupported-remote-workspace-access'
export type {
RemoteWorkspaceAccessFactory,
RemoteWorkspaceProjectBinding,
RemoteWorkspaceTransport,
RemoteWorkspaceTransportBinding,
RemoteWorkspaceTransportLease
} from './remote-workspace-access'
export type {
DirectoryPage,
FilePreview,
SearchPage,
WorkspaceAccess,
WorkspaceChangedFile,
WorkspaceChanges,
WorkspaceChangesInput,
WorkspaceDirectoryEntry,
WorkspaceDirectoryInput,
WorkspaceEntry,
WorkspaceIdentity,
WorkspacePathInput,
WorkspaceReadInput,
WorkspaceSearchInput,
WorkspaceSearchMatch,
WorkspaceWriteInput,
WriteResult
} from './workspace-access'
@@ -0,0 +1,817 @@
import spawn from 'cross-spawn'
import { randomUUID } from 'node:crypto'
import { constants as fileSystemConstants } from 'node:fs'
import {
access,
lstat,
open,
realpath,
rename,
rm,
stat
} from 'node:fs/promises'
import {
basename,
delimiter,
dirname,
isAbsolute,
join,
resolve
} from 'node:path'
import {
getCanonicalWorkspace,
isPathInside,
listBoundedDirectoryEntries,
readBoundedUtf8File,
resolveExistingWorkspacePath
} from '../workspace-file-access'
import type {
DirectoryPage,
FilePreview,
SearchPage,
WorkspaceAccess,
WorkspaceChangedFile,
WorkspaceChanges,
WorkspaceChangesInput,
WorkspaceDirectoryInput,
WorkspaceEntry,
WorkspaceIdentity,
WorkspacePathInput,
WorkspaceReadInput,
WorkspaceSearchInput,
WorkspaceWriteInput,
WriteResult
} from './workspace-access'
const MAX_GIT_OUTPUT_BYTES = 512 * 1024
const GIT_COMMAND_TIMEOUT_MS = 10_000
const MAX_GIT_FILTER_DRIVERS = 256
const MAX_DIRECTORY_ENTRIES = 500
const MAX_CHANGED_FILES = 2_000
const DEFAULT_READ_BYTES = 256 * 1024
const DEFAULT_WRITE_BYTES = 512 * 1024
const DEFAULT_SEARCH_RESULTS = 100
const MAX_SEARCH_RESULTS = 1_000
const DEFAULT_SEARCH_SCAN_BYTES = 2 * 1024 * 1024
const MAX_SEARCH_SCAN_BYTES = 8 * 1024 * 1024
const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'
const gitEnvironmentAllowlist = [
'PATHEXT',
'SystemRoot',
'WINDIR',
'COMSPEC',
'TEMP',
'TMP',
'TMPDIR',
'LANG',
'LC_ALL',
'LC_CTYPE'
] as const
const safeGitArguments = [
'--no-pager',
'--no-optional-locks',
'-c',
`core.hooksPath=${NULL_DEVICE}`,
'-c',
'core.fsmonitor=false',
'-c',
'core.askPass=',
'-c',
'credential.helper=',
'-c',
'diff.external=',
'-c',
'interactive.diffFilter='
] as const
let gitExecutable: Promise<string> | undefined
type CommandResult = {
code: number | null
stdout: string
stderr: string
truncated: boolean
}
type GitConfigOverride = readonly [key: string, value: string]
/**
* Git runs inside user-controlled repositories, so build a fresh environment
* rather than filtering Electron Main's environment in place.
*/
export function buildWorkspaceGitEnvironment(
source: NodeJS.ProcessEnv = process.env,
configOverrides: readonly GitConfigOverride[] = []
): NodeJS.ProcessEnv {
const environment: NodeJS.ProcessEnv = {
GIT_ASKPASS: '',
GIT_CONFIG_GLOBAL: NULL_DEVICE,
GIT_CONFIG_NOSYSTEM: '1',
GIT_OPTIONAL_LOCKS: '0',
GIT_PAGER: '',
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'Never',
SSH_ASKPASS: ''
}
const path = source.PATH ?? source.Path ?? source.path
if (path !== undefined) {
environment.PATH = path
}
for (const name of gitEnvironmentAllowlist) {
if (source[name] !== undefined) {
environment[name] = source[name]
}
}
if (configOverrides.length > 0) {
environment.GIT_CONFIG_COUNT = String(configOverrides.length)
configOverrides.forEach(([key, value], index) => {
environment[`GIT_CONFIG_KEY_${index}`] = key
environment[`GIT_CONFIG_VALUE_${index}`] = value
})
}
return environment
}
async function resolveGitExecutable(
source: NodeJS.ProcessEnv = process.env
): Promise<string> {
const path = source.PATH ?? source.Path ?? source.path ?? ''
const executableName =
process.platform === 'win32' ? 'git.exe' : 'git'
for (const rawDirectory of path.split(delimiter)) {
const trimmed = rawDirectory.trim()
const directory =
trimmed.startsWith('"') && trimmed.endsWith('"')
? trimmed.slice(1, -1)
: trimmed
if (!directory || !isAbsolute(directory)) {
continue
}
const candidate = join(directory, executableName)
try {
await access(candidate, fileSystemConstants.X_OK)
const canonicalPath = await realpath(candidate)
if (
isAbsolute(canonicalPath) &&
(await stat(canonicalPath)).isFile()
) {
return canonicalPath
}
} catch {
// Continue through the explicit PATH candidates.
}
}
throw new Error('无法找到安全的 Git 可执行文件')
}
function getGitExecutable(): Promise<string> {
gitExecutable ??= resolveGitExecutable()
return gitExecutable
}
function getBoundedInteger(
value: number | undefined,
fallback: number,
maximum: number,
message: string
): number {
const resolved = value ?? fallback
if (
!Number.isSafeInteger(resolved) ||
resolved < 1 ||
resolved > maximum
) {
throw new Error(message)
}
return resolved
}
function normalizeRelativePath(
inputPath: string,
allowRoot: boolean
): { path: string; segments: string[] } {
const normalized = inputPath.replaceAll('\\', '/')
if (allowRoot && (normalized === '' || normalized === '.')) {
return { path: '', segments: [] }
}
if (
!normalized ||
normalized.startsWith('/') ||
/^[a-zA-Z]:\//u.test(normalized) ||
isAbsolute(normalized) ||
normalized.includes('\0')
) {
throw new Error('路径必须是工作区内的相对路径')
}
const segments = normalized.split('/')
if (
segments.some(
(segment) =>
!segment || segment === '.' || segment === '..'
)
) {
throw new Error('路径必须是工作区内的相对路径,不能超出工作区')
}
return {
path: segments.join('/'),
segments
}
}
async function runGit(
rootPath: string,
args: string[],
signal?: AbortSignal,
configOverrides: readonly GitConfigOverride[] = []
): Promise<CommandResult> {
const executablePath = await getGitExecutable()
return new Promise((resolveResult, reject) => {
signal?.throwIfAborted()
const child = spawn(executablePath, args, {
cwd: rootPath,
env: buildWorkspaceGitEnvironment(
process.env,
configOverrides
),
shell: false,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe']
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
let bytes = 0
let truncated = false
let settled = false
const capture = (target: Buffer[], chunk: Buffer | string): void => {
const buffer = Buffer.from(chunk)
const remaining = MAX_GIT_OUTPUT_BYTES - bytes
if (remaining <= 0) {
truncated = true
return
}
target.push(buffer.subarray(0, remaining))
bytes += Math.min(buffer.byteLength, remaining)
truncated ||= buffer.byteLength > remaining
}
const finish = (
callback: () => void
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', abort)
callback()
}
const abort = (): void => {
child.kill()
finish(() => reject(signal?.reason))
}
child.stdout?.on('data', (chunk: Buffer | string) =>
capture(stdout, chunk)
)
child.stderr?.on('data', (chunk: Buffer | string) =>
capture(stderr, chunk)
)
const timeout = setTimeout(() => {
child.kill()
finish(() => reject(new Error('读取文件更改超时')))
}, GIT_COMMAND_TIMEOUT_MS)
signal?.addEventListener('abort', abort, { once: true })
child.once('error', (error) => {
finish(() => reject(error))
})
child.once('close', (code) => {
finish(() =>
resolveResult({
code,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8'),
truncated
})
)
})
})
}
function withSafeGitArguments(args: string[]): string[] {
return [...safeGitArguments, ...args]
}
async function getFilterOverrides(
rootPath: string,
signal?: AbortSignal
): Promise<GitConfigOverride[]> {
const result = await runGit(
rootPath,
withSafeGitArguments([
'config',
'--local',
'--includes',
'--name-only',
'-z',
'--get-regexp',
'^filter\\..*\\.(clean|smudge|process|required)$'
]),
signal
)
if (result.truncated) {
throw new Error('Git 过滤器配置超过安全限制')
}
// `git config --get-regexp` returns 1 when no names matched.
if (result.code !== 0 && result.code !== 1) {
throw new Error(
result.stderr.trim().slice(0, 2_000) ||
'无法安全读取 Git 过滤器配置'
)
}
const drivers = new Set<string>()
for (const name of result.stdout.split('\0')) {
const match = /^(filter\..*)\.(?:clean|smudge|process|required)$/iu.exec(
name
)
if (match?.[1]) {
drivers.add(match[1])
if (drivers.size > MAX_GIT_FILTER_DRIVERS) {
throw new Error('Git 过滤器配置超过安全限制')
}
}
}
return [...drivers].flatMap(
(driver): GitConfigOverride[] => [
[`${driver}.clean`, ''],
[`${driver}.smudge`, ''],
[`${driver}.process`, ''],
[`${driver}.required`, 'false']
]
)
}
function parseChangedFiles(status: string): {
files: WorkspaceChangedFile[]
truncated: boolean
} {
const records = status.split('\0')
const files: WorkspaceChangedFile[] = []
let index = 0
while (index < records.length && files.length < MAX_CHANGED_FILES) {
const record = records[index]
index += 1
if (!record) {
continue
}
const statusCode = record.slice(0, 2)
const path = record.slice(3)
if (!path) {
continue
}
const renamed = statusCode.includes('R') || statusCode.includes('C')
const previousPath = renamed ? records[index] : undefined
if (renamed) {
index += 1
}
files.push({
path,
status: statusCode,
...(previousPath ? { previousPath } : {})
})
}
return {
files,
truncated: index < records.length - 1
}
}
function formatChangedFiles(files: WorkspaceChangedFile[]): string {
return files
.map((file) =>
file.previousPath
? `${file.status} ${file.previousPath} -> ${file.path}`
: `${file.status} ${file.path}`
)
.join('\n')
}
export class LocalWorkspaceAccess implements WorkspaceAccess {
private canonicalWorkspace?: Promise<string>
constructor(private readonly rootPath: string) {}
private getWorkspace(): Promise<string> {
this.canonicalWorkspace ??= getCanonicalWorkspace(
this.rootPath,
'项目工作区不是目录'
)
return this.canonicalWorkspace
}
private async resolveExisting(
inputPath: string,
expected: 'file' | 'directory'
): Promise<{ canonicalPath: string; path: string }> {
const root = await this.getWorkspace()
const normalized = normalizeRelativePath(
inputPath,
expected === 'directory'
)
return {
canonicalPath: await resolveExistingWorkspacePath(
root,
normalized.segments,
expected
),
path: normalized.path
}
}
async resolveEntryPath(
inputPath: string,
expected: 'file' | 'directory'
): Promise<string> {
return (await this.resolveExisting(inputPath, expected)).canonicalPath
}
private async resolveWritable(inputPath: string): Promise<{
canonicalPath: string
path: string
}> {
const root = await this.getWorkspace()
const normalized = normalizeRelativePath(inputPath, false)
const candidate = resolve(root, ...normalized.segments)
if (!isPathInside(root, candidate) || candidate === root) {
throw new Error('工具路径不能超出工作区')
}
const canonicalParent = await realpath(dirname(candidate))
if (!isPathInside(root, canonicalParent)) {
throw new Error('工具路径不能通过符号链接超出工作区')
}
const existing = await lstat(candidate).catch((error: unknown) => {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return undefined
}
throw error
})
if (existing?.isSymbolicLink()) {
throw new Error('工作区写入工具拒绝符号链接')
}
if (existing && !existing.isFile()) {
throw new Error('工作区写入目标不是普通文件')
}
return { canonicalPath: candidate, path: normalized.path }
}
async getIdentity(): Promise<WorkspaceIdentity> {
const canonicalRoot = await this.getWorkspace()
const metadata = await stat(canonicalRoot)
return {
kind: 'local',
id: `local:${metadata.dev}:${metadata.ino}:${canonicalRoot}`,
canonicalDisplayPath: canonicalRoot,
access: 'read-write'
}
}
async listDirectory(
input: WorkspaceDirectoryInput
): Promise<DirectoryPage> {
input.signal?.throwIfAborted()
const maximumEntries = getBoundedInteger(
input.maximumEntries,
MAX_DIRECTORY_ENTRIES,
MAX_DIRECTORY_ENTRIES,
'工作区目录条目上限无效'
)
const directory = await this.resolveExisting(input.path, 'directory')
const listing = await listBoundedDirectoryEntries(
directory.canonicalPath,
maximumEntries,
(entry) =>
(input.includeGit === true || entry.name !== '.git') &&
(input.includeOther === true ||
entry.isDirectory() ||
entry.isFile())
)
input.signal?.throwIfAborted()
const entries = listing.entries.sort((left, right) => {
if (
input.includeOther !== true &&
left.isDirectory() !== right.isDirectory()
) {
return left.isDirectory() ? -1 : 1
}
return left.name.localeCompare(right.name)
})
return {
path: directory.path,
entries: entries.map((entry) => ({
name: entry.name,
path: [directory.path, entry.name].filter(Boolean).join('/'),
type: entry.isDirectory()
? 'directory'
: entry.isFile()
? 'file'
: 'other'
})),
truncated: listing.truncated
}
}
async stat(input: WorkspacePathInput): Promise<WorkspaceEntry> {
input.signal?.throwIfAborted()
const root = await this.getWorkspace()
const normalized = normalizeRelativePath(input.path, true)
const candidate = resolve(root, ...normalized.segments)
if (!isPathInside(root, candidate)) {
throw new Error('文件路径不能超出项目工作区')
}
const canonicalPath = await realpath(candidate)
if (!isPathInside(root, canonicalPath)) {
throw new Error('文件路径不能通过符号链接超出项目工作区')
}
const metadata = await stat(canonicalPath)
input.signal?.throwIfAborted()
return {
name: basename(canonicalPath),
path: normalized.path,
type: metadata.isDirectory()
? 'directory'
: metadata.isFile()
? 'file'
: 'other',
size: metadata.size,
modifiedAt: metadata.mtime.toISOString()
}
}
async readText(input: WorkspaceReadInput): Promise<FilePreview> {
input.signal?.throwIfAborted()
const maximumBytes = getBoundedInteger(
input.maximumBytes,
DEFAULT_READ_BYTES,
DEFAULT_WRITE_BYTES,
'工作区读取上限无效'
)
const file = await this.resolveExisting(input.path, 'file')
const preview = await readBoundedUtf8File(
file.canonicalPath,
maximumBytes,
input.tooLargeMessage ?? '工作区文本文件超过安全限制',
input.invalidUtf8Message ?? '工作区读取目标不是有效 UTF-8 文本'
)
input.signal?.throwIfAborted()
return {
path: file.path,
name: basename(file.canonicalPath),
content: preview.content,
size: preview.size
}
}
async writeTextAtomic(
input: WorkspaceWriteInput
): Promise<WriteResult> {
input.signal?.throwIfAborted()
const maximumBytes = getBoundedInteger(
input.maximumBytes,
DEFAULT_WRITE_BYTES,
DEFAULT_WRITE_BYTES,
'工作区写入上限无效'
)
const bytesWritten = Buffer.byteLength(input.content)
if (bytesWritten > maximumBytes) {
throw new Error('写入内容超过 512KB 安全限制')
}
const file = await this.resolveWritable(input.path)
const temporaryPath = `${file.canonicalPath}.${randomUUID()}.tmp`
const handle = await open(temporaryPath, 'wx', 0o600)
try {
try {
await handle.writeFile(input.content, 'utf8')
} finally {
await handle.close()
}
input.signal?.throwIfAborted()
await rename(temporaryPath, file.canonicalPath)
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined)
if (input.signal?.aborted) {
throw error
}
throw new Error('无法安全写入工作区文件', { cause: error })
}
return { path: file.path, bytesWritten }
}
async search(input: WorkspaceSearchInput): Promise<SearchPage> {
input.signal?.throwIfAborted()
if (
!input.query ||
input.query.includes('\0') ||
Buffer.byteLength(input.query) > 4_096
) {
throw new Error('工作区搜索词无效')
}
const maximumResults = getBoundedInteger(
input.maximumResults,
DEFAULT_SEARCH_RESULTS,
MAX_SEARCH_RESULTS,
'工作区搜索结果上限无效'
)
const maximumFileBytes = getBoundedInteger(
input.maximumFileBytes,
DEFAULT_READ_BYTES,
DEFAULT_WRITE_BYTES,
'工作区搜索文件上限无效'
)
const maximumScannedBytes = getBoundedInteger(
input.maximumScannedBytes,
DEFAULT_SEARCH_SCAN_BYTES,
MAX_SEARCH_SCAN_BYTES,
'工作区搜索扫描上限无效'
)
const queue = [input.path ?? '']
const matches: SearchPage['matches'] = []
let scannedBytes = 0
let truncated = false
while (queue.length > 0 && !truncated) {
input.signal?.throwIfAborted()
const currentPath = queue.shift()!
const listing = await this.listDirectory({
path: currentPath,
maximumEntries: MAX_DIRECTORY_ENTRIES,
signal: input.signal
})
truncated ||= listing.truncated
for (const entry of listing.entries) {
if (entry.type === 'directory') {
queue.push(entry.path)
continue
}
if (entry.type !== 'file') {
continue
}
const metadata = await this.stat({
path: entry.path,
signal: input.signal
})
if (
metadata.size > maximumFileBytes ||
scannedBytes + metadata.size > maximumScannedBytes
) {
truncated = true
break
}
scannedBytes += metadata.size
let content: string
try {
content = (
await this.readText({
path: entry.path,
maximumBytes: maximumFileBytes,
signal: input.signal
})
).content
} catch (error) {
if (input.signal?.aborted) {
throw error
}
continue
}
const lines = content.split(/\r?\n/u)
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!
const column = line.indexOf(input.query)
if (column < 0) {
continue
}
matches.push({
path: entry.path,
line: index + 1,
column: column + 1,
preview: line.slice(0, 1_000)
})
if (matches.length >= maximumResults) {
truncated = true
break
}
}
if (truncated) {
break
}
}
}
return { matches, truncated }
}
async getChanges(
input: WorkspaceChangesInput
): Promise<WorkspaceChanges> {
input.signal?.throwIfAborted()
if (!this.rootPath.trim()) {
return {
rootPath: this.rootPath,
available: false,
status: '',
patch: '',
files: [],
truncated: false,
error: '项目尚未配置工作区目录'
}
}
const gitMetadata = await stat(join(this.rootPath, '.git')).catch(
() => undefined
)
if (!gitMetadata) {
return {
rootPath: this.rootPath,
available: false,
status: '',
patch: '',
files: [],
truncated: false
}
}
try {
const canonicalRoot = await this.getWorkspace()
const filterOverrides = await getFilterOverrides(
canonicalRoot,
input.signal
)
const [statusResult, patchResult] = await Promise.all([
runGit(
canonicalRoot,
withSafeGitArguments([
'status',
'--porcelain=v1',
'-z',
'--untracked-files=normal'
]),
input.signal,
filterOverrides
),
runGit(
canonicalRoot,
withSafeGitArguments([
'diff',
'--no-ext-diff',
'--no-textconv',
'--no-color',
'HEAD'
]),
input.signal,
filterOverrides
)
])
if (statusResult.code !== 0 || patchResult.code !== 0) {
const detail = statusResult.stderr || patchResult.stderr
return {
rootPath: this.rootPath,
available: false,
status: '',
patch: '',
files: [],
truncated: statusResult.truncated || patchResult.truncated,
error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区'
}
}
const changedFiles = parseChangedFiles(statusResult.stdout)
return {
rootPath: this.rootPath,
available: true,
status: formatChangedFiles(changedFiles.files),
patch: patchResult.stdout,
files: changedFiles.files,
truncated:
statusResult.truncated ||
patchResult.truncated ||
changedFiles.truncated
}
} catch (error) {
if (input.signal?.aborted) {
throw error
}
return {
rootPath: this.rootPath,
available: false,
status: '',
patch: '',
files: [],
truncated: false,
error:
error instanceof Error ? error.message : '无法读取 Git 工作区'
}
}
}
async dispose(): Promise<void> {}
}

Some files were not shown because too many files have changed in this diff Show More