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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => [])
|
||||
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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('无效')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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(' ')}`
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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' }]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('配置已变化')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user