fix: make managed SSH sessions fast and resilient
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Managed SSH project selection repeatedly activated the remote Host and could leave idle ACP bindings rejected as stale-controller after a controller generation change. Host environment updates also coupled project state to component installation evidence and presented a crowded control surface. Project selection is now local-only, one Host connection serves compatible project and conversation callers, idle bindings are safely replaced only after proven controller takeover, and active prompts keep exact resume/replay. Remote environment setup now uses explicit signed compound package preparation with bounded transfer, rollback, recovery, and clearer settings. Managed Hosts must be updated to separately distributed Agent 0.11.6 before the daemon-side controller recovery fix takes effect. Desktop packages do not embed the Agent or remote Runtime, and existing project data is preserved. Release note: 托管 SSH 项目切换不再重复连接,同一 Host 可顺畅承载多个项目和对话;同时修复重连后的 stale-controller,并优化远程环境安装、更新与状态布局。
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
@@ -1,10 +1,5 @@
|
||||
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
|
||||
@@ -42,35 +37,18 @@ describe('ExecutionSpaceResolver', () => {
|
||||
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'
|
||||
}
|
||||
})
|
||||
it('keeps remote workspace operations unavailable without a factory', async () => {
|
||||
const descriptor = new ExecutionSpaceResolver().resolveProject(
|
||||
remoteProject()
|
||||
)
|
||||
|
||||
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 () => {
|
||||
it('binds remote access only to the configured Host and path', () => {
|
||||
const workspaceAccess = {
|
||||
getIdentity: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
@@ -84,289 +62,54 @@ describe('ExecutionSpaceResolver', () => {
|
||||
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
|
||||
})
|
||||
)
|
||||
const first = resolver.resolveProject(remoteProject())
|
||||
const second = resolver.resolveProject(remoteProject())
|
||||
|
||||
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(first.workspaceAccess).toBe(workspaceAccess)
|
||||
expect(first.cacheIdentity).toBe(second.cacheIdentity)
|
||||
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'
|
||||
remoteRootPath: '/srv/project'
|
||||
})
|
||||
})
|
||||
|
||||
const stale = resolver.resolveProject(
|
||||
project({
|
||||
rootPath: '/srv/project',
|
||||
it('changes remote identities only with stable project configuration', () => {
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const first = resolver.resolveProject(remoteProject())
|
||||
const nextPath = resolver.resolveProject(
|
||||
remoteProject({
|
||||
rootPath: '/srv/other',
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId: '00000000-0000-4000-8000-000000000201',
|
||||
remoteRootPath: '/srv/project',
|
||||
validation
|
||||
},
|
||||
runtimeValidation: {
|
||||
...runtimeValidation,
|
||||
agentInstallationIdAtValidation: 'agent-2'
|
||||
remoteRootPath: '/srv/other'
|
||||
}
|
||||
})
|
||||
)
|
||||
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
|
||||
const nextProject = resolver.resolveProject(
|
||||
remoteProject({
|
||||
id: '00000000-0000-4000-8000-000000000102'
|
||||
})
|
||||
)
|
||||
|
||||
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 })
|
||||
}
|
||||
expect(nextPath.cacheIdentity).not.toBe(first.cacheIdentity)
|
||||
expect(nextProject.cacheIdentity).toBe(first.cacheIdentity)
|
||||
expect(nextProject.routeIdentity).not.toBe(first.routeIdentity)
|
||||
})
|
||||
})
|
||||
|
||||
function remoteProject(
|
||||
overrides: Partial<AssistantProject> = {}
|
||||
): AssistantProject {
|
||||
return project({
|
||||
rootPath: '/srv/project',
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId: '00000000-0000-4000-8000-000000000201',
|
||||
remoteRootPath: '/srv/project'
|
||||
},
|
||||
runtimeSelection: { provider: 'opencode' },
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { isAbsolute, normalize, resolve } from 'node:path'
|
||||
import type {
|
||||
AssistantProject,
|
||||
ProjectRuntimeValidation,
|
||||
SshExecutionValidation
|
||||
} from '../../shared/assistant-contracts'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import {
|
||||
LocalWorkspaceAccess,
|
||||
UnsupportedRemoteWorkspaceAccess,
|
||||
@@ -27,8 +23,6 @@ export type SshExecutionSpaceDescriptor = {
|
||||
kind: 'ssh'
|
||||
hostId: string
|
||||
remoteRootPath: string
|
||||
validation?: SshExecutionValidation
|
||||
runtimeValidation?: ProjectRuntimeValidation
|
||||
cacheIdentity: string
|
||||
routeIdentity: string
|
||||
workspaceAccess: WorkspaceAccess
|
||||
@@ -79,78 +73,27 @@ export class ExecutionSpaceResolver {
|
||||
return this.resolveLocal(executionSpace.rootPath)
|
||||
}
|
||||
|
||||
const sshValidation = executionSpace.validation
|
||||
const runtimeValidation = project.runtimeValidation
|
||||
const identity = {
|
||||
const binding: RemoteWorkspaceProjectBinding = {
|
||||
hostId: executionSpace.hostId,
|
||||
remoteRootPath: executionSpace.remoteRootPath,
|
||||
validation: sshValidation,
|
||||
runtimeValidation
|
||||
remoteRootPath: executionSpace.remoteRootPath
|
||||
}
|
||||
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,
|
||||
...binding,
|
||||
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
|
||||
binding.hostId,
|
||||
binding.remoteRootPath
|
||||
]),
|
||||
routeIdentity: JSON.stringify([
|
||||
'ssh',
|
||||
project.id,
|
||||
identity.hostId,
|
||||
identity.remoteRootPath
|
||||
binding.hostId,
|
||||
binding.remoteRootPath
|
||||
]),
|
||||
workspaceAccess:
|
||||
remoteBinding && this.remoteWorkspaceAccessFactory
|
||||
? this.remoteWorkspaceAccessFactory.create(remoteBinding)
|
||||
this.remoteWorkspaceAccessFactory
|
||||
? this.remoteWorkspaceAccessFactory.create(binding)
|
||||
: new UnsupportedRemoteWorkspaceAccess(
|
||||
REMOTE_EXECUTION_SPACE_UNAVAILABLE
|
||||
)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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 complete evidence from the current Agent installation', () => {
|
||||
expect(() =>
|
||||
assertRemoteRuntimeRequestValidated(descriptor())
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not couple current requests to a persisted model profile or work mode', () => {
|
||||
const changedRequestEvidence = descriptor()
|
||||
changedRequestEvidence.runtimeValidation = {
|
||||
...changedRequestEvidence.runtimeValidation!,
|
||||
runtimeSelectionKey:
|
||||
'opencode:00000000-0000-4000-8000-000000000099',
|
||||
workMode: 'execute'
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
assertRemoteRuntimeRequestValidated(changedRequestEvidence)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects missing or cross-installation evidence', () => {
|
||||
expect(() =>
|
||||
assertRemoteRuntimeRequestValidated(
|
||||
{ ...descriptor(), runtimeValidation: undefined }
|
||||
)
|
||||
).toThrow('缺少完整')
|
||||
expect(() =>
|
||||
assertRemoteRuntimeRequestValidated(
|
||||
{
|
||||
...descriptor(),
|
||||
runtimeValidation: {
|
||||
...descriptor().runtimeValidation!,
|
||||
agentInstallationIdAtValidation: 'installation-2'
|
||||
}
|
||||
}
|
||||
)
|
||||
).toThrow('缺少完整')
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { SshExecutionSpaceDescriptor } from './execution-space-resolver'
|
||||
|
||||
/**
|
||||
* Prevents a request from combining persisted remote Runtime evidence with a
|
||||
* different Agent installation. Runtime provider, model profile, and Ask /
|
||||
* Execute behavior are validated by the live Runtime path for each request.
|
||||
*/
|
||||
export function assertRemoteRuntimeRequestValidated(
|
||||
executionSpace: SshExecutionSpaceDescriptor
|
||||
): void {
|
||||
const executionValidation = executionSpace.validation
|
||||
const runtimeValidation = executionSpace.runtimeValidation
|
||||
if (
|
||||
!executionValidation ||
|
||||
!runtimeValidation ||
|
||||
executionValidation.agentInstallationIdAtValidation !==
|
||||
runtimeValidation.agentInstallationIdAtValidation
|
||||
) {
|
||||
throw new Error('远程 Project 缺少完整的当前验证,请重新验证')
|
||||
}
|
||||
}
|
||||
+38
-9
@@ -11,7 +11,7 @@ import {
|
||||
utilityProcess
|
||||
} from 'electron'
|
||||
import { homedir } from 'node:os'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import spawn from 'cross-spawn'
|
||||
@@ -132,7 +132,8 @@ import {
|
||||
} from './execution-space'
|
||||
import { RemoteAgentServices } from './remote-agent/remote-agent-services'
|
||||
import {
|
||||
resolveBundledAgentResourcePaths
|
||||
resolveBundledAgentResourcePaths,
|
||||
resolveControlPlanePackageInstallerPath
|
||||
} from './remote-agent/bundled-agent-resources'
|
||||
import {
|
||||
AgentPackageManager
|
||||
@@ -141,6 +142,12 @@ import { ManagedRemoteExecutionServices } from './remote-agent/managed-remote-ex
|
||||
import {
|
||||
RemoteEnvironmentUpdateService
|
||||
} from './remote-agent/remote-environment-update-service'
|
||||
import {
|
||||
RemoteEnvironmentPreparer
|
||||
} from './remote-agent/remote-environment-preparer'
|
||||
import {
|
||||
RemoteEnvironmentOperationStore
|
||||
} from './remote-agent/remote-environment-operation-store'
|
||||
import type {
|
||||
RuntimeSettings,
|
||||
RuntimeSettingsInput
|
||||
@@ -651,6 +658,11 @@ if (hasSingleInstanceLock) {
|
||||
resourcesPath: process.resourcesPath,
|
||||
packaged: app.isPackaged
|
||||
})
|
||||
const controlPlanePackageInstaller = await readFile(
|
||||
resolveControlPlanePackageInstallerPath({
|
||||
appPath: app.getAppPath()
|
||||
})
|
||||
)
|
||||
const agentPackageManager = new AgentPackageManager({
|
||||
userDataPath: app.getPath('userData'),
|
||||
desktopVersion: app.getVersion(),
|
||||
@@ -665,7 +677,7 @@ if (hasSingleInstanceLock) {
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
packaged: app.isPackaged,
|
||||
agentPackageManager
|
||||
controlPlanePackageInstaller
|
||||
})
|
||||
remoteAgentServices = startupRemoteAgentServices
|
||||
const startupManagedRemoteExecutionServices =
|
||||
@@ -676,7 +688,6 @@ if (hasSingleInstanceLock) {
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
packaged: app.isPackaged,
|
||||
agentPackageManager,
|
||||
resolveRuntimeSelection: async (selection) =>
|
||||
resolveConfiguredAgentRuntimeSelection(
|
||||
await settingsStore.getResolvedSettings(),
|
||||
@@ -695,6 +706,7 @@ if (hasSingleInstanceLock) {
|
||||
const invalidations = [
|
||||
startupRemoteAgentServices.invalidateHost(hostId)
|
||||
]
|
||||
startupManagedRemoteExecutionServices.invalidateHost(hostId)
|
||||
if (selectedRuntimeManager) {
|
||||
invalidations.push(
|
||||
selectedRuntimeManager.invalidateHost(hostId)
|
||||
@@ -729,8 +741,11 @@ if (hasSingleInstanceLock) {
|
||||
remoteRuntimeLockPath:
|
||||
startupManagedRemoteExecutionServices.runtimeResourcePaths
|
||||
.runtimeLockPath,
|
||||
loadExpectedCatalog: (architecture) =>
|
||||
agentPackageManager.getExpectedCatalog(architecture)
|
||||
loadRemoteEnvironmentCatalog: (architecture, options) =>
|
||||
agentPackageManager.getRemoteEnvironmentCatalog(
|
||||
architecture,
|
||||
options
|
||||
)
|
||||
})
|
||||
const [initialRuntimeSettings, initialResolvedSettings] =
|
||||
await Promise.all([
|
||||
@@ -1240,12 +1255,26 @@ if (hasSingleInstanceLock) {
|
||||
database: startupAssistantDatabase,
|
||||
notify: sendRemoteProjectSaveProgress
|
||||
})
|
||||
const remoteEnvironmentPreparer =
|
||||
new RemoteEnvironmentPreparer({
|
||||
resolver: startupRemoteAgentServices.targetResolver,
|
||||
sshPool: startupRemoteAgentServices.sshPool,
|
||||
agentPackageManager,
|
||||
agentInstallationManager:
|
||||
startupRemoteAgentServices.installationManager,
|
||||
runtimeInstallationManager:
|
||||
startupManagedRemoteExecutionServices
|
||||
.runtimeInstallationManager,
|
||||
operationStore:
|
||||
new RemoteEnvironmentOperationStore(
|
||||
app.getPath('userData')
|
||||
)
|
||||
})
|
||||
const remoteEnvironmentUpdateService =
|
||||
new RemoteEnvironmentUpdateService(
|
||||
startupRemoteAgentServices.installationManager,
|
||||
startupManagedRemoteExecutionServices
|
||||
.runtimeInstallationManager,
|
||||
remoteEnvironmentPreparer,
|
||||
async (hostId) => {
|
||||
startupManagedRemoteExecutionServices.invalidateHost(hostId)
|
||||
const invalidations = [
|
||||
startupRemoteAgentServices.invalidateHost(hostId)
|
||||
]
|
||||
|
||||
+86
-70
@@ -980,7 +980,12 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
}
|
||||
const sshHostService = {
|
||||
getSnapshot: vi.fn(async () => snapshot),
|
||||
remove: vi.fn(async () => undefined),
|
||||
remove: vi.fn(
|
||||
async (
|
||||
_hostId: string,
|
||||
commitLocalDeletion?: () => unknown
|
||||
) => commitLocalDeletion?.()
|
||||
),
|
||||
inspectDraftHostKey: vi.fn(async () => ({
|
||||
candidateId,
|
||||
hostId,
|
||||
@@ -1003,13 +1008,15 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
}
|
||||
}))
|
||||
}
|
||||
const listProjectIdsReferencingSshHost = vi.fn((): string[] => [])
|
||||
const deleteProjectsReferencingSshHost = vi.fn(
|
||||
(): Array<{ id: string; name: string }> => []
|
||||
)
|
||||
const listSshHostProjectReferences = vi.fn(() => [])
|
||||
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)
|
||||
@@ -1061,7 +1068,12 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
version: '1.18.9',
|
||||
architecture: 'x64'
|
||||
}
|
||||
}]
|
||||
}],
|
||||
remoteDownload: {
|
||||
available: true,
|
||||
source: 'mirror',
|
||||
packageSize: 1024
|
||||
}
|
||||
} as const
|
||||
const inspectRemoteEnvironment = vi.fn(
|
||||
async () => remoteEnvironment
|
||||
@@ -1073,16 +1085,21 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
update: vi.fn(
|
||||
async (
|
||||
owner: typeof webContents,
|
||||
updateHostId: string,
|
||||
request: {
|
||||
hostId: string
|
||||
method: 'remote-download' | 'goodbuddy-transfer'
|
||||
},
|
||||
onProgress?: (progress: {
|
||||
hostId: string
|
||||
phase: 'agent'
|
||||
method: 'remote-download' | 'goodbuddy-transfer'
|
||||
phase: 'verifying'
|
||||
}) => void
|
||||
) => {
|
||||
expect(owner).toBe(webContents)
|
||||
onProgress?.({
|
||||
hostId: updateHostId,
|
||||
phase: 'agent'
|
||||
hostId: request.hostId,
|
||||
method: request.method,
|
||||
phase: 'verifying'
|
||||
})
|
||||
}
|
||||
),
|
||||
@@ -1162,7 +1179,8 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
listProjectIdsReferencingSshHost
|
||||
listSshHostProjectReferences,
|
||||
deleteProjectsReferencingSshHost
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
@@ -1214,7 +1232,10 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event)
|
||||
).resolves.toEqual(snapshot)
|
||||
).resolves.toEqual({
|
||||
...snapshot,
|
||||
projectReferences: {}
|
||||
})
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.sshHostsAgentPackageInventory
|
||||
@@ -1328,17 +1349,41 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
event,
|
||||
{ hostId }
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
listProjectIdsReferencingSshHost.mockReturnValue([
|
||||
'00000000-0000-4000-8000-000000000107'
|
||||
).resolves.toEqual({
|
||||
hostId,
|
||||
deletedProjects: []
|
||||
})
|
||||
deleteProjectsReferencingSshHost.mockReturnValue([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000107',
|
||||
name: 'Remote project'
|
||||
}
|
||||
])
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
|
||||
event,
|
||||
{ hostId }
|
||||
)
|
||||
).rejects.toThrow('SSH 主机仍被项目引用,无法删除')
|
||||
expect(sshHostService.remove).toHaveBeenCalledOnce()
|
||||
).resolves.toEqual({
|
||||
hostId,
|
||||
deletedProjects: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000107',
|
||||
name: 'Remote project'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(sshHostService.remove).toHaveBeenCalledTimes(2)
|
||||
sshHostService.remove.mockRejectedValueOnce(
|
||||
new Error('Host store unavailable')
|
||||
)
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
|
||||
event,
|
||||
{ hostId }
|
||||
)
|
||||
).rejects.toThrow('Host store unavailable')
|
||||
expect(deleteProjectsReferencingSshHost).toHaveBeenCalledTimes(2)
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
@@ -1356,28 +1401,36 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.sshHostsUpdateRemoteEnvironment
|
||||
)?.(event, { hostId })
|
||||
)?.(event, { hostId, method: 'remote-download' })
|
||||
).resolves.toBeUndefined()
|
||||
expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledWith(
|
||||
webContents,
|
||||
hostId,
|
||||
{ hostId, method: 'remote-download' },
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(webContents.send).toHaveBeenCalledWith(
|
||||
ipcChannels.sshHostsRemoteEnvironmentUpdateProgress,
|
||||
{ hostId, phase: 'agent' }
|
||||
{
|
||||
hostId,
|
||||
method: 'remote-download',
|
||||
phase: 'verifying'
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.sshHostsUpdateRemoteEnvironment
|
||||
)?.(event, { hostId, command: 'whoami' })
|
||||
)?.(event, {
|
||||
hostId,
|
||||
method: 'remote-download',
|
||||
command: 'whoami'
|
||||
})
|
||||
).rejects.toThrow()
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.sshHostsUpdateRemoteEnvironment
|
||||
)?.(
|
||||
{ sender: {}, senderFrame: webContents.mainFrame },
|
||||
{ hostId }
|
||||
{ hostId, method: 'remote-download' }
|
||||
)
|
||||
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
||||
expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledOnce()
|
||||
@@ -1491,15 +1544,6 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
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
|
||||
@@ -1518,20 +1562,6 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
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(
|
||||
@@ -1604,7 +1634,8 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
listProjectIdsReferencingSshHost: vi.fn(() => []),
|
||||
listSshHostProjectReferences: vi.fn(() => []),
|
||||
deleteProjectsReferencingSshHost: vi.fn(() => []),
|
||||
getProject: vi.fn(() => remoteProject),
|
||||
updateProject: vi.fn(),
|
||||
setProjectArchived: vi.fn(),
|
||||
@@ -1705,12 +1736,14 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
{ candidateId, fingerprintSha256: fingerprint, input: createInput }
|
||||
],
|
||||
[ipcChannels.sshHostsRemoteEnvironment, { hostId }],
|
||||
[ipcChannels.sshHostsUpdateRemoteEnvironment, { hostId }],
|
||||
[
|
||||
ipcChannels.sshHostsUpdateRemoteEnvironment,
|
||||
{ hostId, method: 'remote-download' }
|
||||
],
|
||||
[
|
||||
ipcChannels.sshHostsBrowseDirectories,
|
||||
{ hostId, path: '/srv' }
|
||||
],
|
||||
[ipcChannels.remoteProjectActivate, projectId],
|
||||
[ipcChannels.remoteProjectSave, remoteDraft]
|
||||
]
|
||||
for (const [channel, input] of disabledRequests) {
|
||||
@@ -1786,7 +1819,10 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
webContents
|
||||
)
|
||||
expect(
|
||||
assistantDatabase.listProjectIdsReferencingSshHost
|
||||
assistantDatabase.listSshHostProjectReferences
|
||||
).not.toHaveBeenCalled()
|
||||
expect(
|
||||
assistantDatabase.deleteProjectsReferencingSshHost
|
||||
).not.toHaveBeenCalled()
|
||||
expect(assistantDatabase.updateProject).not.toHaveBeenCalled()
|
||||
expect(assistantDatabase.setProjectArchived).not.toHaveBeenCalled()
|
||||
@@ -1833,7 +1869,8 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
}
|
||||
const progress = {
|
||||
hostId: '00000000-0000-4000-8000-000000000105',
|
||||
phase: 'runtime' as const
|
||||
method: 'remote-download' as const,
|
||||
phase: 'installing-runtime' as const
|
||||
}
|
||||
|
||||
sendRemoteEnvironmentUpdateProgress(owner as never, progress)
|
||||
@@ -7887,7 +7924,6 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
selectedRuntimes
|
||||
)
|
||||
const projectId = '00000000-0000-4000-8000-000000000451'
|
||||
const agentInstallationId = 'agent-installation-1'
|
||||
harness.getApplicationSettings.mockResolvedValue({
|
||||
magicNotesEnabled: false,
|
||||
remoteProjectsEnabled: true
|
||||
@@ -7900,30 +7936,10 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId: '00000000-0000-4000-8000-000000000452',
|
||||
remoteRootPath: '/srv/project',
|
||||
validation: {
|
||||
hostRevision: 1,
|
||||
hostKeyGeneration: 1,
|
||||
remoteUsername: 'builder',
|
||||
workspaceIdentity: 'workspace-1',
|
||||
agentProtocolMajor: 2,
|
||||
agentInstallationIdAtValidation: agentInstallationId,
|
||||
agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`,
|
||||
agentVersionAtValidation: '0.11.2-e2e.12',
|
||||
agentArchitectureAtValidation: 'x64',
|
||||
validatedAt: '2026-08-25T00:00:00.000Z'
|
||||
}
|
||||
remoteRootPath: '/srv/project'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
runtimeSelection: { provider: 'opencode' },
|
||||
runtimeValidation: {
|
||||
runtimeSelectionKey: 'opencode:default',
|
||||
runtimeBundleDigest: `sha256:${'b'.repeat(64)}`,
|
||||
runtimeAdapterDigest: `sha256:${'c'.repeat(64)}`,
|
||||
agentInstallationIdAtValidation: agentInstallationId,
|
||||
validatedAt: '2026-08-25T00:00:00.000Z',
|
||||
workMode: 'execute'
|
||||
},
|
||||
kind: 'user',
|
||||
status: 'active',
|
||||
createdAt: '2026-08-25T00:00:00.000Z',
|
||||
|
||||
+39
-33
@@ -227,9 +227,11 @@ import {
|
||||
sshHostDraftInspectionRequestSchema,
|
||||
sshHostRequestSchema,
|
||||
remoteEnvironmentUpdateProgressSchema,
|
||||
remoteEnvironmentUpdateRequestSchema,
|
||||
sshHostRemoteEnvironmentSchema,
|
||||
sshHostValidationRequestSchema,
|
||||
type RemoteEnvironmentUpdateProgress
|
||||
type RemoteEnvironmentUpdateProgress,
|
||||
type SshHostProjectReference
|
||||
} from '../shared/ssh-host-contracts'
|
||||
import type { SshHostService } from './ssh/ssh-host-service'
|
||||
import type { SshHostDirectoryBrowser } from './ssh/ssh-host-directory-browser'
|
||||
@@ -241,9 +243,9 @@ import {
|
||||
remoteProjectSaveRequestSchema,
|
||||
type RemoteProjectSaveProgress
|
||||
} from '../shared/remote-project-candidate-contracts'
|
||||
import type {
|
||||
RemoteProjectSaveOwner,
|
||||
RemoteProjectSaveService
|
||||
import {
|
||||
type RemoteProjectSaveOwner,
|
||||
type RemoteProjectSaveService
|
||||
} from './remote-agent/remote-project-save-service'
|
||||
import type {
|
||||
RemoteEnvironmentUpdateOwner,
|
||||
@@ -326,7 +328,6 @@ 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
|
||||
@@ -1133,9 +1134,6 @@ export function registerIpcHandlers(
|
||||
selection
|
||||
)
|
||||
}
|
||||
if (executionSpace?.kind === 'ssh') {
|
||||
assertRemoteRuntimeRequestValidated(executionSpace)
|
||||
}
|
||||
return selectedRuntimes.getRuntime(selection, executionSpace)
|
||||
}
|
||||
const channels = Object.values(ipcChannels).filter(
|
||||
@@ -4112,7 +4110,24 @@ export function registerIpcHandlers(
|
||||
if (!sshHostService) {
|
||||
throw new Error('SSH 主机设置服务不可用')
|
||||
}
|
||||
return sshHostService.getSnapshot()
|
||||
const snapshot = await sshHostService.getSnapshot()
|
||||
const projectReferences: Record<
|
||||
string,
|
||||
SshHostProjectReference[]
|
||||
> = {}
|
||||
for (const reference of
|
||||
assistantDatabase.listSshHostProjectReferences()) {
|
||||
const projects = projectReferences[reference.hostId] ?? []
|
||||
projects.push({
|
||||
id: reference.id,
|
||||
name: reference.name
|
||||
})
|
||||
projectReferences[reference.hostId] = projects
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
projectReferences
|
||||
}
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
@@ -4222,12 +4237,15 @@ export function registerIpcHandlers(
|
||||
throw new Error('SSH 主机设置服务不可用')
|
||||
}
|
||||
const hostId = sshHostRequestSchema.parse(input).hostId
|
||||
const referencingProjectIds =
|
||||
assistantDatabase.listProjectIdsReferencingSshHost(hostId)
|
||||
if (referencingProjectIds.length > 0) {
|
||||
throw new Error('SSH 主机仍被项目引用,无法删除')
|
||||
const deletedProjects = await sshHostService.remove(
|
||||
hostId,
|
||||
() =>
|
||||
assistantDatabase.deleteProjectsReferencingSshHost(hostId)
|
||||
)
|
||||
return {
|
||||
hostId,
|
||||
deletedProjects
|
||||
}
|
||||
return sshHostService.remove(hostId)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4296,10 +4314,11 @@ export function registerIpcHandlers(
|
||||
if (!remoteEnvironmentUpdateService) {
|
||||
throw new Error('SSH 远端运行环境更新服务不可用')
|
||||
}
|
||||
const hostId = sshHostRequestSchema.parse(input).hostId
|
||||
const request =
|
||||
remoteEnvironmentUpdateRequestSchema.parse(input)
|
||||
await remoteEnvironmentUpdateService.update(
|
||||
event.sender,
|
||||
hostId,
|
||||
request,
|
||||
(progress) =>
|
||||
sendRemoteEnvironmentUpdateProgress(
|
||||
event.sender,
|
||||
@@ -5175,22 +5194,6 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.remoteProjectActivate,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
await requireRemoteProjectsEnabled()
|
||||
if (!remoteProjectSaveService) {
|
||||
throw new Error('远程项目验证服务不可用')
|
||||
}
|
||||
const result = await remoteProjectSaveService.activate(
|
||||
event.sender,
|
||||
assistantIdSchema.parse(input)
|
||||
)
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.remoteProjectSave,
|
||||
async (event, input: unknown) => {
|
||||
@@ -5262,7 +5265,10 @@ export function registerIpcHandlers(
|
||||
}
|
||||
assistantDatabase.deleteProject(
|
||||
value.projectId,
|
||||
value.confirmation
|
||||
value.confirmation,
|
||||
{
|
||||
allowActiveTasks: project.executionSpace?.kind === 'ssh'
|
||||
}
|
||||
)
|
||||
await selectedRuntimes?.reset?.()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { ensureModelOperationNotAborted } from './model-package-utils'
|
||||
const MAX_REDIRECTS = 3
|
||||
const redirectStatuses = new Set([301, 302, 303, 307, 308])
|
||||
|
||||
function validateDownloadUrl(value: string, modelLabel: string): URL {
|
||||
export function validateSecureDownloadUrl(
|
||||
value: string,
|
||||
downloadLabel: string
|
||||
): URL {
|
||||
const url = new URL(value)
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
@@ -13,12 +16,16 @@ function validateDownloadUrl(value: string, modelLabel: string): URL {
|
||||
url.hash
|
||||
) {
|
||||
throw new Error(
|
||||
`${modelLabel}下载地址必须是使用标准端口、无凭据和 Fragment 的 HTTPS URL`
|
||||
`${downloadLabel}下载地址必须是使用标准端口、无凭据和 Fragment 的 HTTPS URL`
|
||||
)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
export function isDownloadRedirectStatus(status: number): boolean {
|
||||
return redirectStatuses.has(status)
|
||||
}
|
||||
|
||||
export async function fetchModelDownloadResponse(options: {
|
||||
transport: typeof fetch
|
||||
initialUrl: string
|
||||
@@ -26,7 +33,10 @@ export async function fetchModelDownloadResponse(options: {
|
||||
signal: AbortSignal
|
||||
modelLabel: string
|
||||
}): Promise<Response> {
|
||||
let url = validateDownloadUrl(options.initialUrl, options.modelLabel)
|
||||
let url = validateSecureDownloadUrl(
|
||||
options.initialUrl,
|
||||
options.modelLabel
|
||||
)
|
||||
const initialHost = url.hostname
|
||||
const allowedRedirectHosts = new Set(options.redirectHosts)
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
@@ -38,7 +48,7 @@ export async function fetchModelDownloadResponse(options: {
|
||||
cache: 'no-store',
|
||||
signal: options.signal
|
||||
})
|
||||
if (!redirectStatuses.has(response.status)) {
|
||||
if (!isDownloadRedirectStatus(response.status)) {
|
||||
return response
|
||||
}
|
||||
if (redirectCount >= MAX_REDIRECTS) {
|
||||
@@ -54,7 +64,7 @@ export async function fetchModelDownloadResponse(options: {
|
||||
`${options.modelLabel}下载重定向缺少地址`
|
||||
)
|
||||
}
|
||||
const nextUrl = validateDownloadUrl(
|
||||
const nextUrl = validateSecureDownloadUrl(
|
||||
new URL(location, url).toString(),
|
||||
options.modelLabel
|
||||
)
|
||||
|
||||
@@ -248,10 +248,37 @@ describe('AgentAttachTransport', () => {
|
||||
await expect(connect(channel)).rejects.toMatchObject({
|
||||
code: 'closed',
|
||||
message:
|
||||
'Agent attach closed during handshake with diagnostic output'
|
||||
'Agent attach closed during handshake with diagnostic output',
|
||||
diagnostic: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies installation integrity stderr without exposing its contents', async () => {
|
||||
const channel = new FakeChannel()
|
||||
channel.onWrite = () => {
|
||||
queueMicrotask(() => {
|
||||
channel.stderr.emit(
|
||||
'data',
|
||||
'Agent manifest uses an unknown signing key: private-key-id\n'
|
||||
)
|
||||
channel.emit('close')
|
||||
})
|
||||
}
|
||||
|
||||
let error: unknown
|
||||
try {
|
||||
await connect(channel)
|
||||
} catch (reason) {
|
||||
error = reason
|
||||
}
|
||||
expect(error).toBeInstanceOf(AgentAttachTransportError)
|
||||
expect(error).toMatchObject({
|
||||
code: 'closed',
|
||||
diagnostic: 'installation-repair-required'
|
||||
})
|
||||
expect((error as Error).message).not.toContain('private-key-id')
|
||||
})
|
||||
|
||||
it('writes every frame in caller FIFO order', async () => {
|
||||
const channel = new FakeChannel()
|
||||
channel.onWrite = () => {
|
||||
|
||||
@@ -523,15 +523,19 @@ async function readWelcome(
|
||||
}
|
||||
const onAbort = (): void => fail(signal.reason)
|
||||
const onError = (error: Error): void => fail(error)
|
||||
const onClose = (): void =>
|
||||
const onClose = (): void => {
|
||||
const diagnostic = handshakeFailureDiagnostic(stderr)
|
||||
fail(
|
||||
new AgentAttachTransportError(
|
||||
stderr.byteLength > 0
|
||||
? 'Agent attach closed during handshake with diagnostic output'
|
||||
: 'Agent attach closed during handshake',
|
||||
'closed'
|
||||
'closed',
|
||||
undefined,
|
||||
diagnostic
|
||||
)
|
||||
)
|
||||
}
|
||||
const onStderr = (chunk: Buffer | string): void => {
|
||||
const bytes = Buffer.from(chunk)
|
||||
if (stderr.byteLength + bytes.byteLength > MAXIMUM_STDERR_BYTES) {
|
||||
@@ -633,6 +637,20 @@ async function readWelcome(
|
||||
})
|
||||
}
|
||||
|
||||
function handshakeFailureDiagnostic(
|
||||
stderr: Uint8Array
|
||||
): string | undefined {
|
||||
const text = Buffer.from(stderr).toString('utf8')
|
||||
if (
|
||||
/(?:unknown signing key|signature verification|manifest .*mismatch|manifest does not match|authorized registry role|payload (?:size|hash|mode) mismatch)/iu.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return 'installation-repair-required'
|
||||
}
|
||||
return protocolFailureDiagnostic(stderr)
|
||||
}
|
||||
|
||||
function encodedFrameLength(frame: AgentFrame): number {
|
||||
return (
|
||||
AGENT_FRAME_FIXED_HEADER_BYTES +
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,756 @@
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
sign
|
||||
} from 'node:crypto'
|
||||
import {
|
||||
mkdir,
|
||||
lstat,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
agentPackageCatalogSchema,
|
||||
type AgentPackageCatalog
|
||||
} from '../../shared/agent-package-contracts'
|
||||
import {
|
||||
AgentPackageManager,
|
||||
type VerifiedRemoteAgentInstallCandidate
|
||||
} from './agent-package-manager'
|
||||
|
||||
vi.mock('./agent-package-verifier', async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
typeof import('./agent-package-verifier')
|
||||
>()
|
||||
const node = Buffer.from('verified-node')
|
||||
const verified = (
|
||||
rootDirectory: string,
|
||||
version = '2.0.0'
|
||||
) => ({
|
||||
rootDirectory,
|
||||
descriptor: {
|
||||
format: 'goodbuddy-agent-package',
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
component: 'agent',
|
||||
version,
|
||||
minimumDesktopVersion: '1.0.0',
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
agentProtocol: { major: 2, minor: 0 },
|
||||
remoteRuntime: {
|
||||
runtimeId: 'opencode',
|
||||
provider: 'opencode',
|
||||
version: '1.2.3',
|
||||
bundleDigest: `sha256:${'a'.repeat(64)}`,
|
||||
protocol: { major: 1, minor: 0 }
|
||||
},
|
||||
contentDigest: `sha256:${'b'.repeat(64)}`,
|
||||
signingKeyId: 'production-test',
|
||||
files: [{
|
||||
path: 'agent/node',
|
||||
size: node.byteLength,
|
||||
sha256: createHash('sha256').update(node).digest('hex'),
|
||||
mode: '0755'
|
||||
}]
|
||||
},
|
||||
agentBundle: {},
|
||||
runtimeBundle: {},
|
||||
runtimeMetadata: {}
|
||||
})
|
||||
return {
|
||||
...actual,
|
||||
extractAndVerifyAgentPackage: vi.fn(
|
||||
async (options: {
|
||||
archivePath: string
|
||||
destinationDirectory: string
|
||||
}) => {
|
||||
const { readFile: readFixture } =
|
||||
await import('node:fs/promises')
|
||||
const archive = await readFixture(options.archivePath)
|
||||
const version = archive.toString('utf8') === '2.1.0'
|
||||
? '2.1.0'
|
||||
: '2.0.0'
|
||||
await mkdir(join(options.destinationDirectory, 'agent'), {
|
||||
recursive: true
|
||||
})
|
||||
await writeFile(
|
||||
join(options.destinationDirectory, 'agent', 'node'),
|
||||
node
|
||||
)
|
||||
await writeFile(
|
||||
join(options.destinationDirectory, '.mock-version'),
|
||||
version
|
||||
)
|
||||
return verified(
|
||||
options.destinationDirectory,
|
||||
version
|
||||
) as never
|
||||
}
|
||||
),
|
||||
verifyExtractedAgentPackage: vi.fn(
|
||||
async (options: { rootDirectory: string }) => {
|
||||
const { readFile: readFixture } =
|
||||
await import('node:fs/promises')
|
||||
const version = (
|
||||
await readFixture(
|
||||
join(options.rootDirectory, '.mock-version'),
|
||||
'utf8'
|
||||
)
|
||||
).trim()
|
||||
return verified(options.rootDirectory, version) as never
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const MIRROR_ROOT =
|
||||
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/'
|
||||
const GITHUB_ROOT =
|
||||
'https://github.com/mesalogo/goodbuddy/releases/download/'
|
||||
const SIGNATURE_DOMAIN = Buffer.from(
|
||||
'GoodBuddy Agent Package Catalog Signature v1\0',
|
||||
'utf8'
|
||||
)
|
||||
const SHA256 = 'a'.repeat(64)
|
||||
const KEY_ID = 'production-test'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
async function createFixture(
|
||||
source: 'mirror' | 'github',
|
||||
mutateCatalog?: (catalog: AgentPackageCatalog) => void,
|
||||
archiveBytes?: Buffer
|
||||
) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-agent-catalog-'))
|
||||
temporaryDirectories.push(root)
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
|
||||
const keyRegistryPath = join(root, 'registry.json')
|
||||
await writeFile(keyRegistryPath, JSON.stringify({
|
||||
formatVersion: 1,
|
||||
keys: [{
|
||||
keyId: KEY_ID,
|
||||
publicKeySpkiBase64: publicKey.export({
|
||||
format: 'der',
|
||||
type: 'spki'
|
||||
}).toString('base64'),
|
||||
environment: 'production'
|
||||
}],
|
||||
revocations: []
|
||||
}))
|
||||
|
||||
const archive = 'goodbuddy-agent-2.0.0-linux-x64.gbagent'
|
||||
const catalog = agentPackageCatalogSchema.parse({
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
component: 'agent',
|
||||
signingKeyId: KEY_ID,
|
||||
generatedAt: '2026-01-01T00:00:00.000Z',
|
||||
entries: [{
|
||||
format: 'goodbuddy-agent-package',
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
component: 'agent',
|
||||
version: '2.0.0',
|
||||
minimumDesktopVersion: '1.0.0',
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
agentProtocol: { major: 2, minor: 0 },
|
||||
remoteRuntime: {
|
||||
runtimeId: 'opencode',
|
||||
provider: 'opencode',
|
||||
version: '1.2.3',
|
||||
bundleDigest: `sha256:${SHA256}`,
|
||||
protocol: { major: 1, minor: 0 }
|
||||
},
|
||||
archive,
|
||||
size: archiveBytes?.byteLength ?? 123,
|
||||
sha256: archiveBytes
|
||||
? createHash('sha256').update(archiveBytes).digest('hex')
|
||||
: SHA256,
|
||||
downloads: {
|
||||
github: {
|
||||
url: `${GITHUB_ROOT}agent-v2.0.0/${archive}`
|
||||
},
|
||||
mirror: {
|
||||
url: `${MIRROR_ROOT}v2.0.0/${archive}`
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
mutateCatalog?.(catalog)
|
||||
const catalogBytes = Buffer.from(
|
||||
`${JSON.stringify(catalog, null, 2)}\n`
|
||||
)
|
||||
const signature = sign(
|
||||
null,
|
||||
Buffer.concat([SIGNATURE_DOMAIN, catalogBytes]),
|
||||
privateKey
|
||||
).toString('base64')
|
||||
const pointer = Buffer.from(`${JSON.stringify({
|
||||
formatVersion: 1,
|
||||
version: '2.0.0',
|
||||
catalog: 'v2.0.0/agent-catalog.json',
|
||||
signature: 'v2.0.0/agent-catalog.sig'
|
||||
}, null, 2)}\n`)
|
||||
const releases = [{
|
||||
tag_name: 'agent-v2.0.0',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: 'agent-catalog.json',
|
||||
browser_download_url:
|
||||
`${GITHUB_ROOT}agent-v2.0.0/agent-catalog.json`
|
||||
},
|
||||
{
|
||||
name: 'agent-catalog.sig',
|
||||
browser_download_url:
|
||||
`${GITHUB_ROOT}agent-v2.0.0/agent-catalog.sig`
|
||||
}
|
||||
]
|
||||
}]
|
||||
|
||||
const manager = (transport: typeof fetch) =>
|
||||
new AgentPackageManager({
|
||||
userDataPath: root,
|
||||
desktopVersion: '9.0.0',
|
||||
keyRegistryPath,
|
||||
getUpdateSource: async () => source,
|
||||
fetch: transport
|
||||
})
|
||||
return {
|
||||
root,
|
||||
archive,
|
||||
catalog,
|
||||
catalogBytes,
|
||||
signature,
|
||||
pointer,
|
||||
releases,
|
||||
archiveBytes,
|
||||
manager
|
||||
}
|
||||
}
|
||||
|
||||
function response(
|
||||
body: string | Uint8Array | null,
|
||||
init: ResponseInit = {}
|
||||
): Response {
|
||||
return new Response(body as never, init)
|
||||
}
|
||||
|
||||
describe('AgentPackageManager remote install candidates', () => {
|
||||
it('removes interrupted manager-owned staging on startup', async () => {
|
||||
const fixture = await createFixture('mirror')
|
||||
const packageRoot = join(
|
||||
fixture.root,
|
||||
'remote-components',
|
||||
'agent-packages'
|
||||
)
|
||||
const stage = join(
|
||||
packageRoot,
|
||||
'.stage-linux-x64-00000000-0000-4000-8000-000000000001'
|
||||
)
|
||||
const backup = join(
|
||||
packageRoot,
|
||||
'linux-x64',
|
||||
'.backup-00000000-0000-4000-8000-000000000002'
|
||||
)
|
||||
const unrelated = join(packageRoot, '.keep')
|
||||
await Promise.all([
|
||||
mkdir(stage, { recursive: true }),
|
||||
mkdir(backup, { recursive: true }),
|
||||
mkdir(unrelated, { recursive: true })
|
||||
])
|
||||
|
||||
await fixture.manager(vi.fn<typeof fetch>()).getInventory()
|
||||
|
||||
await expect(lstat(stage)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(lstat(backup)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(lstat(unrelated)).resolves.toMatchObject({})
|
||||
})
|
||||
|
||||
it('downloads, verifies, publishes, and leases an exact install archive', async () => {
|
||||
const archiveBytes = Buffer.alloc(3 * 64 * 1024 + 11, 0x5a)
|
||||
const fixture = await createFixture(
|
||||
'mirror',
|
||||
undefined,
|
||||
archiveBytes
|
||||
)
|
||||
let archiveRequests = 0
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = input.toString()
|
||||
if (url.endsWith('latest.json')) {
|
||||
return response(fixture.pointer)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.json')) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.sig')) {
|
||||
return response(fixture.signature)
|
||||
}
|
||||
if (url.endsWith(fixture.archive)) {
|
||||
archiveRequests += 1
|
||||
return response(archiveBytes, {
|
||||
headers: {
|
||||
'content-length': String(archiveBytes.byteLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`)
|
||||
})
|
||||
const entry = fixture.catalog.entries[0]!
|
||||
const expectedCandidate: VerifiedRemoteAgentInstallCandidate = {
|
||||
source: 'mirror',
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
version: entry.version,
|
||||
minimumDesktopVersion: entry.minimumDesktopVersion,
|
||||
agentProtocol: { ...entry.agentProtocol },
|
||||
remoteRuntime: {
|
||||
...entry.remoteRuntime,
|
||||
protocol: { ...entry.remoteRuntime.protocol }
|
||||
},
|
||||
archive: entry.archive,
|
||||
size: entry.size,
|
||||
sha256: entry.sha256,
|
||||
urls: [entry.downloads.mirror.url]
|
||||
}
|
||||
|
||||
const manager = fixture.manager(transport)
|
||||
const lease = await manager.acquireInstallArchive(
|
||||
'x64',
|
||||
expectedCandidate
|
||||
)
|
||||
expect(lease).toMatchObject({
|
||||
size: archiveBytes.byteLength,
|
||||
sha256: entry.sha256,
|
||||
nodeSize: Buffer.byteLength('verified-node'),
|
||||
nodeSha256: createHash('sha256')
|
||||
.update('verified-node')
|
||||
.digest('hex')
|
||||
})
|
||||
await expect(readFile(lease.path)).resolves.toEqual(archiveBytes)
|
||||
await expect(readFile(lease.nodePath)).resolves.toEqual(
|
||||
Buffer.from('verified-node')
|
||||
)
|
||||
expect(archiveRequests).toBe(1)
|
||||
|
||||
const cachedLease = await manager.acquireInstallArchive(
|
||||
'x64',
|
||||
expectedCandidate
|
||||
)
|
||||
expect(cachedLease.path).toBe(lease.path)
|
||||
expect(archiveRequests).toBe(1)
|
||||
|
||||
const importDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-agent-import-')
|
||||
)
|
||||
temporaryDirectories.push(importDirectory)
|
||||
const replacementArchive = join(
|
||||
importDirectory,
|
||||
'goodbuddy-agent-2.1.0-linux-x64.gbagent'
|
||||
)
|
||||
await writeFile(replacementArchive, '2.1.0')
|
||||
await manager.importArchive(replacementArchive)
|
||||
await expect(readFile(lease.path)).resolves.toEqual(archiveBytes)
|
||||
|
||||
cachedLease.release()
|
||||
cachedLease.release()
|
||||
await expect(readFile(lease.path)).resolves.toEqual(archiveBytes)
|
||||
lease.release()
|
||||
lease.release()
|
||||
await vi.waitFor(async () => {
|
||||
await expect(readFile(lease.path)).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a stale expected candidate before downloading an archive', async () => {
|
||||
const archiveBytes = Buffer.from('package')
|
||||
const fixture = await createFixture(
|
||||
'mirror',
|
||||
undefined,
|
||||
archiveBytes
|
||||
)
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = input.toString()
|
||||
if (url.endsWith('latest.json')) {
|
||||
return response(fixture.pointer)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.json')) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.sig')) {
|
||||
return response(fixture.signature)
|
||||
}
|
||||
throw new Error(`Archive must not be requested: ${url}`)
|
||||
})
|
||||
const entry = fixture.catalog.entries[0]!
|
||||
|
||||
await expect(
|
||||
fixture.manager(transport).acquireInstallArchive(
|
||||
'x64',
|
||||
{
|
||||
source: 'mirror',
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
version: entry.version,
|
||||
minimumDesktopVersion: entry.minimumDesktopVersion,
|
||||
agentProtocol: { ...entry.agentProtocol },
|
||||
remoteRuntime: {
|
||||
...entry.remoteRuntime,
|
||||
protocol: { ...entry.remoteRuntime.protocol }
|
||||
},
|
||||
archive: entry.archive,
|
||||
size: entry.size + 1,
|
||||
sha256: entry.sha256,
|
||||
urls: [entry.downloads.mirror.url]
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('current signed catalog')
|
||||
expect(
|
||||
transport.mock.calls.some(([input]) =>
|
||||
input.toString().endsWith(fixture.archive)
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('leases an imported or cached package without an online catalog', async () => {
|
||||
const archiveBytes = Buffer.from('offline-package')
|
||||
const fixture = await createFixture(
|
||||
'mirror',
|
||||
undefined,
|
||||
archiveBytes
|
||||
)
|
||||
const online = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = input.toString()
|
||||
if (url.endsWith('latest.json')) {
|
||||
return response(fixture.pointer)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.json')) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.sig')) {
|
||||
return response(fixture.signature)
|
||||
}
|
||||
if (url.endsWith(fixture.archive)) {
|
||||
return response(archiveBytes, {
|
||||
headers: {
|
||||
'content-length': String(archiveBytes.byteLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`)
|
||||
})
|
||||
const entry = fixture.catalog.entries[0]!
|
||||
const installed = await fixture.manager(online)
|
||||
.acquireInstallArchive('x64', {
|
||||
source: 'mirror',
|
||||
platform: 'linux',
|
||||
architecture: entry.architecture,
|
||||
version: entry.version,
|
||||
minimumDesktopVersion: entry.minimumDesktopVersion,
|
||||
agentProtocol: { ...entry.agentProtocol },
|
||||
remoteRuntime: {
|
||||
...entry.remoteRuntime,
|
||||
protocol: { ...entry.remoteRuntime.protocol }
|
||||
},
|
||||
archive: entry.archive,
|
||||
size: entry.size,
|
||||
sha256: entry.sha256,
|
||||
urls: [entry.downloads.mirror.url]
|
||||
})
|
||||
installed.release()
|
||||
|
||||
const offline = vi.fn<typeof fetch>(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
const lease = await fixture.manager(offline)
|
||||
.acquireGoodBuddyInstallArchive('x64')
|
||||
|
||||
expect(offline).not.toHaveBeenCalled()
|
||||
expect(lease.candidate).toMatchObject({
|
||||
architecture: 'x64',
|
||||
version: entry.version,
|
||||
sha256: entry.sha256,
|
||||
urls: []
|
||||
})
|
||||
await expect(readFile(lease.path)).resolves.toEqual(archiveBytes)
|
||||
lease.release()
|
||||
})
|
||||
|
||||
it('propagates cancellation into an active archive download', async () => {
|
||||
const archiveBytes = Buffer.from('package')
|
||||
const fixture = await createFixture(
|
||||
'mirror',
|
||||
undefined,
|
||||
archiveBytes
|
||||
)
|
||||
const transport = vi.fn<typeof fetch>(async (input, init) => {
|
||||
const url = input.toString()
|
||||
if (url.endsWith('latest.json')) {
|
||||
return response(fixture.pointer)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.json')) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.sig')) {
|
||||
return response(fixture.signature)
|
||||
}
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(init.signal?.reason),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const download = fixture.manager(transport).download(
|
||||
'x64',
|
||||
undefined,
|
||||
controller.signal
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
transport.mock.calls.some(([input]) =>
|
||||
input.toString().endsWith(fixture.archive)
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
|
||||
controller.abort()
|
||||
await expect(download).rejects.toMatchObject({
|
||||
name: 'AbortError'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a signed, compatible mirror candidate without a local package', async () => {
|
||||
const fixture = await createFixture('mirror')
|
||||
const transport = vi.fn<typeof fetch>(async (input, init) => {
|
||||
const url = input.toString()
|
||||
if (url === `${MIRROR_ROOT}latest.json`) {
|
||||
return response(fixture.pointer)
|
||||
}
|
||||
if (url === `${MIRROR_ROOT}v2.0.0/agent-catalog.json`) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
if (url === `${MIRROR_ROOT}v2.0.0/agent-catalog.sig`) {
|
||||
return response(fixture.signature)
|
||||
}
|
||||
expect(init?.method).toBe('HEAD')
|
||||
return response(null, {
|
||||
status: 200,
|
||||
headers: { 'content-length': '123' }
|
||||
})
|
||||
})
|
||||
|
||||
const manager = fixture.manager(transport)
|
||||
await expect(
|
||||
manager.getRemoteEnvironmentCatalog('x64')
|
||||
).resolves.toMatchObject({
|
||||
expected: {
|
||||
agent: { version: '2.0.0' },
|
||||
runtimes: [{
|
||||
runtimeId: 'opencode',
|
||||
provider: 'opencode',
|
||||
version: '1.2.3'
|
||||
}]
|
||||
},
|
||||
candidate: {
|
||||
source: 'mirror',
|
||||
architecture: 'x64',
|
||||
version: '2.0.0',
|
||||
minimumDesktopVersion: '1.0.0',
|
||||
agentProtocol: { major: 2, minor: 0 },
|
||||
remoteRuntime: { version: '1.2.3' },
|
||||
size: 123,
|
||||
sha256: SHA256,
|
||||
urls: [`${MIRROR_ROOT}v2.0.0/${fixture.archive}`]
|
||||
}
|
||||
})
|
||||
expect(
|
||||
transport.mock.calls.filter(([input]) =>
|
||||
input.toString().endsWith('/agent-catalog.json')
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects a catalog whose signature does not match its canonical bytes', async () => {
|
||||
const fixture = await createFixture('mirror')
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = input.toString()
|
||||
if (url.endsWith('latest.json')) {
|
||||
return response(fixture.pointer)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.json')) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
return response(Buffer.from('not-a-signature'))
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager(transport).getRemoteInstallCandidate('x64')
|
||||
).rejects.toThrow('签名校验失败')
|
||||
})
|
||||
|
||||
it('rejects mirror redirects even when they stay on the mirror host', async () => {
|
||||
const fixture = await createFixture('mirror')
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = input.toString()
|
||||
if (url.endsWith('latest.json')) {
|
||||
return response(fixture.pointer)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.json')) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.sig')) {
|
||||
return response(fixture.signature)
|
||||
}
|
||||
return response(null, {
|
||||
status: 302,
|
||||
headers: { location: `${url}?redirected=1` }
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager(transport).getRemoteInstallCandidate('x64')
|
||||
).rejects.toThrow('镜像下载地址不允许重定向')
|
||||
})
|
||||
|
||||
it('preserves a candidate source probe failure separately from package absence', async () => {
|
||||
const fixture = await createFixture('mirror')
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = input.toString()
|
||||
if (url.endsWith('latest.json')) {
|
||||
return response(fixture.pointer)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.json')) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.sig')) {
|
||||
return response(fixture.signature)
|
||||
}
|
||||
return response(null, { status: 503 })
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager(transport).getRemoteEnvironmentCatalog('x64')
|
||||
).resolves.toMatchObject({
|
||||
candidate: null,
|
||||
candidateFailure: {
|
||||
reason: 'probe-failed',
|
||||
source: 'mirror',
|
||||
packageSize: 123
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('records only the canonical GitHub URL and approved asset redirects', async () => {
|
||||
const fixture = await createFixture('github')
|
||||
const assetUrl =
|
||||
`https://release-assets.githubusercontent.com/download/${fixture.archive}`
|
||||
const transport = vi.fn<typeof fetch>(async (input, init) => {
|
||||
const url = input.toString()
|
||||
if (url.startsWith('https://api.github.com/')) {
|
||||
return response(JSON.stringify(fixture.releases))
|
||||
}
|
||||
if (url.endsWith('agent-catalog.json')) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.sig')) {
|
||||
return response(fixture.signature)
|
||||
}
|
||||
if (url.startsWith(GITHUB_ROOT)) {
|
||||
expect(init?.method).toBe('HEAD')
|
||||
return response(null, {
|
||||
status: 302,
|
||||
headers: { location: assetUrl }
|
||||
})
|
||||
}
|
||||
if (url === assetUrl) {
|
||||
return response(null, {
|
||||
status: 200,
|
||||
headers: { 'content-length': '123' }
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`)
|
||||
})
|
||||
|
||||
const candidate = await fixture.manager(transport)
|
||||
.getRemoteInstallCandidate('x64')
|
||||
expect(candidate.urls).toEqual([
|
||||
`${GITHUB_ROOT}agent-v2.0.0/${fixture.archive}`,
|
||||
assetUrl
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects GitHub redirects to unapproved hosts', async () => {
|
||||
const fixture = await createFixture('github')
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = input.toString()
|
||||
if (url.startsWith('https://api.github.com/')) {
|
||||
return response(JSON.stringify(fixture.releases))
|
||||
}
|
||||
if (url.endsWith('agent-catalog.json')) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.sig')) {
|
||||
return response(fixture.signature)
|
||||
}
|
||||
return response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'https://example.com/package.gbagent' }
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager(transport).getRemoteInstallCandidate('x64')
|
||||
).rejects.toThrow('不受信任的主机')
|
||||
})
|
||||
|
||||
it('returns and probes the highest compatible signed entry', async () => {
|
||||
const fixture = await createFixture('mirror')
|
||||
const transport = vi.fn<typeof fetch>(async (input, init) => {
|
||||
const url = input.toString()
|
||||
if (url.endsWith('latest.json')) {
|
||||
return response(fixture.pointer)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.json')) {
|
||||
return response(fixture.catalogBytes)
|
||||
}
|
||||
if (url.endsWith('agent-catalog.sig')) {
|
||||
return response(fixture.signature)
|
||||
}
|
||||
expect(init?.method).toBe('HEAD')
|
||||
return response(null, {
|
||||
status: 200,
|
||||
headers: { 'content-length': '123' }
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager(transport).getRemoteInstallCandidate('x64')
|
||||
).resolves.toMatchObject({
|
||||
source: 'mirror',
|
||||
architecture: 'x64',
|
||||
version: '2.0.0',
|
||||
size: 123,
|
||||
sha256: SHA256,
|
||||
urls: [`${MIRROR_ROOT}v2.0.0/${fixture.archive}`]
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,20 +45,14 @@ import {
|
||||
parseAgentReleaseKeyRegistry,
|
||||
parseAgentReleaseKeyRegistryBytes,
|
||||
verifyAgentBundleDirectory,
|
||||
type AgentVerificationEnvironment,
|
||||
type VerifiedAgentBundle
|
||||
type AgentVerificationEnvironment
|
||||
} from './agent-bundle-verifier'
|
||||
import {
|
||||
loadVerifiedRemoteRuntimeResourceBundle,
|
||||
loadRemoteRuntimeVerificationMetadata,
|
||||
type RemoteRuntimeVerificationMetadata
|
||||
loadVerifiedRemoteRuntimeResourceBundle
|
||||
} from './remote-runtime-resource-loader'
|
||||
import type {
|
||||
BundledRemoteRuntimeResourcePaths
|
||||
} from './bundled-remote-runtime-resources'
|
||||
import type {
|
||||
VerifiedRemoteRuntimeInstallationBundle
|
||||
} from './remote-runtime-installation-manager'
|
||||
|
||||
const DESCRIPTOR_NAME = 'agent-package.json'
|
||||
const SIGNATURE_NAME = 'agent-package.sig'
|
||||
@@ -76,9 +70,6 @@ const SIGNATURE_DOMAIN = Buffer.from(
|
||||
export type VerifiedAgentPackage = {
|
||||
rootDirectory: string
|
||||
descriptor: AgentPackageDescriptor
|
||||
agentBundle: VerifiedAgentBundle
|
||||
runtimeBundle: VerifiedRemoteRuntimeInstallationBundle
|
||||
runtimeMetadata: RemoteRuntimeVerificationMetadata
|
||||
}
|
||||
|
||||
export async function extractAndVerifyAgentPackage(options: {
|
||||
@@ -239,8 +230,6 @@ export async function verifyExtractedAgentPackage(options: {
|
||||
options.verificationEnvironment ?? 'production'
|
||||
}
|
||||
)
|
||||
const runtimeMetadata =
|
||||
await loadRemoteRuntimeVerificationMetadata(runtimePaths)
|
||||
if (
|
||||
runtimeBundle.manifest.runtimeVersion !==
|
||||
descriptor.remoteRuntime.version ||
|
||||
@@ -258,9 +247,6 @@ export async function verifyExtractedAgentPackage(options: {
|
||||
return {
|
||||
rootDirectory,
|
||||
descriptor,
|
||||
agentBundle,
|
||||
runtimeBundle,
|
||||
runtimeMetadata
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getBundledAgentDirectory,
|
||||
resolveBundledAgentResourcePaths
|
||||
resolveBundledAgentResourcePaths,
|
||||
resolveControlPlanePackageInstallerPath
|
||||
} from './bundled-agent-resources'
|
||||
|
||||
describe('bundled Agent resource paths', () => {
|
||||
@@ -55,6 +56,24 @@ describe('bundled Agent resource paths', () => {
|
||||
expect(paths.runtimeLockPath).toBe(
|
||||
join('installed', 'resources', 'agent-runtime-lock.json')
|
||||
)
|
||||
expect(
|
||||
resolveControlPlanePackageInstallerPath({
|
||||
appPath: join(
|
||||
'installed',
|
||||
'resources',
|
||||
'app.asar'
|
||||
)
|
||||
})
|
||||
).toBe(
|
||||
join(
|
||||
'installed',
|
||||
'resources',
|
||||
'app.asar',
|
||||
'out',
|
||||
'main',
|
||||
'remote-package-installer.mjs'
|
||||
)
|
||||
)
|
||||
expect(getBundledAgentDirectory(paths, 'x64')).toBe(
|
||||
join('installed', 'resources', 'agents', 'linux-x64')
|
||||
)
|
||||
|
||||
@@ -35,6 +35,17 @@ export function resolveBundledAgentResourcePaths(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveControlPlanePackageInstallerPath(
|
||||
environment: Pick<BundledAgentResourceEnvironment, 'appPath'>
|
||||
): string {
|
||||
return join(
|
||||
environment.appPath,
|
||||
'out',
|
||||
'main',
|
||||
'remote-package-installer.mjs'
|
||||
)
|
||||
}
|
||||
|
||||
export function getBundledAgentDirectory(
|
||||
paths: BundledAgentResourcePaths,
|
||||
architecture: AgentArchitecture
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { runPackageInstallerCli } from './control-plane-package-installer'
|
||||
|
||||
process.exitCode = runPackageInstallerCli()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,6 @@ export {
|
||||
ProtocolRemoteWorkspaceTransportError
|
||||
} from './protocol-remote-workspace-transport'
|
||||
export type {
|
||||
DurableRemoteWorkspaceValidation,
|
||||
RemoteWorkspaceInstallationIdentityResolver,
|
||||
RemoteWorkspaceInstallationResolution
|
||||
} from './protocol-remote-workspace-transport'
|
||||
@@ -75,10 +74,9 @@ export {
|
||||
AgentInstallationManager
|
||||
} from './agent-installation-manager'
|
||||
export type {
|
||||
AgentInstallationBundleLoader,
|
||||
AgentInstallationIdentity,
|
||||
AgentInstallationPhase,
|
||||
AgentInstallationRequestOptions,
|
||||
AgentActivationRequestOptions,
|
||||
AgentInstallationTargetResolver
|
||||
} from './agent-installation-manager'
|
||||
export { RemoteAgentServices } from './remote-agent-services'
|
||||
@@ -90,7 +88,6 @@ export {
|
||||
export type {
|
||||
RemoteProjectSaveOwner,
|
||||
RemoteProjectSaveServiceOptions,
|
||||
RemoteProjectRuntimeValidationEvidence,
|
||||
RemoteProjectRuntimeValidationInput,
|
||||
RemoteProjectRuntimeValidationLease,
|
||||
RemoteProjectRuntimeValidator
|
||||
|
||||
@@ -86,26 +86,6 @@ function descriptor(): SshExecutionSpaceDescriptor {
|
||||
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: {
|
||||
@@ -127,10 +107,25 @@ function harness(
|
||||
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 workspaceValidate = vi.fn(async () => ({
|
||||
handle: {
|
||||
workspaceId: 'workspace-id',
|
||||
workspaceIdentity: 'workspace-1',
|
||||
canonicalDisplayPath: '/srv/project',
|
||||
access: 'read-only' as const,
|
||||
git: 'available' as const,
|
||||
capabilities: ['list', 'stat', 'read-text', 'search'],
|
||||
generation: 1
|
||||
},
|
||||
validatedAt: '2030-01-01T00:00:00.000Z'
|
||||
}))
|
||||
const workspaceClose = vi.fn(async (params: {
|
||||
workspaceId: string
|
||||
generation: number
|
||||
}) => ({
|
||||
...params,
|
||||
closed: true as const
|
||||
}))
|
||||
const connection = {
|
||||
identity: {
|
||||
cacheKey: 'connection-cache',
|
||||
@@ -156,7 +151,23 @@ function harness(
|
||||
supervisor: 'detached-on-demand'
|
||||
},
|
||||
capabilities: capabilities(),
|
||||
client: { generation: 5 },
|
||||
client: {
|
||||
generation: 5,
|
||||
request: vi.fn(async (method: string, params: unknown) => {
|
||||
if (method === 'workspace/validate') {
|
||||
return await workspaceValidate()
|
||||
}
|
||||
if (method === 'workspace/close') {
|
||||
return await workspaceClose(
|
||||
params as {
|
||||
workspaceId: string
|
||||
generation: number
|
||||
}
|
||||
)
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`)
|
||||
})
|
||||
},
|
||||
state: 'ready',
|
||||
refreshCapabilities: vi.fn(async () => capabilities()),
|
||||
reconnect: vi.fn(async () => undefined),
|
||||
@@ -166,62 +177,36 @@ function harness(
|
||||
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')
|
||||
},
|
||||
installationManager: {
|
||||
activateInstalled: vi.fn(async () => ({
|
||||
installationId: 'agent-1',
|
||||
binaryDigest: digest('a'),
|
||||
agentVersion: '1.0.0',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
supervisor: 'detached-on-demand'
|
||||
}))
|
||||
}
|
||||
},
|
||||
runtimeInstallationManager: {
|
||||
ensureInstalled: vi.fn(async () => ({
|
||||
activateInstalled: vi.fn(async () => ({
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: '1.18.9',
|
||||
bundleDigest: digest('b'),
|
||||
manifestDigest: digest('e'),
|
||||
manifestDigest: digest('d'),
|
||||
runtimeAdapterDigest: digest('c'),
|
||||
acpCapabilitiesDigest: digest('f'),
|
||||
platform: 'linux' as const,
|
||||
architecture: 'x64' as const
|
||||
platform: 'linux',
|
||||
architecture: 'x64'
|
||||
}))
|
||||
},
|
||||
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: {
|
||||
@@ -243,7 +228,8 @@ function harness(
|
||||
options,
|
||||
connection,
|
||||
releaseConnection,
|
||||
workspaceAccesses
|
||||
workspaceValidate,
|
||||
workspaceClose
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,19 +260,13 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
|
||||
await expect(
|
||||
createManagedRemoteAcpRuntime(fixture.options)
|
||||
).rejects.toThrow(/validated OpenCode project evidence/iu)
|
||||
expect(
|
||||
fixture.options.agentServices.installationManager.ensureInstalled
|
||||
).not.toHaveBeenCalled()
|
||||
expect(
|
||||
fixture.options.runtimeInstallationManager.ensureInstalled
|
||||
).not.toHaveBeenCalled()
|
||||
).rejects.toThrow(/OpenCode Runtime/iu)
|
||||
expect(
|
||||
fixture.options.agentServices.connectionManager.acquire
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('binds exact persisted evidence and owns its trust and connection leases', async () => {
|
||||
it('binds the current Agent and Runtime and owns its connection lease', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
fixture.options
|
||||
@@ -296,9 +276,15 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
id: 'opencode',
|
||||
available: true
|
||||
})
|
||||
expect(fixture.workspaceValidate).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
fixture.options.workspaceAccessFactory.create
|
||||
).not.toHaveBeenCalled()
|
||||
fixture.connection.refreshCapabilities
|
||||
).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
fixture.options.runtimeInstallationManager.activateInstalled
|
||||
).toHaveBeenCalledWith('host-1', {
|
||||
agentInstallationId: 'agent-1'
|
||||
})
|
||||
expect(
|
||||
fixture.options.agentServices.connectionManager.acquire
|
||||
).toHaveBeenCalledWith('host-1', expect.objectContaining({
|
||||
@@ -317,6 +303,23 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the current Host-management transport identity after Host edits', async () => {
|
||||
const fixture = harness()
|
||||
fixture.connection.identity.hostRevision = 9
|
||||
fixture.connection.identity.hostKeyGeneration = 10
|
||||
fixture.connection.identity.remoteUsername = 'current-user'
|
||||
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
fixture.options
|
||||
)
|
||||
|
||||
expect(remoteHarness.instances[0]!.options.identity).toMatchObject({
|
||||
hostRevision: 9,
|
||||
hostKeyGeneration: 10
|
||||
})
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('recreates the Runtime and reopens the Workspace after reconnect identity changes', async () => {
|
||||
const fixture = harness()
|
||||
const runtime = await createManagedRemoteAcpRuntime(
|
||||
@@ -329,7 +332,7 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
controllerGeneration: 5,
|
||||
daemonBootIdAtOpen: 'boot-1'
|
||||
})
|
||||
expect(fixture.workspaceAccesses).toHaveLength(1)
|
||||
expect(fixture.workspaceValidate).toHaveBeenCalledTimes(1)
|
||||
|
||||
fixture.connection.state = 'offline'
|
||||
vi.mocked(fixture.connection.reconnect).mockImplementationOnce(async () => {
|
||||
@@ -347,13 +350,11 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
controllerGeneration: 6,
|
||||
daemonBootIdAtOpen: 'boot-2'
|
||||
})
|
||||
expect(fixture.workspaceAccesses).toHaveLength(2)
|
||||
expect(
|
||||
fixture.workspaceAccesses[0]!.dispose
|
||||
).toHaveBeenCalledOnce()
|
||||
expect(fixture.workspaceValidate).toHaveBeenCalledTimes(2)
|
||||
expect(fixture.workspaceClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
await runtime.dispose()
|
||||
expect(fixture.workspaceAccesses[1]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(fixture.workspaceClose).toHaveBeenCalledTimes(2)
|
||||
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -371,7 +372,7 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
await expect(staleFactory('binding-1')).rejects.toThrow(
|
||||
/transport lease is stale/iu
|
||||
)
|
||||
expect(fixture.workspaceAccesses).toHaveLength(1)
|
||||
expect(fixture.workspaceValidate).toHaveBeenCalledTimes(2)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -390,7 +391,7 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
controllerGeneration: 5,
|
||||
daemonBootIdAtOpen: 'boot-2'
|
||||
})
|
||||
expect(fixture.workspaceAccesses).toHaveLength(2)
|
||||
expect(fixture.workspaceValidate).toHaveBeenCalledTimes(2)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -428,27 +429,24 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('releases exact trust without connecting when Runtime evidence changed', async () => {
|
||||
it('rejects capabilities that differ from the current Runtime', async () => {
|
||||
const fixture = harness()
|
||||
vi.mocked(
|
||||
fixture.options.runtimeInstallationManager.ensureInstalled
|
||||
fixture.connection.refreshCapabilities
|
||||
).mockResolvedValueOnce({
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: '1.18.9',
|
||||
bundleDigest: digest('9'),
|
||||
manifestDigest: digest('e'),
|
||||
runtimeAdapterDigest: digest('c'),
|
||||
acpCapabilitiesDigest: digest('f'),
|
||||
platform: 'linux',
|
||||
architecture: 'x64'
|
||||
...capabilities(),
|
||||
runtimes: [
|
||||
{
|
||||
...capabilities().runtimes[0]!,
|
||||
bundleDigest: digest('9')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await expect(
|
||||
createManagedRemoteAcpRuntime(fixture.options)
|
||||
).rejects.toThrow(/does not match project validation/iu)
|
||||
expect(
|
||||
fixture.options.agentServices.connectionManager.acquire
|
||||
).not.toHaveBeenCalled()
|
||||
).rejects.toThrow(/current OpenCode Runtime/iu)
|
||||
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('releases both leases when the live Agent identity is stale', async () => {
|
||||
@@ -457,7 +455,7 @@ describe('createManagedRemoteAcpRuntime', () => {
|
||||
|
||||
await expect(
|
||||
createManagedRemoteAcpRuntime(fixture.options)
|
||||
).rejects.toThrow(/connection does not match/iu)
|
||||
).rejects.toThrow(/connection is not current/iu)
|
||||
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
|
||||
@@ -12,11 +12,20 @@ import type {
|
||||
import type { RuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
|
||||
import type { SshExecutionSpaceDescriptor } from '../execution-space'
|
||||
import type { RemoteWorkspaceProjectBinding } from '../workspace'
|
||||
import {
|
||||
REMOTE_WORKSPACE_READ_CAPABILITIES,
|
||||
remoteWorkspaceCloseResultSchema,
|
||||
remoteWorkspaceValidateResultSchema,
|
||||
type RemoteWorkspaceHandle
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
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'
|
||||
import type { AgentInstallationIdentity } from './agent-installation-manager'
|
||||
import type {
|
||||
RemoteRuntimeInstallationIdentity,
|
||||
RemoteRuntimeInstallationManager
|
||||
} from './remote-runtime-installation-manager'
|
||||
|
||||
const RUNTIME_ACP_CAPABILITY = {
|
||||
name: 'runtime/acp',
|
||||
@@ -34,20 +43,26 @@ type ManagedRemoteTransportIdentity = {
|
||||
daemonBootId: string
|
||||
}
|
||||
|
||||
type ManagedRemoteRuntimeIdentity = {
|
||||
runtimeId: 'opencode'
|
||||
runtimeVersion: string
|
||||
bundleDigest: string
|
||||
runtimeAdapterDigest: string
|
||||
acpCapabilitiesDigest: string
|
||||
platform: 'linux'
|
||||
architecture: 'x64' | 'arm64'
|
||||
}
|
||||
|
||||
export type ManagedRemoteAcpRuntimeOptions = {
|
||||
executionSpace: SshExecutionSpaceDescriptor
|
||||
selection: AgentRuntimeSelection
|
||||
agentServices: Pick<
|
||||
RemoteAgentServices,
|
||||
'installationManager' | 'connectionManager' | 'controllerState'
|
||||
'connectionManager' | 'controllerState' | 'installationManager'
|
||||
>
|
||||
runtimeInstallationManager: Pick<
|
||||
RemoteRuntimeInstallationManager,
|
||||
'ensureInstalled'
|
||||
>
|
||||
workspaceAccessFactory: Pick<
|
||||
ManagedRemoteWorkspaceAccessFactory,
|
||||
'create'
|
||||
'activateInstalled'
|
||||
>
|
||||
bindingStore: RuntimeSessionBindingStore
|
||||
modelBridge: ManagedModelBridge
|
||||
@@ -58,7 +73,7 @@ export type ManagedRemoteAcpRuntimeOptions = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires every production lease needed by one validated remote Runtime.
|
||||
* Acquires every production lease needed by the Host's current Runtime.
|
||||
* The returned Runtime owns those leases and releases them only after all ACP
|
||||
* conversation channels have been closed.
|
||||
*/
|
||||
@@ -66,15 +81,9 @@ 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
|
||||
) {
|
||||
if (options.selection.provider !== 'opencode') {
|
||||
throw new Error(
|
||||
'Managed remote execution requires validated OpenCode project evidence'
|
||||
'Managed remote execution requires the OpenCode Runtime'
|
||||
)
|
||||
}
|
||||
const workspaceBinding = workspaceProjectBinding(descriptor)
|
||||
@@ -88,17 +97,9 @@ export async function createManagedRemoteAcpRuntime(
|
||||
let retained = false
|
||||
try {
|
||||
const agent =
|
||||
await options.agentServices.installationManager.ensureInstalled(
|
||||
await options.agentServices.installationManager.activateInstalled(
|
||||
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,
|
||||
@@ -111,39 +112,29 @@ export async function createManagedRemoteAcpRuntime(
|
||||
}
|
||||
)
|
||||
connection = activeConnection
|
||||
assertConnectionMatches(workspaceBinding, activeConnection)
|
||||
const initialTransport = {
|
||||
controllerGeneration: activeConnection.client.generation,
|
||||
daemonBootId: activeConnection.status.daemonBootId
|
||||
}
|
||||
assertConnectionMatches(workspaceBinding, agent, activeConnection)
|
||||
const runtimeInstallation =
|
||||
await options.runtimeInstallationManager.activateInstalled(
|
||||
descriptor.hostId,
|
||||
{ agentInstallationId: agent.installationId }
|
||||
)
|
||||
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'
|
||||
assertConnectionMatches(workspaceBinding, agent, activeConnection)
|
||||
const runtime = runtimeIdentityFromCapabilities(
|
||||
runtimeInstallation,
|
||||
capabilities
|
||||
)
|
||||
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,
|
||||
agent,
|
||||
activeConnection,
|
||||
runtime,
|
||||
options.modelBridge
|
||||
)
|
||||
const initialTransport =
|
||||
await resources.prepareTransport(capabilities)
|
||||
|
||||
const createRemote = (
|
||||
transport: ManagedRemoteTransportIdentity
|
||||
@@ -156,15 +147,14 @@ export async function createManagedRemoteAcpRuntime(
|
||||
controllerId,
|
||||
controllerGeneration: transport.controllerGeneration,
|
||||
hostId: workspaceBinding.hostId,
|
||||
hostRevision: workspaceBinding.hostRevision,
|
||||
hostKeyGeneration: workspaceBinding.hostKeyGeneration,
|
||||
workspaceIdentity: workspaceBinding.workspaceIdentity,
|
||||
agentInstallationId: workspaceBinding.agentInstallationId,
|
||||
hostRevision: activeConnection.identity.hostRevision,
|
||||
hostKeyGeneration:
|
||||
activeConnection.identity.hostKeyGeneration,
|
||||
workspaceIdentity: resources.workspaceIdentity,
|
||||
agentInstallationId: agent.installationId,
|
||||
daemonBootIdAtOpen: transport.daemonBootId,
|
||||
runtimeBundleDigest:
|
||||
runtimeValidation.runtimeBundleDigest,
|
||||
runtimeAdapterDigest:
|
||||
runtimeValidation.runtimeAdapterDigest,
|
||||
runtimeBundleDigest: runtime.bundleDigest,
|
||||
runtimeAdapterDigest: runtime.runtimeAdapterDigest
|
||||
},
|
||||
channelFactory: async (bindingId) =>
|
||||
resources.openChannel(transport, bindingId),
|
||||
@@ -174,11 +164,12 @@ export async function createManagedRemoteAcpRuntime(
|
||||
if (
|
||||
identity.controllerId !== controllerId ||
|
||||
identity.hostId !== workspaceBinding.hostId ||
|
||||
identity.hostRevision !== workspaceBinding.hostRevision ||
|
||||
identity.hostRevision !==
|
||||
activeConnection.identity.hostRevision ||
|
||||
identity.hostKeyGeneration !==
|
||||
workspaceBinding.hostKeyGeneration ||
|
||||
activeConnection.identity.hostKeyGeneration ||
|
||||
identity.agentInstallationId !==
|
||||
workspaceBinding.agentInstallationId
|
||||
agent.installationId
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Runtime Host identity no longer matches its project'
|
||||
@@ -217,16 +208,14 @@ class ManagedRemoteRuntimeResources {
|
||||
#workspace:
|
||||
| {
|
||||
transport: ManagedRemoteTransportIdentity
|
||||
access: ReturnType<ManagedRemoteWorkspaceAccessFactory['create']>
|
||||
handle: RemoteWorkspaceHandle
|
||||
}
|
||||
| undefined
|
||||
#workspaceTail: Promise<void> = Promise.resolve()
|
||||
#disposePromise?: Promise<void>
|
||||
|
||||
constructor(
|
||||
private readonly workspaceFactory:
|
||||
Pick<ManagedRemoteWorkspaceAccessFactory, 'create'>,
|
||||
private readonly workspaceBinding: RemoteWorkspaceProjectBinding,
|
||||
private readonly agent: AgentInstallationIdentity,
|
||||
private readonly connection: NonNullable<
|
||||
Awaited<
|
||||
ReturnType<
|
||||
@@ -234,12 +223,17 @@ class ManagedRemoteRuntimeResources {
|
||||
>
|
||||
>
|
||||
>,
|
||||
private readonly runtime: Awaited<
|
||||
ReturnType<RemoteRuntimeInstallationManager['ensureInstalled']>
|
||||
>,
|
||||
private readonly runtime: ManagedRemoteRuntimeIdentity,
|
||||
private readonly modelBridge: ManagedModelBridge
|
||||
) {}
|
||||
|
||||
get workspaceIdentity(): string {
|
||||
if (this.#workspace === undefined) {
|
||||
throw new Error('Remote Workspace is not open')
|
||||
}
|
||||
return this.#workspace.handle.workspaceIdentity
|
||||
}
|
||||
|
||||
currentTransportIdentity(): ManagedRemoteTransportIdentity {
|
||||
return {
|
||||
controllerGeneration: this.connection.client.generation,
|
||||
@@ -247,14 +241,30 @@ class ManagedRemoteRuntimeResources {
|
||||
}
|
||||
}
|
||||
|
||||
async prepareTransport(): Promise<ManagedRemoteTransportIdentity> {
|
||||
async prepareTransport(
|
||||
preparedCapabilities?: Awaited<
|
||||
ReturnType<
|
||||
RemoteAgentServices['connectionManager']['acquire']
|
||||
>
|
||||
>['capabilities']
|
||||
): Promise<ManagedRemoteTransportIdentity> {
|
||||
if (this.connection.state !== 'ready') {
|
||||
await this.connection.reconnect()
|
||||
}
|
||||
assertConnectionMatches(this.workspaceBinding, this.connection)
|
||||
assertConnectionMatches(
|
||||
this.workspaceBinding,
|
||||
this.agent,
|
||||
this.connection
|
||||
)
|
||||
const beforeRefresh = this.currentTransportIdentity()
|
||||
const capabilities = await this.connection.refreshCapabilities()
|
||||
assertConnectionMatches(this.workspaceBinding, this.connection)
|
||||
const capabilities =
|
||||
preparedCapabilities ??
|
||||
await this.connection.refreshCapabilities()
|
||||
assertConnectionMatches(
|
||||
this.workspaceBinding,
|
||||
this.agent,
|
||||
this.connection
|
||||
)
|
||||
assertSameTransport(
|
||||
beforeRefresh,
|
||||
this.currentTransportIdentity(),
|
||||
@@ -286,7 +296,7 @@ class ManagedRemoteRuntimeResources {
|
||||
bindingId,
|
||||
runtimeId: 'opencode',
|
||||
runtimeBundleDigest: this.runtime.bundleDigest,
|
||||
workspaceIdentity: this.workspaceBinding.workspaceIdentity
|
||||
workspaceIdentity: this.workspaceIdentity
|
||||
},
|
||||
modelBridge: this.modelBridge.channel
|
||||
})
|
||||
@@ -311,7 +321,6 @@ class ManagedRemoteRuntimeResources {
|
||||
this.#workspace !== undefined &&
|
||||
sameTransport(this.#workspace.transport, transport)
|
||||
) {
|
||||
await this.#workspace.access.getIdentity()
|
||||
assertSameTransport(
|
||||
transport,
|
||||
this.currentTransportIdentity(),
|
||||
@@ -322,32 +331,40 @@ class ManagedRemoteRuntimeResources {
|
||||
const previous = this.#workspace
|
||||
this.#workspace = undefined
|
||||
if (previous !== undefined) {
|
||||
await previous.access.dispose().catch(() => undefined)
|
||||
await this.#closeWorkspace(previous.handle).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
const access = this.workspaceFactory.create(
|
||||
this.workspaceBinding
|
||||
const validated = remoteWorkspaceValidateResultSchema.parse(
|
||||
await this.connection.client.request(
|
||||
'workspace/validate',
|
||||
{
|
||||
remoteRootPath: this.workspaceBinding.remoteRootPath,
|
||||
requestedAccess: 'read-only',
|
||||
requiredCapabilities: [
|
||||
...REMOTE_WORKSPACE_READ_CAPABILITIES
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
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'
|
||||
)
|
||||
}
|
||||
assertWorkspace(
|
||||
this.workspaceBinding,
|
||||
validated.handle
|
||||
)
|
||||
assertSameTransport(
|
||||
transport,
|
||||
this.currentTransportIdentity(),
|
||||
'Remote Agent transport changed while opening the Workspace'
|
||||
)
|
||||
this.#workspace = { transport, access }
|
||||
this.#workspace = {
|
||||
transport,
|
||||
handle: validated.handle
|
||||
}
|
||||
} catch (error) {
|
||||
await access.dispose().catch(() => undefined)
|
||||
await this.#closeWorkspace(validated.handle).catch(
|
||||
() => undefined
|
||||
)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
@@ -355,6 +372,21 @@ class ManagedRemoteRuntimeResources {
|
||||
return operation
|
||||
}
|
||||
|
||||
async #closeWorkspace(handle: RemoteWorkspaceHandle): Promise<void> {
|
||||
const result = remoteWorkspaceCloseResultSchema.parse(
|
||||
await this.connection.client.request('workspace/close', {
|
||||
workspaceId: handle.workspaceId,
|
||||
generation: handle.generation
|
||||
})
|
||||
)
|
||||
if (
|
||||
result.workspaceId !== handle.workspaceId ||
|
||||
result.generation !== handle.generation
|
||||
) {
|
||||
throw new Error('Remote Workspace close confirmation is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
this.#disposePromise ??= this.#dispose()
|
||||
return this.#disposePromise
|
||||
@@ -364,7 +396,9 @@ class ManagedRemoteRuntimeResources {
|
||||
await this.#workspaceTail
|
||||
const errors: unknown[] = []
|
||||
try {
|
||||
await this.#workspace?.access.dispose()
|
||||
if (this.#workspace !== undefined) {
|
||||
await this.#closeWorkspace(this.#workspace.handle)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
@@ -562,77 +596,61 @@ function assertSameTransport(
|
||||
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
|
||||
remoteRootPath: descriptor.remoteRootPath
|
||||
}
|
||||
}
|
||||
|
||||
function assertAgentMatches(
|
||||
function assertWorkspace(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
agent: Awaited<
|
||||
ReturnType<RemoteAgentServices['installationManager']['ensureInstalled']>
|
||||
>
|
||||
handle: RemoteWorkspaceHandle
|
||||
): void {
|
||||
if (
|
||||
agent.installationId !== binding.agentInstallationId ||
|
||||
agent.binaryDigest !== binding.agentBinaryDigest ||
|
||||
agent.agentVersion !== binding.agentVersion ||
|
||||
agent.architecture !== binding.agentArchitecture ||
|
||||
agent.protocol.major !== binding.agentProtocolMajor
|
||||
handle.canonicalDisplayPath !== binding.remoteRootPath ||
|
||||
handle.access !== 'read-only' ||
|
||||
REMOTE_WORKSPACE_READ_CAPABILITIES.some(
|
||||
(capability) => !handle.capabilities.includes(capability)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Installed remote Agent does not match project validation'
|
||||
'Live remote Workspace does not match the project path'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRuntimeMatches(
|
||||
validation: NonNullable<
|
||||
SshExecutionSpaceDescriptor['runtimeValidation']
|
||||
>,
|
||||
architecture: 'x64' | 'arm64',
|
||||
runtime: Awaited<
|
||||
ReturnType<RemoteRuntimeInstallationManager['ensureInstalled']>
|
||||
>
|
||||
): void {
|
||||
function runtimeIdentityFromCapabilities(
|
||||
installation: RemoteRuntimeInstallationIdentity,
|
||||
capabilities: Awaited<
|
||||
ReturnType<
|
||||
RemoteAgentServices['connectionManager']['acquire']
|
||||
>
|
||||
>['capabilities']
|
||||
): ManagedRemoteRuntimeIdentity {
|
||||
if (
|
||||
runtime.runtimeId !== 'opencode' ||
|
||||
runtime.architecture !== architecture ||
|
||||
runtime.bundleDigest !== validation.runtimeBundleDigest ||
|
||||
runtime.runtimeAdapterDigest !==
|
||||
validation.runtimeAdapterDigest
|
||||
installation.runtimeId !== 'opencode' ||
|
||||
installation.platform !== 'linux'
|
||||
) {
|
||||
throw new Error(
|
||||
'Installed OpenCode Runtime does not match project validation'
|
||||
'Remote Agent does not advertise the current Runtime'
|
||||
)
|
||||
}
|
||||
const runtime: ManagedRemoteRuntimeIdentity = {
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: installation.runtimeVersion,
|
||||
bundleDigest: installation.bundleDigest,
|
||||
runtimeAdapterDigest: installation.runtimeAdapterDigest,
|
||||
acpCapabilitiesDigest: installation.acpCapabilitiesDigest,
|
||||
platform: 'linux',
|
||||
architecture: installation.architecture
|
||||
}
|
||||
assertRuntimeAdvertised(runtime, capabilities)
|
||||
return runtime
|
||||
}
|
||||
|
||||
function assertConnectionMatches(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
agent: AgentInstallationIdentity,
|
||||
connection: Awaited<
|
||||
ReturnType<RemoteAgentServices['connectionManager']['acquire']>
|
||||
>
|
||||
@@ -640,31 +658,24 @@ function assertConnectionMatches(
|
||||
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.identity.installationId !== agent.installationId ||
|
||||
connection.identity.binaryDigest !== agent.binaryDigest ||
|
||||
connection.identity.protocolMajor !== agent.protocol.major ||
|
||||
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
|
||||
connection.status.installationId !== agent.installationId ||
|
||||
connection.status.binaryDigest !== agent.binaryDigest ||
|
||||
connection.status.agentVersion !== agent.agentVersion ||
|
||||
connection.status.architecture !== agent.architecture
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Agent connection does not match project validation'
|
||||
'Remote Agent connection is not current'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRuntimeAdvertised(
|
||||
runtime: Awaited<
|
||||
ReturnType<RemoteRuntimeInstallationManager['ensureInstalled']>
|
||||
>,
|
||||
runtime: ManagedRemoteRuntimeIdentity,
|
||||
capabilities: Awaited<
|
||||
ReturnType<
|
||||
RemoteAgentServices['connectionManager']['acquire']
|
||||
@@ -698,7 +709,7 @@ function assertRuntimeAdvertised(
|
||||
!advertised[0]!.sessionResume
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Agent does not advertise the validated OpenCode Runtime'
|
||||
'Remote Agent does not advertise the current OpenCode Runtime'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ import {
|
||||
RemoteRuntimeInstallationManager,
|
||||
type RemoteRuntimeActivator
|
||||
} from './remote-runtime-installation-manager'
|
||||
import {
|
||||
loadRemoteRuntimeVerificationMetadata
|
||||
} from './remote-runtime-resource-loader'
|
||||
import {
|
||||
createManagedModelBridge,
|
||||
reconcileStartupModelCalls
|
||||
} from './managed-model-bridge'
|
||||
import type {
|
||||
AgentPackageManager
|
||||
} from './agent-package-manager'
|
||||
|
||||
const RUNTIME_BINDING_DATABASE_NAME = 'remote-runtime-bindings.sqlite'
|
||||
const MODEL_CALL_DATABASE_NAME = 'remote-model-calls-v2.sqlite'
|
||||
@@ -46,10 +46,6 @@ export type ManagedRemoteExecutionServicesOptions = {
|
||||
appPath: string
|
||||
resourcesPath: string
|
||||
packaged: boolean
|
||||
agentPackageManager: Pick<
|
||||
AgentPackageManager,
|
||||
'loadRuntimeBundle' | 'loadRuntimeMetadata'
|
||||
>
|
||||
resolveModelProfile(
|
||||
selection: AgentRuntimeSelection
|
||||
): Promise<ResolvedModelProfile | undefined>
|
||||
@@ -93,13 +89,17 @@ export class ManagedRemoteExecutionServices {
|
||||
runtimeId,
|
||||
bundleDigest,
|
||||
architecture,
|
||||
currentAgentInstallationId,
|
||||
signal
|
||||
) => {
|
||||
const agent =
|
||||
await options.agentServices.installationManager.ensureInstalled(
|
||||
lease.identity.hostId,
|
||||
{ signal }
|
||||
)
|
||||
const agentInstallationId =
|
||||
currentAgentInstallationId ??
|
||||
(
|
||||
await options.agentServices.installationManager.activateInstalled(
|
||||
lease.identity.hostId,
|
||||
{ signal }
|
||||
)
|
||||
).installationId
|
||||
signal.throwIfAborted()
|
||||
const current = await options.agentServices.targetResolver.resolve(
|
||||
lease.identity.hostId
|
||||
@@ -110,15 +110,14 @@ export class ManagedRemoteExecutionServices {
|
||||
current.host.id !== lease.identity.hostId ||
|
||||
current.hostRevision !== lease.identity.hostRevision ||
|
||||
current.hostKeyGeneration !==
|
||||
lease.identity.hostKeyGeneration ||
|
||||
agent.architecture !== architecture
|
||||
lease.identity.hostKeyGeneration
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Agent or SSH host identity changed before Runtime activation'
|
||||
)
|
||||
}
|
||||
const result = await lease.runAgentRuntimeAction(
|
||||
agent.installationId,
|
||||
agentInstallationId,
|
||||
{
|
||||
kind: 'runtime-activate',
|
||||
runtimeId,
|
||||
@@ -135,10 +134,10 @@ export class ManagedRemoteExecutionServices {
|
||||
new RemoteRuntimeInstallationManager({
|
||||
resolver: options.agentServices.targetResolver,
|
||||
sshPool: options.agentServices.sshPool,
|
||||
loadVerifiedBundle:
|
||||
options.agentPackageManager.loadRuntimeBundle,
|
||||
loadVerificationMetadata:
|
||||
options.agentPackageManager.loadRuntimeMetadata,
|
||||
loadVerificationMetadata: () =>
|
||||
loadRemoteRuntimeVerificationMetadata(
|
||||
this.runtimeResourcePaths
|
||||
),
|
||||
activate
|
||||
})
|
||||
this.runtimeValidator = new ManagedRemoteProjectRuntimeValidator({
|
||||
@@ -147,9 +146,9 @@ export class ManagedRemoteExecutionServices {
|
||||
})
|
||||
this.workspaceAccessFactory =
|
||||
new ManagedRemoteWorkspaceAccessFactory({
|
||||
connectionManager: options.agentServices.connectionManager,
|
||||
installationManager:
|
||||
options.agentServices.installationManager,
|
||||
connectionManager: options.agentServices.connectionManager
|
||||
options.agentServices.installationManager
|
||||
})
|
||||
this.bindingStore = new SqliteRuntimeSessionBindingStore(
|
||||
join(options.userDataPath, RUNTIME_BINDING_DATABASE_NAME)
|
||||
@@ -188,6 +187,10 @@ export class ManagedRemoteExecutionServices {
|
||||
await this.#readiness
|
||||
}
|
||||
|
||||
invalidateHost(hostId: string): void {
|
||||
this.runtimeInstallationManager.invalidateHost(hostId)
|
||||
}
|
||||
|
||||
async createRuntime(
|
||||
options: Pick<
|
||||
ManagedRemoteAcpRuntimeOptions,
|
||||
@@ -206,14 +209,12 @@ export class ManagedRemoteExecutionServices {
|
||||
...options,
|
||||
modelBridge,
|
||||
agentServices: {
|
||||
installationManager:
|
||||
this.#agentServices.installationManager,
|
||||
connectionManager: this.#agentServices.connectionManager,
|
||||
controllerState: this.#agentServices.controllerState
|
||||
controllerState: this.#agentServices.controllerState,
|
||||
installationManager:
|
||||
this.#agentServices.installationManager
|
||||
},
|
||||
runtimeInstallationManager:
|
||||
this.runtimeInstallationManager,
|
||||
workspaceAccessFactory: this.workspaceAccessFactory,
|
||||
runtimeInstallationManager: this.runtimeInstallationManager,
|
||||
bindingStore: this.bindingStore
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ import { ManagedRemoteProjectRuntimeValidator } from './managed-remote-project-r
|
||||
import type { RemoteProjectRuntimeValidationInput } from './remote-project-save-service'
|
||||
import type { RemoteRuntimeInstallationIdentity } from './remote-runtime-installation-manager'
|
||||
import type { ResolvedModelProfile } from '../runtime-settings-store'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
|
||||
const digest = (character: string): string =>
|
||||
`sha256:${character.repeat(64)}`
|
||||
const agentInstallationId =
|
||||
verifyAgentInstallationId('agent-installation')
|
||||
|
||||
const installation: RemoteRuntimeInstallationIdentity = {
|
||||
runtimeId: 'opencode',
|
||||
@@ -60,7 +63,6 @@ function advertisedCapabilities(): DaemonCapabilities {
|
||||
|
||||
function harness(options: {
|
||||
selection?: RemoteProjectRuntimeValidationInput['selection']
|
||||
workMode?: 'ask' | 'execute'
|
||||
architecture?: 'x64' | 'arm64'
|
||||
installed?: RemoteRuntimeInstallationIdentity
|
||||
refreshed?: DaemonCapabilities
|
||||
@@ -72,7 +74,6 @@ function harness(options: {
|
||||
({
|
||||
provider: 'opencode'
|
||||
} as const)
|
||||
const workMode = options.workMode ?? 'ask'
|
||||
const architecture = options.architecture ?? 'x64'
|
||||
const installed = options.installed ?? installation
|
||||
let state: RemoteAgentConnection['state'] = 'ready'
|
||||
@@ -85,7 +86,7 @@ function harness(options: {
|
||||
}
|
||||
const status: DaemonStatus = {
|
||||
state: 'ready',
|
||||
installationId: 'agent-installation',
|
||||
installationId: agentInstallationId,
|
||||
binaryDigest: digest('a'),
|
||||
daemonBootId: 'daemon-boot',
|
||||
agentVersion: '2.0.0',
|
||||
@@ -127,8 +128,8 @@ function harness(options: {
|
||||
reconnect: vi.fn(),
|
||||
release: vi.fn()
|
||||
} as unknown as RemoteAgentConnection
|
||||
const ensureInstalled = vi.fn(async () => {
|
||||
calls.push('install')
|
||||
const activateInstalled = vi.fn(async () => {
|
||||
calls.push('activate')
|
||||
return installed
|
||||
})
|
||||
const resolveModelProfile = vi.fn(async () => {
|
||||
@@ -138,19 +139,12 @@ function harness(options: {
|
||||
: options.modelProfile ?? modelProfile
|
||||
})
|
||||
const validator = new ManagedRemoteProjectRuntimeValidator({
|
||||
installationManager: { ensureInstalled },
|
||||
resolveModelProfile,
|
||||
now: () => new Date('2030-01-01T00:00:00.000Z')
|
||||
installationManager: { activateInstalled },
|
||||
resolveModelProfile
|
||||
})
|
||||
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,
|
||||
@@ -158,20 +152,12 @@ function harness(options: {
|
||||
remoteUsername: 'builder'
|
||||
},
|
||||
agent: {
|
||||
installationId: status.installationId,
|
||||
installationId: agentInstallationId,
|
||||
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
|
||||
}
|
||||
@@ -180,7 +166,7 @@ function harness(options: {
|
||||
input,
|
||||
connection,
|
||||
calls,
|
||||
ensureInstalled,
|
||||
activateInstalled,
|
||||
refreshCapabilities,
|
||||
resolveModelProfile,
|
||||
setCapabilities: (value: DaemonCapabilities) => {
|
||||
@@ -193,22 +179,15 @@ function harness(options: {
|
||||
}
|
||||
|
||||
describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
it('installs, refreshes, and leases exact Ask Runtime evidence', async () => {
|
||||
it('installs, refreshes, and leases the current Runtime', 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', {
|
||||
expect(test.calls).toEqual(['model', 'activate', 'refresh'])
|
||||
expect(test.activateInstalled).toHaveBeenCalledWith('host-1', {
|
||||
agentInstallationId,
|
||||
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({
|
||||
@@ -221,13 +200,10 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
expect(() => lease.assertCurrent()).toThrow(/released/iu)
|
||||
})
|
||||
|
||||
it('validates Execute without a second authorization tier', async () => {
|
||||
const test = harness({ workMode: 'execute' })
|
||||
it('fails the live lease when the connection goes offline', async () => {
|
||||
const test = harness()
|
||||
const lease = await test.validator.validate(test.input)
|
||||
|
||||
expect(lease.evidence).toMatchObject({
|
||||
workMode: 'execute'
|
||||
})
|
||||
test.setState('offline')
|
||||
expect(() => lease.assertCurrent()).toThrow(/not ready/iu)
|
||||
})
|
||||
@@ -243,7 +219,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
await expect(test.validator.validate(test.input)).rejects.toThrow(
|
||||
/OpenCode Runtime/iu
|
||||
)
|
||||
expect(test.ensureInstalled).not.toHaveBeenCalled()
|
||||
expect(test.activateInstalled).not.toHaveBeenCalled()
|
||||
expect(test.refreshCapabilities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -263,7 +239,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
|
||||
/model profile/iu
|
||||
)
|
||||
expect(test.resolveModelProfile).toHaveBeenCalledOnce()
|
||||
expect(test.ensureInstalled).not.toHaveBeenCalled()
|
||||
expect(test.activateInstalled).not.toHaveBeenCalled()
|
||||
expect(test.refreshCapabilities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
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
|
||||
@@ -24,9 +21,8 @@ export class ManagedRemoteProjectRuntimeValidator
|
||||
{
|
||||
readonly #installationManager: Pick<
|
||||
RemoteRuntimeInstallationManager,
|
||||
'ensureInstalled'
|
||||
'activateInstalled'
|
||||
>
|
||||
readonly #now: () => Date
|
||||
readonly #resolveModelProfile: (
|
||||
selection: RemoteProjectRuntimeValidationInput['selection']
|
||||
) => Promise<ResolvedModelProfile | undefined>
|
||||
@@ -34,16 +30,14 @@ export class ManagedRemoteProjectRuntimeValidator
|
||||
constructor(options: {
|
||||
installationManager: Pick<
|
||||
RemoteRuntimeInstallationManager,
|
||||
'ensureInstalled'
|
||||
'activateInstalled'
|
||||
>
|
||||
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(
|
||||
@@ -51,9 +45,7 @@ export class ManagedRemoteProjectRuntimeValidator
|
||||
): Promise<RemoteProjectRuntimeValidationLease> {
|
||||
input.signal.throwIfAborted()
|
||||
if (
|
||||
input.selection.provider !== 'opencode' ||
|
||||
input.runtimeSelectionKey !==
|
||||
agentRuntimeSelectionKey(input.selection)
|
||||
input.selection.provider !== 'opencode'
|
||||
) {
|
||||
throw new Error(
|
||||
'Managed remote projects require the OpenCode Runtime'
|
||||
@@ -75,9 +67,12 @@ export class ManagedRemoteProjectRuntimeValidator
|
||||
)
|
||||
}
|
||||
const installation =
|
||||
await this.#installationManager.ensureInstalled(
|
||||
await this.#installationManager.activateInstalled(
|
||||
input.host.hostId,
|
||||
{ signal: input.signal }
|
||||
{
|
||||
signal: input.signal,
|
||||
agentInstallationId: input.agent.installationId
|
||||
}
|
||||
)
|
||||
input.signal.throwIfAborted()
|
||||
assertInstallationIdentity(input, installation)
|
||||
@@ -96,19 +91,9 @@ export class ManagedRemoteProjectRuntimeValidator
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -5,11 +5,6 @@ import {
|
||||
} 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,
|
||||
@@ -19,35 +14,26 @@ import type {
|
||||
RemoteAgentConnection,
|
||||
RemoteAgentConnectionManager
|
||||
} from './remote-agent-connection-manager'
|
||||
import type { AgentInstallationIdentity } from './agent-installation-manager'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
|
||||
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
|
||||
remoteRootPath: '/srv/project'
|
||||
}
|
||||
const installation: AgentInstallationIdentity = {
|
||||
installationId: verifyAgentInstallationId(
|
||||
binding.agentInstallationId
|
||||
),
|
||||
binaryDigest: binding.agentBinaryDigest,
|
||||
agentVersion: binding.agentVersion,
|
||||
protocol: { ...AGENT_PROTOCOL_VERSION },
|
||||
installationId: verifyAgentInstallationId('installation-test'),
|
||||
binaryDigest: digest,
|
||||
agentVersion: '0.11.0',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
platform: 'linux',
|
||||
architecture: binding.agentArchitecture,
|
||||
architecture: 'x64',
|
||||
supervisor: 'detached-on-demand'
|
||||
}
|
||||
const handle: RemoteWorkspaceHandle = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceIdentity: binding.workspaceIdentity,
|
||||
workspaceIdentity: 'workspace-identity',
|
||||
canonicalDisplayPath: binding.remoteRootPath,
|
||||
access: 'read-only',
|
||||
git: 'available',
|
||||
@@ -63,21 +49,38 @@ const handle: RemoteWorkspaceHandle = {
|
||||
}
|
||||
|
||||
describe('ManagedRemoteWorkspaceAccessFactory', () => {
|
||||
it('verifies installation before network work and owns the connection lease', async () => {
|
||||
it('opens with the Host current Agent 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}`,
|
||||
id: `${binding.hostId}:${handle.workspaceIdentity}`,
|
||||
canonicalDisplayPath: binding.remoteRootPath,
|
||||
access: 'read-only'
|
||||
})
|
||||
|
||||
expect(test.events.slice(0, 2)).toEqual([
|
||||
'agent:ensure',
|
||||
'installation:activate',
|
||||
'connection:acquire'
|
||||
])
|
||||
expect(test.activateInstalled).toHaveBeenCalledWith(
|
||||
binding.hostId,
|
||||
{}
|
||||
)
|
||||
expect(test.acquire).toHaveBeenCalledWith(
|
||||
binding.hostId,
|
||||
expect.objectContaining({
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: installation.binaryDigest,
|
||||
agentVersion: installation.agentVersion,
|
||||
architecture: installation.architecture,
|
||||
protocol: expect.objectContaining({
|
||||
major: installation.protocol.major
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
expect(test.protocolMethods).toContain('workspace/validate')
|
||||
expect(test.protocolMethods).not.toContain('workspace/open')
|
||||
expect(test.protocolMethods).not.toContain('workspace/resume')
|
||||
@@ -92,49 +95,6 @@ describe('ManagedRemoteWorkspaceAccessFactory', () => {
|
||||
).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')
|
||||
@@ -150,25 +110,18 @@ describe('ManagedRemoteWorkspaceAccessFactory', () => {
|
||||
|
||||
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>
|
||||
activateInstalled: 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)
|
||||
@@ -198,23 +151,23 @@ function createHarness(options: HarnessOptions = {}): {
|
||||
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
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'builder',
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: installation.binaryDigest,
|
||||
protocolMajor: installation.protocol.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor
|
||||
},
|
||||
status: {
|
||||
state: 'ready',
|
||||
installationId: binding.agentInstallationId,
|
||||
binaryDigest: binding.agentBinaryDigest,
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: installation.binaryDigest,
|
||||
daemonBootId: 'boot-1',
|
||||
agentVersion: binding.agentVersion,
|
||||
protocol: { ...installation.protocol },
|
||||
agentVersion: installation.agentVersion,
|
||||
protocol: { ...AGENT_PROTOCOL_VERSION },
|
||||
platform: 'linux',
|
||||
architecture: binding.agentArchitecture,
|
||||
architecture: installation.architecture,
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: 'uid:1000',
|
||||
draining: false
|
||||
@@ -235,20 +188,24 @@ function createHarness(options: HarnessOptions = {}): {
|
||||
}
|
||||
return connection
|
||||
})
|
||||
const activateInstalled = vi.fn(async () => {
|
||||
events.push('installation:activate')
|
||||
return installation
|
||||
})
|
||||
const factoryOptions = {
|
||||
installationManager: {
|
||||
ensureInstalled
|
||||
} as unknown as Pick<AgentInstallationManager, 'ensureInstalled'>,
|
||||
connectionManager: {
|
||||
acquire
|
||||
} as unknown as Pick<RemoteAgentConnectionManager, 'acquire'>
|
||||
} as unknown as Pick<RemoteAgentConnectionManager, 'acquire'>,
|
||||
installationManager: {
|
||||
activateInstalled
|
||||
}
|
||||
} satisfies ManagedRemoteWorkspaceAccessFactoryOptions
|
||||
return {
|
||||
factory: new ManagedRemoteWorkspaceAccessFactory(factoryOptions),
|
||||
events,
|
||||
protocolMethods,
|
||||
ensureInstalled,
|
||||
acquire,
|
||||
activateInstalled,
|
||||
connectionRelease
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,21 @@ import {
|
||||
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 type { AgentInstallationManager } from './agent-installation-manager'
|
||||
import {
|
||||
ProtocolRemoteWorkspaceTransport,
|
||||
type RemoteWorkspaceInstallationIdentityResolver
|
||||
} from './protocol-remote-workspace-transport'
|
||||
|
||||
export type ManagedRemoteWorkspaceAccessFactoryOptions = {
|
||||
installationManager: Pick<
|
||||
AgentInstallationManager,
|
||||
'ensureInstalled'
|
||||
>
|
||||
connectionManager: Pick<RemoteAgentConnectionManager, 'acquire'>
|
||||
installationManager: Pick<AgentInstallationManager, 'activateInstalled'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes persisted project validation with fresh installation and
|
||||
* connection leases. The resulting WorkspaceAccess remains strictly
|
||||
* read-only and owns every acquired connection lease.
|
||||
* Resolves the Host's current Agent before opening a read-only Workspace
|
||||
* connection. Projects persist only the Host and remote path.
|
||||
*/
|
||||
export class ManagedRemoteWorkspaceAccessFactory
|
||||
implements RemoteWorkspaceAccessFactory
|
||||
@@ -33,25 +29,21 @@ export class ManagedRemoteWorkspaceAccessFactory
|
||||
create(bindingInput: RemoteWorkspaceProjectBinding): RemoteWorkspaceAccess {
|
||||
const binding = { ...bindingInput }
|
||||
const resolver: RemoteWorkspaceInstallationIdentityResolver = {
|
||||
resolve: async (hostId, installationId, signal) => {
|
||||
if (
|
||||
hostId !== binding.hostId ||
|
||||
installationId !== binding.agentInstallationId
|
||||
) {
|
||||
resolve: async (hostId, signal) => {
|
||||
if (hostId !== binding.hostId) {
|
||||
throw new Error(
|
||||
'Remote workspace installation request does not match its project binding'
|
||||
'Remote workspace Host 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 {
|
||||
installation:
|
||||
await this.options.installationManager.activateInstalled(
|
||||
hostId,
|
||||
{ ...(signal === undefined ? {} : { signal }) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return new RemoteWorkspaceAccess(
|
||||
@@ -63,22 +55,3 @@ export class ManagedRemoteWorkspaceAccessFactory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,16 +89,7 @@ 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
|
||||
remoteRootPath: '/srv/project'
|
||||
}
|
||||
const installation: RemoteAgentInstallationIdentity = {
|
||||
installationId: verifyAgentInstallationId('installation-test'),
|
||||
@@ -111,7 +102,7 @@ const installation: RemoteAgentInstallationIdentity = {
|
||||
}
|
||||
const handle: RemoteWorkspaceHandle = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceIdentity: binding.workspaceIdentity,
|
||||
workspaceIdentity: 'workspace-identity',
|
||||
canonicalDisplayPath: binding.remoteRootPath,
|
||||
access: 'read-only',
|
||||
git: 'available',
|
||||
@@ -139,16 +130,9 @@ describe('ProtocolRemoteWorkspaceTransport', () => {
|
||||
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,
|
||||
@@ -156,19 +140,6 @@ describe('ProtocolRemoteWorkspaceTransport', () => {
|
||||
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,
|
||||
@@ -244,8 +215,6 @@ describe('ProtocolRemoteWorkspaceTransport', () => {
|
||||
|
||||
expect(calls).toEqual([
|
||||
'workspace/validate',
|
||||
'workspace/open',
|
||||
'workspace/resume',
|
||||
'workspace/list',
|
||||
'workspace/stat',
|
||||
'workspace/readText',
|
||||
@@ -259,38 +228,49 @@ describe('ProtocolRemoteWorkspaceTransport', () => {
|
||||
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
|
||||
}
|
||||
it('uses the current Host-management transport identity for workspace access', async () => {
|
||||
const test = createHarness({
|
||||
identity: {
|
||||
hostRevision: 99,
|
||||
hostKeyGeneration: 100,
|
||||
remoteUsername: 'current-user'
|
||||
}
|
||||
})
|
||||
|
||||
const lease = await test.transport.acquireLease(binding)
|
||||
expect(test.release).not.toHaveBeenCalled()
|
||||
lease.release()
|
||||
expect(test.release).toHaveBeenCalledOnce()
|
||||
test.close()
|
||||
})
|
||||
|
||||
expect(lease.resumableHandle).toEqual(nonGitHandle)
|
||||
it('accepts an older compatible Agent protocol minor', async () => {
|
||||
const test = createHarness({
|
||||
installation: {
|
||||
...installation,
|
||||
protocol: {
|
||||
major: AGENT_PROTOCOL_VERSION.major,
|
||||
minor: AGENT_PROTOCOL_VERSION.minor + 1
|
||||
}
|
||||
},
|
||||
identity: {
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor
|
||||
},
|
||||
connectionStatus: {
|
||||
protocol: {
|
||||
major: AGENT_PROTOCOL_VERSION.major,
|
||||
minor: AGENT_PROTOCOL_VERSION.minor
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const lease = await test.transport.acquireLease(binding)
|
||||
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,
|
||||
@@ -301,7 +281,8 @@ describe('ProtocolRemoteWorkspaceTransport', () => {
|
||||
await expect(
|
||||
installationMismatch.transport.acquireLease(binding)
|
||||
).rejects.toMatchObject({ reason: 'binding-mismatch' })
|
||||
expect(installationMismatch.acquire).not.toHaveBeenCalled()
|
||||
expect(installationMismatch.acquire).toHaveBeenCalledOnce()
|
||||
expect(installationMismatch.release).toHaveBeenCalledOnce()
|
||||
installationMismatch.close()
|
||||
|
||||
const statusMismatch = createHarness({
|
||||
@@ -333,7 +314,8 @@ describe('ProtocolRemoteWorkspaceTransport', () => {
|
||||
await expect(
|
||||
identityMismatch.transport.acquireLease(binding)
|
||||
).rejects.toMatchObject({ reason: 'binding-mismatch' })
|
||||
expect(identityMismatch.acquire).not.toHaveBeenCalled()
|
||||
expect(identityMismatch.acquire).toHaveBeenCalledOnce()
|
||||
expect(identityMismatch.release).toHaveBeenCalledOnce()
|
||||
identityMismatch.close()
|
||||
}
|
||||
|
||||
@@ -421,24 +403,6 @@ describe('ProtocolRemoteWorkspaceTransport', () => {
|
||||
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']
|
||||
@@ -491,7 +455,7 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
const events = new EventJournal(resolve(root, 'events.sqlite'))
|
||||
const status = {
|
||||
state: 'ready' as const,
|
||||
installationId: binding.agentInstallationId,
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: digest,
|
||||
daemonBootId: 'boot-test',
|
||||
agentVersion: '0.11.0',
|
||||
@@ -544,10 +508,10 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
identity: {
|
||||
cacheKey: 'connection-cache',
|
||||
hostId: binding.hostId,
|
||||
hostRevision: binding.hostRevision,
|
||||
hostKeyGeneration: binding.hostKeyGeneration,
|
||||
remoteUsername: binding.remoteUsername,
|
||||
installationId: binding.agentInstallationId,
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'builder',
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: digest,
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
@@ -572,12 +536,7 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
} satisfies RemoteAgentConnection
|
||||
})
|
||||
const resolution = {
|
||||
installation: options.installation ?? installation,
|
||||
durableWorkspaceValidation: undefined
|
||||
} as {
|
||||
installation: RemoteAgentInstallationIdentity
|
||||
durableWorkspaceValidation?:
|
||||
import('./protocol-remote-workspace-transport').DurableRemoteWorkspaceValidation
|
||||
installation: options.installation ?? installation
|
||||
}
|
||||
const resolver: RemoteWorkspaceInstallationIdentityResolver = {
|
||||
resolve: vi.fn(async () => resolution)
|
||||
@@ -697,14 +656,14 @@ function transportBinding(
|
||||
): 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,
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'builder',
|
||||
agentInstallationId: installation.installationId,
|
||||
agentBinaryDigest: installation.binaryDigest,
|
||||
agentVersion: '0.11.0',
|
||||
agentArchitecture: installation.architecture,
|
||||
agentProtocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
capabilityGeneration
|
||||
}
|
||||
}
|
||||
@@ -762,7 +721,7 @@ class MemoryAgentTransport {
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
connectionId: controller.connectionId,
|
||||
generation: controller.generation,
|
||||
installationId: binding.agentInstallationId,
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: digest,
|
||||
daemonBootId: 'boot-test',
|
||||
serverNonce: 'server-test'
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
REMOTE_WORKSPACE_READ_CAPABILITIES,
|
||||
remoteWorkspaceHandleSchema,
|
||||
type RemoteWorkspaceCloseResult,
|
||||
type RemoteWorkspaceHandle
|
||||
import type {
|
||||
RemoteWorkspaceCloseResult
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import type {
|
||||
RemoteWorkspaceProjectBinding,
|
||||
@@ -23,26 +20,13 @@ import type {
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -85,7 +69,6 @@ export class ProtocolRemoteWorkspaceTransport
|
||||
const projectBinding = { ...binding }
|
||||
const resolved = await this.installations.resolve(
|
||||
projectBinding.hostId,
|
||||
projectBinding.agentInstallationId,
|
||||
signal
|
||||
)
|
||||
let resolution: RemoteWorkspaceInstallationResolution | undefined
|
||||
@@ -94,15 +77,13 @@ export class ProtocolRemoteWorkspaceTransport
|
||||
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)
|
||||
assertConnectionStatus(resolution, connection.status)
|
||||
const capabilities = await connection.client.request(
|
||||
'agent/capabilities',
|
||||
{},
|
||||
@@ -116,23 +97,11 @@ export class ProtocolRemoteWorkspaceTransport
|
||||
) {
|
||||
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
|
||||
acquiredCapabilityGeneration: capabilities.generation
|
||||
})
|
||||
retained = true
|
||||
return lease
|
||||
@@ -148,7 +117,6 @@ function snapshotResolution(
|
||||
resolution: RemoteWorkspaceInstallationResolution
|
||||
): RemoteWorkspaceInstallationResolution {
|
||||
const installation = resolution.installation
|
||||
const durable = resolution.durableWorkspaceValidation
|
||||
return {
|
||||
installation: {
|
||||
...installation,
|
||||
@@ -163,20 +131,7 @@ function snapshotResolution(
|
||||
...entry
|
||||
}))
|
||||
})
|
||||
},
|
||||
...(durable === undefined
|
||||
? {}
|
||||
: {
|
||||
durableWorkspaceValidation: {
|
||||
binding: { ...durable.binding },
|
||||
workspaceIdentity: durable.workspaceIdentity,
|
||||
handle: {
|
||||
...durable.handle,
|
||||
capabilities: [...durable.handle.capabilities]
|
||||
},
|
||||
validatedAt: durable.validatedAt
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +140,6 @@ type LeaseState = {
|
||||
resolution: RemoteWorkspaceInstallationResolution
|
||||
connection: RemoteAgentConnection
|
||||
acquiredCapabilityGeneration: number
|
||||
resumableHandle?: RemoteWorkspaceHandle
|
||||
}
|
||||
|
||||
function createLease(state: LeaseState): RemoteWorkspaceTransportLease {
|
||||
@@ -218,7 +172,6 @@ function createLease(state: LeaseState): RemoteWorkspaceTransportLease {
|
||||
state.connection
|
||||
)
|
||||
assertConnectionStatus(
|
||||
state.binding,
|
||||
state.resolution,
|
||||
state.connection.status
|
||||
)
|
||||
@@ -264,15 +217,8 @@ function createLease(state: LeaseState): RemoteWorkspaceTransportLease {
|
||||
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 (
|
||||
@@ -337,8 +283,6 @@ function releaseLeaseResources(
|
||||
|
||||
type WorkspaceReadProtocolMethod =
|
||||
| 'workspace/validate'
|
||||
| 'workspace/open'
|
||||
| 'workspace/resume'
|
||||
| 'workspace/close'
|
||||
| 'workspace/list'
|
||||
| 'workspace/stat'
|
||||
@@ -382,26 +326,6 @@ function requireWorkspaceReadCapability(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -411,22 +335,18 @@ function assertConnectionIdentity(
|
||||
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.installationId !==
|
||||
resolution.installation.installationId ||
|
||||
identity.binaryDigest !== resolution.installation.binaryDigest ||
|
||||
identity.protocolMajor !== binding.agentProtocolMajor ||
|
||||
protocol === undefined ||
|
||||
identity.protocolMinor !== protocol.minor
|
||||
identity.protocolMajor !== protocol.major ||
|
||||
identity.protocolMinor > protocol.minor
|
||||
) {
|
||||
throw bindingMismatchError()
|
||||
}
|
||||
}
|
||||
|
||||
function assertConnectionStatus(
|
||||
binding: RemoteWorkspaceProjectBinding,
|
||||
resolution: RemoteWorkspaceInstallationResolution,
|
||||
status: RemoteAgentConnection['status']
|
||||
): void {
|
||||
@@ -434,16 +354,13 @@ function assertConnectionStatus(
|
||||
if (
|
||||
status.state !== 'ready' ||
|
||||
status.draining ||
|
||||
status.installationId !== binding.agentInstallationId ||
|
||||
status.binaryDigest !== binding.agentBinaryDigest ||
|
||||
status.installationId !== installation.installationId ||
|
||||
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.protocol.major !== installation.protocol.major ||
|
||||
status.protocol.minor > installation.protocol.minor ||
|
||||
status.platform !== installation.platform ||
|
||||
status.supervisor !== installation.supervisor ||
|
||||
installation.agentVersion === undefined
|
||||
@@ -479,7 +396,7 @@ function liveBinding(
|
||||
capabilityGeneration: number
|
||||
): RemoteWorkspaceTransportBinding {
|
||||
assertConnectionIdentity(binding, resolution, connection)
|
||||
assertConnectionStatus(binding, resolution, connection.status)
|
||||
assertConnectionStatus(resolution, connection.status)
|
||||
if (
|
||||
connection.capabilities.generation !== capabilityGeneration
|
||||
) {
|
||||
@@ -499,54 +416,6 @@ function liveBinding(
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
SshConnectionPoolTarget
|
||||
} from '../ssh/ssh-connection-pool'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
import type { AgentAttachTransport } from './agent-attach-transport'
|
||||
import {
|
||||
AgentAttachTransportError,
|
||||
type AgentAttachTransport
|
||||
} from './agent-attach-transport'
|
||||
import type {
|
||||
AgentProtocolClient,
|
||||
AgentProtocolMethod
|
||||
@@ -26,12 +29,14 @@ import {
|
||||
|
||||
class MemoryStateFile implements ControllerStateFile {
|
||||
value?: unknown
|
||||
writes = 0
|
||||
|
||||
async read(): Promise<unknown | undefined> {
|
||||
return structuredClone(this.value)
|
||||
}
|
||||
|
||||
async write(value: PersistedControllerState): Promise<void> {
|
||||
this.writes += 1
|
||||
this.value = structuredClone(value)
|
||||
}
|
||||
}
|
||||
@@ -152,7 +157,8 @@ const installation = {
|
||||
|
||||
function harness(
|
||||
connectGate?: Promise<void>,
|
||||
reconnectResumeGate?: Promise<void>
|
||||
reconnectResumeGate?: Promise<void>,
|
||||
idleTimeoutMs = 0
|
||||
) {
|
||||
let currentTarget = target()
|
||||
const release = vi.fn()
|
||||
@@ -199,7 +205,7 @@ function harness(
|
||||
sshPool,
|
||||
controllerState: new ControllerStateStore(stateFile),
|
||||
goodBuddyVersion: '0.11.0',
|
||||
idleTimeoutMs: 0,
|
||||
idleTimeoutMs,
|
||||
connectTransport,
|
||||
createProtocolClient: () => {
|
||||
const client = new FakeProtocolClient(clients.length)
|
||||
@@ -244,6 +250,109 @@ describe('RemoteAgentConnectionManager', () => {
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('shares one Host connection across caller capability requirements', async () => {
|
||||
const test = harness()
|
||||
const workspace = await test.manager.acquire(
|
||||
'host-1',
|
||||
installation
|
||||
)
|
||||
test.clients[0]!.capabilities = {
|
||||
generation: 2,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true },
|
||||
{ name: 'runtime/acp', version: 3, critical: true }
|
||||
],
|
||||
runtimes: []
|
||||
}
|
||||
await workspace.refreshCapabilities()
|
||||
|
||||
const runtime = await test.manager.acquire('host-1', {
|
||||
...installation,
|
||||
requiredCapabilities: [
|
||||
{
|
||||
name: 'runtime/acp',
|
||||
exactVersion: 3,
|
||||
critical: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(runtime.client).toBe(workspace.client)
|
||||
expect(test.connectTransport).toHaveBeenCalledOnce()
|
||||
runtime.release()
|
||||
workspace.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('rejects only the caller when a shared connection lacks its capability', async () => {
|
||||
const test = harness()
|
||||
const workspace = await test.manager.acquire(
|
||||
'host-1',
|
||||
installation
|
||||
)
|
||||
|
||||
await expect(
|
||||
test.manager.acquire('host-1', {
|
||||
...installation,
|
||||
requiredCapabilities: [
|
||||
{
|
||||
name: 'runtime/acp',
|
||||
exactVersion: 3,
|
||||
critical: true
|
||||
}
|
||||
]
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
disposition: 'terminal',
|
||||
reason: 'capability'
|
||||
})
|
||||
expect(workspace.status.state).toBe('ready')
|
||||
expect(test.connectTransport).toHaveBeenCalledOnce()
|
||||
|
||||
workspace.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('does not bind shared connection viability to the first caller requirements', async () => {
|
||||
const test = harness(undefined, undefined, 1_000)
|
||||
await expect(
|
||||
test.manager.acquire('host-1', {
|
||||
...installation,
|
||||
requiredCapabilities: [
|
||||
{
|
||||
name: 'runtime/acp',
|
||||
exactVersion: 3,
|
||||
critical: true
|
||||
}
|
||||
]
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
disposition: 'terminal',
|
||||
reason: 'capability'
|
||||
})
|
||||
|
||||
const workspace = await test.manager.acquire(
|
||||
'host-1',
|
||||
installation
|
||||
)
|
||||
expect(test.connectTransport).toHaveBeenCalledOnce()
|
||||
|
||||
workspace.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('does not persist an unchanged capability refresh', async () => {
|
||||
const test = harness()
|
||||
const lease = await test.manager.acquire('host-1', installation)
|
||||
const writes = test.stateFile.writes
|
||||
|
||||
await lease.refreshCapabilities()
|
||||
|
||||
expect(test.stateFile.writes).toBe(writes)
|
||||
lease.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('isolates cache entries across Host revisions', async () => {
|
||||
const test = harness()
|
||||
const first = await test.manager.acquire('host-1', installation)
|
||||
@@ -256,6 +365,28 @@ describe('RemoteAgentConnectionManager', () => {
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('directs registered installation integrity failures to Host repair', async () => {
|
||||
const test = harness()
|
||||
test.connectTransport.mockRejectedValueOnce(
|
||||
new AgentAttachTransportError(
|
||||
'Agent attach closed during handshake with diagnostic output',
|
||||
'closed',
|
||||
undefined,
|
||||
'installation-repair-required'
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
test.manager.acquire('host-1', installation)
|
||||
).rejects.toMatchObject({
|
||||
message: 'GoodBuddy Agent installation needs repair',
|
||||
disposition: 'terminal',
|
||||
reason: 'protocol'
|
||||
})
|
||||
expect(test.release).toHaveBeenCalledOnce()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('cancels one waiter without canceling a shared connect', async () => {
|
||||
let openTransport!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
@@ -352,11 +483,54 @@ describe('RemoteAgentConnectionManager', () => {
|
||||
reason: 'capability',
|
||||
disposition: 'terminal'
|
||||
})
|
||||
expect(lease.capabilities).toEqual(refreshed)
|
||||
expect(lease.capabilities).toEqual(
|
||||
test.clients[0]!.capabilities
|
||||
)
|
||||
lease.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('deduplicates concurrent capability refreshes for shared leases', async () => {
|
||||
const test = harness()
|
||||
const [first, second] = 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 requestsBefore = test.clients[0]!.requests.filter(
|
||||
(request) => request.method === 'agent/capabilities'
|
||||
).length
|
||||
|
||||
const refreshes = [
|
||||
first.refreshCapabilities(),
|
||||
second.refreshCapabilities()
|
||||
]
|
||||
finishRefresh()
|
||||
|
||||
await expect(Promise.all(refreshes)).resolves.toEqual([
|
||||
test.clients[0]!.capabilities,
|
||||
test.clients[0]!.capabilities
|
||||
])
|
||||
expect(
|
||||
test.clients[0]!.requests.filter(
|
||||
(request) => request.method === 'agent/capabilities'
|
||||
)
|
||||
).toHaveLength(requestsBefore + 1)
|
||||
first.release()
|
||||
second.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', {
|
||||
@@ -395,7 +569,7 @@ describe('RemoteAgentConnectionManager', () => {
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
it('does not publish a capability refresh after its lease is released in flight', async () => {
|
||||
it('publishes a shared capability refresh for a retained lease', async () => {
|
||||
const test = harness()
|
||||
const [refreshing, retained] = await Promise.all([
|
||||
test.manager.acquire('host-1', installation),
|
||||
@@ -418,7 +592,7 @@ describe('RemoteAgentConnectionManager', () => {
|
||||
finishRefresh()
|
||||
|
||||
await expect(refresh).rejects.toThrow('lease is released')
|
||||
expect(retained.capabilities.generation).toBe(1)
|
||||
expect(retained.capabilities.generation).toBe(2)
|
||||
retained.release()
|
||||
await test.manager.dispose()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, createHmac, randomBytes } from 'node:crypto'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
AGENT_PROTOCOL_VERSION,
|
||||
agentCapabilitySchema,
|
||||
@@ -18,7 +19,10 @@ import {
|
||||
type VerifiedAgentInstallationId
|
||||
} from '../ssh/ssh-agent-command'
|
||||
import { defaultSystemAgent } from '../ssh/ssh-transport'
|
||||
import { AgentAttachTransport } from './agent-attach-transport'
|
||||
import {
|
||||
AgentAttachTransport,
|
||||
AgentAttachTransportError
|
||||
} from './agent-attach-transport'
|
||||
import {
|
||||
AgentProtocolClient,
|
||||
type AgentProtocolClientError
|
||||
@@ -141,6 +145,7 @@ type ConnectionEntry = {
|
||||
controller: AbortController
|
||||
connectPromise: Promise<ActiveConnection>
|
||||
reconnectPromise?: Promise<void>
|
||||
capabilityRefreshPromise?: Promise<DaemonCapabilities>
|
||||
connection?: ActiveConnection
|
||||
connectionState: RemoteAgentConnectionState
|
||||
failure?: RemoteAgentConnectionError
|
||||
@@ -283,8 +288,12 @@ export class RemoteAgentConnectionManager {
|
||||
'network'
|
||||
)
|
||||
}
|
||||
this.#validateCallerCapabilities(
|
||||
installation,
|
||||
entry.connection!.capabilities
|
||||
)
|
||||
entry.refs += 1
|
||||
return this.#lease(entry)
|
||||
return this.#lease(entry, installation)
|
||||
} finally {
|
||||
entry.waiters -= 1
|
||||
if (entry.waiters === 0 && entry.refs === 0) {
|
||||
@@ -595,11 +604,22 @@ export class RemoteAgentConnectionManager {
|
||||
'installation'
|
||||
)
|
||||
}
|
||||
if (capabilities.generation < 1) {
|
||||
throw terminalError(
|
||||
'GoodBuddy Agent capability state is invalid',
|
||||
'capability'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#validateCallerCapabilities(
|
||||
installation: RemoteAgentInstallationIdentity,
|
||||
capabilities: DaemonCapabilities
|
||||
): void {
|
||||
if (
|
||||
capabilities.generation < 1 ||
|
||||
!requiredCapabilitiesPresent(
|
||||
capabilities,
|
||||
expected.requiredCapabilities ?? []
|
||||
installation.requiredCapabilities ?? []
|
||||
)
|
||||
) {
|
||||
throw terminalError(
|
||||
@@ -609,7 +629,10 @@ export class RemoteAgentConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
#lease(entry: ConnectionEntry): RemoteAgentConnection {
|
||||
#lease(
|
||||
entry: ConnectionEntry,
|
||||
installation: RemoteAgentInstallationIdentity
|
||||
): RemoteAgentConnection {
|
||||
let released = false
|
||||
const assertRetained = (): void => {
|
||||
if (
|
||||
@@ -650,50 +673,16 @@ export class RemoteAgentConnectionManager {
|
||||
return released ? 'disposed' : entry.connectionState
|
||||
},
|
||||
refreshCapabilities: async (signal) => {
|
||||
const active = assertActive()
|
||||
const client = active.client
|
||||
const generation = client.generation
|
||||
const previousCapabilities = active.capabilities
|
||||
const capabilities = daemonCapabilitiesSchema.parse(
|
||||
await client.request(
|
||||
'agent/capabilities',
|
||||
{},
|
||||
{ signal }
|
||||
)
|
||||
assertActive()
|
||||
const capabilities = await waitForShared(
|
||||
this.#refreshCapabilities(entry),
|
||||
signal
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
const current = assertActive()
|
||||
if (
|
||||
this.#entries.get(entry.key) !== entry ||
|
||||
current !== active ||
|
||||
current.client !== client ||
|
||||
client.generation !== generation ||
|
||||
current.capabilities !== previousCapabilities
|
||||
) {
|
||||
throw new RemoteAgentConnectionError(
|
||||
'Remote Agent capability refresh became stale',
|
||||
'transient',
|
||||
'network'
|
||||
)
|
||||
}
|
||||
this.#validateDaemon(
|
||||
entry,
|
||||
active.transport,
|
||||
active.status,
|
||||
assertRetained()
|
||||
this.#validateCallerCapabilities(
|
||||
installation,
|
||||
capabilities
|
||||
)
|
||||
active.capabilities = capabilities
|
||||
try {
|
||||
await this.#persistConnection(
|
||||
entry,
|
||||
active,
|
||||
await this.#controllerState.getConnection(entry.stateKey),
|
||||
true
|
||||
)
|
||||
} catch (error) {
|
||||
active.capabilities = previousCapabilities
|
||||
throw error
|
||||
}
|
||||
return capabilities
|
||||
},
|
||||
reconnect: async (signal) => {
|
||||
@@ -734,6 +723,84 @@ export class RemoteAgentConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
#refreshCapabilities(
|
||||
entry: ConnectionEntry
|
||||
): Promise<DaemonCapabilities> {
|
||||
if (entry.capabilityRefreshPromise !== undefined) {
|
||||
return entry.capabilityRefreshPromise
|
||||
}
|
||||
const refresh = (async () => {
|
||||
const active = entry.connection
|
||||
if (
|
||||
active === undefined ||
|
||||
entry.connectionState !== 'ready'
|
||||
) {
|
||||
throw (
|
||||
entry.failure ??
|
||||
new RemoteAgentConnectionError(
|
||||
'Remote Agent connection is offline',
|
||||
'transient',
|
||||
'network'
|
||||
)
|
||||
)
|
||||
}
|
||||
const client = active.client
|
||||
const generation = client.generation
|
||||
const previousCapabilities = active.capabilities
|
||||
const capabilities = daemonCapabilitiesSchema.parse(
|
||||
await client.request(
|
||||
'agent/capabilities',
|
||||
{},
|
||||
{ signal: entry.controller.signal }
|
||||
)
|
||||
)
|
||||
const current = entry.connection
|
||||
if (
|
||||
this.#entries.get(entry.key) !== entry ||
|
||||
current !== active ||
|
||||
entry.connectionState !== 'ready' ||
|
||||
current.client !== client ||
|
||||
client.generation !== generation ||
|
||||
current.capabilities !== previousCapabilities
|
||||
) {
|
||||
throw new RemoteAgentConnectionError(
|
||||
'Remote Agent capability refresh became stale',
|
||||
'transient',
|
||||
'network'
|
||||
)
|
||||
}
|
||||
this.#validateDaemon(
|
||||
entry,
|
||||
active.transport,
|
||||
active.status,
|
||||
capabilities
|
||||
)
|
||||
if (isDeepStrictEqual(capabilities, previousCapabilities)) {
|
||||
return previousCapabilities
|
||||
}
|
||||
active.capabilities = capabilities
|
||||
try {
|
||||
await this.#persistConnection(
|
||||
entry,
|
||||
active,
|
||||
await this.#controllerState.getConnection(entry.stateKey),
|
||||
true
|
||||
)
|
||||
} catch (error) {
|
||||
active.capabilities = previousCapabilities
|
||||
throw error
|
||||
}
|
||||
return capabilities
|
||||
})()
|
||||
const shared = refresh.finally(() => {
|
||||
if (entry.capabilityRefreshPromise === shared) {
|
||||
entry.capabilityRefreshPromise = undefined
|
||||
}
|
||||
})
|
||||
entry.capabilityRefreshPromise = shared
|
||||
return shared
|
||||
}
|
||||
|
||||
async #reconnect(
|
||||
entry: ConnectionEntry,
|
||||
signal?: AbortSignal
|
||||
@@ -1034,6 +1101,16 @@ function classifyConnectionError(error: unknown): RemoteAgentConnectionError {
|
||||
if (error instanceof RemoteAgentConnectionError) {
|
||||
return error
|
||||
}
|
||||
if (
|
||||
error instanceof AgentAttachTransportError &&
|
||||
error.diagnostic === 'installation-repair-required'
|
||||
) {
|
||||
return terminalError(
|
||||
'GoodBuddy Agent installation needs repair',
|
||||
'protocol',
|
||||
error
|
||||
)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (
|
||||
/host key|主机密钥|authentication|认证|credential|凭据/iu.test(
|
||||
|
||||
@@ -37,6 +37,8 @@ const target: SshConnectionPoolTarget = {
|
||||
hostRevision: 1,
|
||||
hostKeyGeneration: 1
|
||||
}
|
||||
const controlPlanePackageInstaller =
|
||||
'module.exports = { source: "GoodBuddy" }\n'
|
||||
|
||||
function harness(overrides: {
|
||||
installationDispose?: () => Promise<void>
|
||||
@@ -60,7 +62,8 @@ function harness(overrides: {
|
||||
dispose: vi.fn(overrides.controllerDispose ?? (() => undefined))
|
||||
}
|
||||
const installationManager = {
|
||||
ensureInstalled: vi.fn(),
|
||||
activateInstalled: vi.fn(),
|
||||
invalidateHost: vi.fn(),
|
||||
dispose: vi.fn(
|
||||
overrides.installationDispose ?? (async () => undefined)
|
||||
)
|
||||
@@ -93,7 +96,6 @@ function harness(overrides: {
|
||||
return connectionManager as unknown as RemoteAgentConnectionManager
|
||||
}
|
||||
)
|
||||
const loadAgentBundle = vi.fn()
|
||||
const services = new RemoteAgentServices({
|
||||
sshHostStore,
|
||||
goodBuddyVersion: '0.11.0',
|
||||
@@ -101,9 +103,7 @@ function harness(overrides: {
|
||||
appPath: join('workspace', 'goodbuddy'),
|
||||
resourcesPath: join('installed', 'resources'),
|
||||
packaged: false,
|
||||
agentPackageManager: {
|
||||
loadAgentBundle
|
||||
} as never,
|
||||
controlPlanePackageInstaller,
|
||||
factories: {
|
||||
createSshConnectionPool:
|
||||
createSshConnectionPool as never,
|
||||
@@ -123,8 +123,7 @@ function harness(overrides: {
|
||||
createSshConnectionPool,
|
||||
createControllerStateStore,
|
||||
createAgentInstallationManager,
|
||||
createConnectionManager,
|
||||
loadAgentBundle
|
||||
createConnectionManager
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +136,9 @@ describe('RemoteAgentServices', () => {
|
||||
instance.createConnectionManager.mock.calls[0]?.[0]
|
||||
|
||||
expect(instance.createSshConnectionPool).toHaveBeenCalledOnce()
|
||||
expect(instance.createSshConnectionPool).toHaveBeenCalledWith({
|
||||
controlPlanePackageInstaller
|
||||
})
|
||||
expect(installerOptions?.resolver).toBe(
|
||||
connectionOptions?.resolver
|
||||
)
|
||||
@@ -145,9 +147,6 @@ describe('RemoteAgentServices', () => {
|
||||
)
|
||||
expect(installerOptions?.sshPool).toBe(connectionOptions?.sshPool)
|
||||
expect(installerOptions?.sshPool).toBe(instance.services.sshPool)
|
||||
expect(installerOptions?.packageBundleLoader).toBe(
|
||||
instance.loadAgentBundle
|
||||
)
|
||||
|
||||
await expect(
|
||||
instance.services.targetResolver.resolve('host-1')
|
||||
@@ -162,7 +161,7 @@ describe('RemoteAgentServices', () => {
|
||||
|
||||
expect(instance.resolveConnectionTarget).not.toHaveBeenCalled()
|
||||
expect(instance.sshPool.acquire).not.toHaveBeenCalled()
|
||||
expect(instance.installationManager.ensureInstalled).not.toHaveBeenCalled()
|
||||
expect(instance.installationManager.activateInstalled).not.toHaveBeenCalled()
|
||||
expect(instance.connectionManager.acquire).not.toHaveBeenCalled()
|
||||
expect(instance.services.resourcePaths).toEqual({
|
||||
keyRegistryPath: join(
|
||||
@@ -213,9 +212,7 @@ describe('RemoteAgentServices', () => {
|
||||
appPath: join('workspace', 'goodbuddy'),
|
||||
resourcesPath: join('installed', 'resources'),
|
||||
packaged: false,
|
||||
agentPackageManager: {
|
||||
loadAgentBundle: vi.fn()
|
||||
} as never
|
||||
controlPlanePackageInstaller,
|
||||
})
|
||||
|
||||
expect(resolveConnectionTarget).not.toHaveBeenCalled()
|
||||
@@ -233,6 +230,9 @@ describe('RemoteAgentServices', () => {
|
||||
|
||||
const invalidation = instance.services.invalidateHost('host-1')
|
||||
|
||||
expect(
|
||||
instance.installationManager.invalidateHost
|
||||
).toHaveBeenCalledWith('host-1')
|
||||
expect(invalidateHost).toHaveBeenCalledWith('host-1')
|
||||
expect(invalidation).toBe(persistence)
|
||||
finishPersistence?.()
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { join } from 'node:path'
|
||||
import { SshConnectionPool } from '../ssh/ssh-connection-pool'
|
||||
import type { SshHostStore } from '../ssh/ssh-host-store'
|
||||
import {
|
||||
AgentInstallationManager,
|
||||
type AgentInstallationBundleLoader
|
||||
} from './agent-installation-manager'
|
||||
import { AgentInstallationManager } from './agent-installation-manager'
|
||||
import {
|
||||
resolveBundledAgentResourcePaths,
|
||||
type BundledAgentResourcePaths
|
||||
@@ -17,14 +14,13 @@ import {
|
||||
RemoteAgentConnectionManager,
|
||||
type RemoteAgentTargetResolver
|
||||
} from './remote-agent-connection-manager'
|
||||
import type {
|
||||
AgentPackageManager
|
||||
} from './agent-package-manager'
|
||||
|
||||
const CONTROLLER_STATE_FILE_NAME = 'remote-agent-controller-state.json'
|
||||
|
||||
type RemoteAgentServiceFactories = {
|
||||
createSshConnectionPool: () => SshConnectionPool
|
||||
createSshConnectionPool: (options: {
|
||||
controlPlanePackageInstaller: Buffer | string
|
||||
}) => SshConnectionPool
|
||||
createControllerStateStore: (
|
||||
filePath: string
|
||||
) => ControllerStateStore
|
||||
@@ -32,7 +28,6 @@ type RemoteAgentServiceFactories = {
|
||||
resolver: RemoteAgentTargetResolver
|
||||
sshPool: SshConnectionPool
|
||||
resourcePaths: BundledAgentResourcePaths
|
||||
packageBundleLoader: AgentInstallationBundleLoader
|
||||
}) => AgentInstallationManager
|
||||
createConnectionManager: (options: {
|
||||
resolver: RemoteAgentTargetResolver
|
||||
@@ -49,10 +44,7 @@ export type RemoteAgentServicesOptions = {
|
||||
appPath: string
|
||||
resourcesPath: string
|
||||
packaged: boolean
|
||||
agentPackageManager: Pick<
|
||||
AgentPackageManager,
|
||||
'loadAgentBundle'
|
||||
>
|
||||
controlPlanePackageInstaller: Buffer | string
|
||||
factories?: Partial<RemoteAgentServiceFactories>
|
||||
}
|
||||
|
||||
@@ -74,7 +66,8 @@ export class RemoteAgentServices {
|
||||
|
||||
constructor(options: RemoteAgentServicesOptions) {
|
||||
const factories: RemoteAgentServiceFactories = {
|
||||
createSshConnectionPool: () => new SshConnectionPool(),
|
||||
createSshConnectionPool: (poolOptions) =>
|
||||
new SshConnectionPool(undefined, poolOptions),
|
||||
createControllerStateStore: (filePath) =>
|
||||
new ControllerStateStore(new JsonControllerStateFile(filePath)),
|
||||
createAgentInstallationManager: (managerOptions) =>
|
||||
@@ -93,7 +86,10 @@ export class RemoteAgentServices {
|
||||
resourcesPath: options.resourcesPath,
|
||||
packaged: options.packaged
|
||||
})
|
||||
this.sshPool = factories.createSshConnectionPool()
|
||||
this.sshPool = factories.createSshConnectionPool({
|
||||
controlPlanePackageInstaller:
|
||||
options.controlPlanePackageInstaller
|
||||
})
|
||||
this.controllerState = factories.createControllerStateStore(
|
||||
join(options.userDataPath, CONTROLLER_STATE_FILE_NAME)
|
||||
)
|
||||
@@ -101,9 +97,7 @@ export class RemoteAgentServices {
|
||||
factories.createAgentInstallationManager({
|
||||
resolver: this.targetResolver,
|
||||
sshPool: this.sshPool,
|
||||
resourcePaths: this.resourcePaths,
|
||||
packageBundleLoader:
|
||||
options.agentPackageManager.loadAgentBundle
|
||||
resourcePaths: this.resourcePaths
|
||||
})
|
||||
this.connectionManager = factories.createConnectionManager({
|
||||
resolver: this.targetResolver,
|
||||
@@ -119,6 +113,7 @@ export class RemoteAgentServices {
|
||||
* credential-free SSH host lifecycle hook starts fail-closed synchronously.
|
||||
*/
|
||||
invalidateHost(hostId: string): Promise<void> {
|
||||
this.installationManager.invalidateHost(hostId)
|
||||
return this.connectionManager.invalidateHost(hostId)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
SftpEntryMetadata,
|
||||
StagedSftp
|
||||
} from '../ssh/bounded-sftp'
|
||||
import {
|
||||
REMOTE_ENVIRONMENT_METADATA_PATHS,
|
||||
restoreRemoteEnvironmentMetadata,
|
||||
snapshotRemoteEnvironmentMetadata,
|
||||
type RemoteEnvironmentMetadataSnapshot
|
||||
} from './remote-environment-metadata'
|
||||
|
||||
const UID = 1000
|
||||
|
||||
function missingPath(): Error & { code: string } {
|
||||
return Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
function fileMetadata(
|
||||
contents: Buffer,
|
||||
overrides: Partial<SftpEntryMetadata> = {}
|
||||
): SftpEntryMetadata {
|
||||
return {
|
||||
type: 'file',
|
||||
size: contents.byteLength,
|
||||
mode: 0o600,
|
||||
uid: UID,
|
||||
gid: UID,
|
||||
atime: 0,
|
||||
mtime: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createSftp(
|
||||
initial: ReadonlyMap<string, Buffer>,
|
||||
metadataOverrides: ReadonlyMap<string, Partial<SftpEntryMetadata>> =
|
||||
new Map()
|
||||
) {
|
||||
const files = new Map(
|
||||
[...initial].map(([path, contents]) => [path, Buffer.from(contents)])
|
||||
)
|
||||
const temporary = new Map<string, Buffer>()
|
||||
const sftp: StagedSftp = {
|
||||
stagingDirectory: '/home/goodbuddy',
|
||||
mkdir: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
writeFile: vi.fn(async (path, contents) => {
|
||||
temporary.set(path, Buffer.from(contents))
|
||||
}),
|
||||
readFile: vi.fn(async (path) => {
|
||||
const contents = files.get(path) ?? temporary.get(path)
|
||||
if (!contents) throw missingPath()
|
||||
return Buffer.from(contents)
|
||||
}),
|
||||
lstat: vi.fn(async (path) => {
|
||||
const contents = files.get(path) ?? temporary.get(path)
|
||||
if (!contents) throw missingPath()
|
||||
return fileMetadata(
|
||||
contents,
|
||||
metadataOverrides.get(path)
|
||||
)
|
||||
}),
|
||||
stat: vi.fn(),
|
||||
chmod: vi.fn(),
|
||||
setExecutable: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
replaceFile: vi.fn(async (source, destination) => {
|
||||
const contents = temporary.get(source)
|
||||
if (!contents) throw missingPath()
|
||||
files.set(destination, Buffer.from(contents))
|
||||
temporary.delete(source)
|
||||
}),
|
||||
unlink: vi.fn(async (path) => {
|
||||
if (!files.delete(path) && !temporary.delete(path)) {
|
||||
throw missingPath()
|
||||
}
|
||||
}),
|
||||
rmdir: vi.fn(),
|
||||
close: vi.fn()
|
||||
}
|
||||
return { sftp, files, temporary }
|
||||
}
|
||||
|
||||
describe('remote environment metadata', () => {
|
||||
it('snapshots original bytes and restores them exactly', async () => {
|
||||
const originals = new Map(
|
||||
REMOTE_ENVIRONMENT_METADATA_PATHS.map((path, index) => [
|
||||
path,
|
||||
Buffer.from([0, index, 255, 10])
|
||||
])
|
||||
)
|
||||
const value = createSftp(originals)
|
||||
const snapshots = await snapshotRemoteEnvironmentMetadata(
|
||||
value.sftp,
|
||||
UID,
|
||||
new AbortController().signal
|
||||
)
|
||||
for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) {
|
||||
value.files.set(path, Buffer.from('changed'))
|
||||
}
|
||||
|
||||
await restoreRemoteEnvironmentMetadata(value.sftp, snapshots)
|
||||
|
||||
expect(
|
||||
REMOTE_ENVIRONMENT_METADATA_PATHS.map((path) =>
|
||||
value.files.get(path)
|
||||
)
|
||||
).toEqual(
|
||||
REMOTE_ENVIRONMENT_METADATA_PATHS.map((path) =>
|
||||
originals.get(path)
|
||||
)
|
||||
)
|
||||
expect(value.sftp.chmod).toHaveBeenCalledTimes(5)
|
||||
expect(value.sftp.replaceFile).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('records missing metadata and removes files created after the snapshot', async () => {
|
||||
const value = createSftp(new Map())
|
||||
const snapshots = await snapshotRemoteEnvironmentMetadata(
|
||||
value.sftp,
|
||||
UID,
|
||||
new AbortController().signal
|
||||
)
|
||||
for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) {
|
||||
value.files.set(path, Buffer.from('new'))
|
||||
}
|
||||
|
||||
await restoreRemoteEnvironmentMetadata(value.sftp, snapshots)
|
||||
|
||||
expect(value.files.size).toBe(0)
|
||||
expect(value.sftp.unlink).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('distinguishes an empty original file from a missing file', async () => {
|
||||
const path = REMOTE_ENVIRONMENT_METADATA_PATHS[0]
|
||||
const value = createSftp(
|
||||
new Map([[path, Buffer.alloc(0)]])
|
||||
)
|
||||
const snapshots = await snapshotRemoteEnvironmentMetadata(
|
||||
value.sftp,
|
||||
UID,
|
||||
new AbortController().signal
|
||||
)
|
||||
value.files.set(path, Buffer.from('changed'))
|
||||
|
||||
await restoreRemoteEnvironmentMetadata(value.sftp, snapshots)
|
||||
|
||||
expect(value.files.get(path)).toEqual(Buffer.alloc(0))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['permissions', { mode: 0o644 }],
|
||||
['owner', { uid: UID + 1 }],
|
||||
['symbolic link', { type: 'symbolic-link' as const }]
|
||||
])('rejects metadata with invalid %s', async (_label, override) => {
|
||||
const path = REMOTE_ENVIRONMENT_METADATA_PATHS[0]
|
||||
const contents = Buffer.from('metadata')
|
||||
const value = createSftp(
|
||||
new Map([[path, contents]]),
|
||||
new Map([[path, override]])
|
||||
)
|
||||
|
||||
await expect(snapshotRemoteEnvironmentMetadata(
|
||||
value.sftp,
|
||||
UID,
|
||||
new AbortController().signal
|
||||
)).rejects.toThrow('类型、所有者或权限无效')
|
||||
expect(value.sftp.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues restoring remaining files and aggregates every error', async () => {
|
||||
const first = REMOTE_ENVIRONMENT_METADATA_PATHS[0]
|
||||
const second = REMOTE_ENVIRONMENT_METADATA_PATHS[1]
|
||||
const third = REMOTE_ENVIRONMENT_METADATA_PATHS[2]
|
||||
const value = createSftp(new Map([
|
||||
[first, Buffer.from('changed-1')],
|
||||
[second, Buffer.from('changed-2')],
|
||||
[third, Buffer.from('created')]
|
||||
]))
|
||||
const firstError = new Error('first replace failed')
|
||||
const secondError = new Error('second replace failed')
|
||||
vi.mocked(value.sftp.replaceFile).mockImplementation(
|
||||
async (_source, destination) => {
|
||||
throw destination === first ? firstError : secondError
|
||||
}
|
||||
)
|
||||
const snapshots: readonly RemoteEnvironmentMetadataSnapshot[] = [
|
||||
{ path: first, contents: Buffer.from('original-1'), uid: UID },
|
||||
{ path: second, contents: Buffer.from('original-2'), uid: UID },
|
||||
{ path: third, uid: UID }
|
||||
]
|
||||
|
||||
const caught = await restoreRemoteEnvironmentMetadata(
|
||||
value.sftp,
|
||||
snapshots
|
||||
).catch((error: unknown) => error)
|
||||
|
||||
expect(caught).toBeInstanceOf(AggregateError)
|
||||
expect((caught as AggregateError).errors).toEqual([
|
||||
firstError,
|
||||
secondError
|
||||
])
|
||||
expect(value.sftp.replaceFile).toHaveBeenCalledTimes(2)
|
||||
expect(value.files.has(third)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { StagedSftp } from '../ssh/bounded-sftp'
|
||||
import { isMissingPathError } from './path-errors'
|
||||
|
||||
const PRIVATE_FILE_MODE = 0o600
|
||||
|
||||
export const REMOTE_ENVIRONMENT_METADATA_PATHS = [
|
||||
'.goodbuddy/agent/release-keys.json',
|
||||
'.goodbuddy/agent/registry.json',
|
||||
'.goodbuddy/runtimes/release-keys.json',
|
||||
'.goodbuddy/runtimes/remote-runtime-lock.json',
|
||||
'.goodbuddy/runtimes/registry.json'
|
||||
] as const
|
||||
|
||||
export type RemoteEnvironmentMetadataSnapshot = {
|
||||
path: (typeof REMOTE_ENVIRONMENT_METADATA_PATHS)[number]
|
||||
contents?: Buffer
|
||||
uid: number
|
||||
}
|
||||
|
||||
export async function snapshotRemoteEnvironmentMetadata(
|
||||
sftp: StagedSftp,
|
||||
uid: number,
|
||||
signal: AbortSignal
|
||||
): Promise<readonly RemoteEnvironmentMetadataSnapshot[]> {
|
||||
const snapshots: RemoteEnvironmentMetadataSnapshot[] = []
|
||||
for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) {
|
||||
signal.throwIfAborted()
|
||||
const metadata = await metadataIfPresent(sftp, path, signal)
|
||||
if (!metadata) {
|
||||
snapshots.push({ path, uid })
|
||||
continue
|
||||
}
|
||||
assertPrivateFile(metadata, path, uid)
|
||||
snapshots.push({
|
||||
path,
|
||||
contents: await sftp.readFile(path, signal),
|
||||
uid
|
||||
})
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
export async function restoreRemoteEnvironmentMetadata(
|
||||
sftp: StagedSftp,
|
||||
snapshots: readonly RemoteEnvironmentMetadataSnapshot[]
|
||||
): Promise<void> {
|
||||
const errors: unknown[] = []
|
||||
for (const snapshot of snapshots) {
|
||||
try {
|
||||
await restoreRemoteEnvironmentMetadataFile(sftp, snapshot)
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
'远程运行环境元数据回滚不完整'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreRemoteEnvironmentMetadataFile(
|
||||
sftp: StagedSftp,
|
||||
snapshot: RemoteEnvironmentMetadataSnapshot
|
||||
): Promise<void> {
|
||||
const existing = await metadataIfPresent(sftp, snapshot.path)
|
||||
if (snapshot.contents === undefined) {
|
||||
if (existing) {
|
||||
assertPrivateFile(existing, snapshot.path, snapshot.uid)
|
||||
await sftp.unlink(snapshot.path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
assertPrivateFile(existing, snapshot.path, snapshot.uid)
|
||||
if (
|
||||
existing.size === snapshot.contents.byteLength &&
|
||||
(await sftp.readFile(snapshot.path)).equals(snapshot.contents)
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const temporary =
|
||||
`.goodbuddy/.environment-metadata-rollback-${randomUUID()}.tmp`
|
||||
try {
|
||||
await sftp.writeFile(temporary, snapshot.contents)
|
||||
await sftp.chmod(temporary, PRIVATE_FILE_MODE)
|
||||
await sftp.replaceFile(temporary, snapshot.path)
|
||||
const restored = await sftp.lstat(snapshot.path)
|
||||
assertPrivateFile(restored, snapshot.path, snapshot.uid)
|
||||
if (
|
||||
restored.size !== snapshot.contents.byteLength ||
|
||||
!(await sftp.readFile(snapshot.path)).equals(snapshot.contents)
|
||||
) {
|
||||
throw new Error(
|
||||
`远程运行环境元数据 ${snapshot.path} 回滚校验失败`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
await sftp.unlink(temporary).catch((cleanupError: unknown) => {
|
||||
if (!isMissingPathError(cleanupError)) {
|
||||
throw cleanupError
|
||||
}
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function metadataIfPresent(
|
||||
sftp: StagedSftp,
|
||||
path: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
try {
|
||||
return await sftp.lstat(path, signal)
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function assertPrivateFile(
|
||||
metadata: Awaited<ReturnType<StagedSftp['lstat']>>,
|
||||
path: string,
|
||||
uid: number
|
||||
): void {
|
||||
if (
|
||||
metadata.type !== 'file' ||
|
||||
metadata.uid !== uid ||
|
||||
metadata.mode !== PRIVATE_FILE_MODE
|
||||
) {
|
||||
throw new Error(
|
||||
`远程运行环境元数据 ${path} 的类型、所有者或权限无效`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RemoteEnvironmentOperationStore,
|
||||
type PendingRemoteEnvironmentOperation
|
||||
} from './remote-environment-operation-store'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const operation: PendingRemoteEnvironmentOperation = {
|
||||
version: 1,
|
||||
hostId: 'host-1',
|
||||
targetIdentity: 'a'.repeat(64),
|
||||
operationId: '00000000-0000-4000-8000-000000000777',
|
||||
size: 123,
|
||||
sha256: 'b'.repeat(64),
|
||||
urls: []
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((path) =>
|
||||
rm(path, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
describe('RemoteEnvironmentOperationStore', () => {
|
||||
it('persists one bounded operation per Host and removes it', async () => {
|
||||
const root = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-environment-operation-')
|
||||
)
|
||||
temporaryDirectories.push(root)
|
||||
const store = new RemoteEnvironmentOperationStore(root)
|
||||
|
||||
await store.save(operation)
|
||||
await expect(store.load(operation.hostId)).resolves.toEqual(
|
||||
operation
|
||||
)
|
||||
await store.remove(operation.hostId)
|
||||
await expect(store.load(operation.hostId)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a mismatched or oversized recovery record', async () => {
|
||||
const root = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-environment-operation-')
|
||||
)
|
||||
temporaryDirectories.push(root)
|
||||
const store = new RemoteEnvironmentOperationStore(root)
|
||||
await store.save(operation)
|
||||
const files = join(
|
||||
root,
|
||||
'remote-components',
|
||||
'pending-environment-operations'
|
||||
)
|
||||
const { readdir } = await import('node:fs/promises')
|
||||
const [record] = await readdir(files)
|
||||
await writeFile(
|
||||
join(files, record!),
|
||||
Buffer.alloc(64 * 1024 + 1)
|
||||
)
|
||||
|
||||
await expect(store.load(operation.hostId)).rejects.toThrow(
|
||||
'超过大小限制'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
isMissingFileError,
|
||||
writeJsonFileAtomically
|
||||
} from '../settings-file-utils'
|
||||
|
||||
const MAXIMUM_RECORD_BYTES = 64 * 1024
|
||||
const OPERATION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/u
|
||||
|
||||
export type PendingRemoteEnvironmentOperation = {
|
||||
version: 1
|
||||
hostId: string
|
||||
targetIdentity: string
|
||||
operationId: string
|
||||
size: number
|
||||
sha256: string
|
||||
urls: readonly string[]
|
||||
}
|
||||
|
||||
export class RemoteEnvironmentOperationStore {
|
||||
readonly #directory: string
|
||||
|
||||
constructor(userDataPath: string) {
|
||||
this.#directory = resolve(
|
||||
userDataPath,
|
||||
'remote-components',
|
||||
'pending-environment-operations'
|
||||
)
|
||||
}
|
||||
|
||||
async load(
|
||||
hostId: string
|
||||
): Promise<PendingRemoteEnvironmentOperation | undefined> {
|
||||
const path = this.#path(hostId)
|
||||
let bytes: Buffer
|
||||
try {
|
||||
bytes = await readFile(path)
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (bytes.byteLength > MAXIMUM_RECORD_BYTES) {
|
||||
throw new Error('远程环境恢复记录超过大小限制')
|
||||
}
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error('远程环境恢复记录无效', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
return parsePendingOperation(value, hostId)
|
||||
}
|
||||
|
||||
save(operation: PendingRemoteEnvironmentOperation): Promise<void> {
|
||||
return writeJsonFileAtomically(
|
||||
this.#path(operation.hostId),
|
||||
parsePendingOperation(operation, operation.hostId)
|
||||
)
|
||||
}
|
||||
|
||||
remove(hostId: string): Promise<void> {
|
||||
return rm(this.#path(hostId), { force: true })
|
||||
}
|
||||
|
||||
#path(hostId: string): string {
|
||||
if (
|
||||
!hostId ||
|
||||
hostId.length > 512 ||
|
||||
/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(hostId)
|
||||
) {
|
||||
throw new Error('远程环境恢复 Host ID 无效')
|
||||
}
|
||||
const name = createHash('sha256')
|
||||
.update(hostId)
|
||||
.digest('hex')
|
||||
return join(this.#directory, `${name}.json`)
|
||||
}
|
||||
}
|
||||
|
||||
function parsePendingOperation(
|
||||
value: unknown,
|
||||
expectedHostId: string
|
||||
): PendingRemoteEnvironmentOperation {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
[
|
||||
'hostId',
|
||||
'operationId',
|
||||
'sha256',
|
||||
'size',
|
||||
'targetIdentity',
|
||||
'urls',
|
||||
'version'
|
||||
].sort().join('\0')
|
||||
) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
if (
|
||||
record.version !== 1 ||
|
||||
record.hostId !== expectedHostId ||
|
||||
typeof record.targetIdentity !== 'string' ||
|
||||
!SHA256_PATTERN.test(record.targetIdentity) ||
|
||||
typeof record.operationId !== 'string' ||
|
||||
!OPERATION_ID_PATTERN.test(record.operationId) ||
|
||||
!Number.isSafeInteger(record.size) ||
|
||||
Number(record.size) <= 0 ||
|
||||
typeof record.sha256 !== 'string' ||
|
||||
!SHA256_PATTERN.test(record.sha256) ||
|
||||
!Array.isArray(record.urls) ||
|
||||
record.urls.length > 4 ||
|
||||
record.urls.some(
|
||||
(url) =>
|
||||
typeof url !== 'string' ||
|
||||
Buffer.byteLength(url, 'utf8') > 4_096
|
||||
)
|
||||
) {
|
||||
throw new Error('远程环境恢复记录无效')
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
hostId: expectedHostId,
|
||||
targetIdentity: record.targetIdentity,
|
||||
operationId: record.operationId,
|
||||
size: Number(record.size),
|
||||
sha256: record.sha256,
|
||||
urls: [...record.urls] as string[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SshConnectionPoolTarget } from '../ssh/ssh-connection-pool'
|
||||
import {
|
||||
SshRemotePackageCommitIndeterminateError,
|
||||
type SshRemotePackageIdentity
|
||||
} from '../ssh/ssh-remote-package-bootstrap'
|
||||
import type { StagedSftp } from '../ssh/bounded-sftp'
|
||||
import type {
|
||||
AgentPackageArchiveLease,
|
||||
VerifiedRemoteAgentInstallCandidate
|
||||
} from './agent-package-manager'
|
||||
import { REMOTE_ENVIRONMENT_METADATA_PATHS } from './remote-environment-metadata'
|
||||
import type {
|
||||
PendingRemoteEnvironmentOperation
|
||||
} from './remote-environment-operation-store'
|
||||
import { RemoteEnvironmentPreparer } from './remote-environment-preparer'
|
||||
import {
|
||||
remoteHostRecoveryIdentityKey
|
||||
} from './remote-host-target-identity'
|
||||
|
||||
const HOST_ID = 'host-1'
|
||||
const SHA256 = 'a'.repeat(64)
|
||||
const NODE_SHA256 = 'b'.repeat(64)
|
||||
|
||||
function target(
|
||||
overrides: Partial<SshConnectionPoolTarget> = {}
|
||||
): SshConnectionPoolTarget {
|
||||
return {
|
||||
host: {
|
||||
id: HOST_ID,
|
||||
name: 'Builder',
|
||||
hostname: 'builder.example',
|
||||
port: 22,
|
||||
username: 'goodbuddy',
|
||||
authentication: 'password',
|
||||
password: 'secret',
|
||||
hostKey: {
|
||||
algorithm: 'ssh-ed25519',
|
||||
publicKeyBase64: 'a2V5',
|
||||
fingerprintSha256: `SHA256:${'b'.repeat(43)}`,
|
||||
generation: 2
|
||||
}
|
||||
},
|
||||
hostRevision: 3,
|
||||
hostKeyGeneration: 2,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const candidate: VerifiedRemoteAgentInstallCandidate = {
|
||||
source: 'mirror',
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
version: '2.0.0',
|
||||
minimumDesktopVersion: '1.0.0',
|
||||
agentProtocol: { major: 2, minor: 1 },
|
||||
remoteRuntime: {
|
||||
runtimeId: 'opencode',
|
||||
provider: 'opencode',
|
||||
version: '1.2.3',
|
||||
bundleDigest: `sha256:${'c'.repeat(64)}`,
|
||||
protocol: { major: 1, minor: 0 }
|
||||
},
|
||||
archive: 'goodbuddy-agent-2.0.0-linux-x64.gbagent',
|
||||
size: 123,
|
||||
sha256: SHA256,
|
||||
urls: [
|
||||
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/v2.0.0/package.gbagent'
|
||||
]
|
||||
}
|
||||
|
||||
const identity: SshRemotePackageIdentity = {
|
||||
archiveSha256: SHA256,
|
||||
agent: {
|
||||
installationId: `agent-${'d'.repeat(64)}`,
|
||||
agentVersion: '2.0.0',
|
||||
manifestSha256: 'd'.repeat(64),
|
||||
binaryDigest: `sha256:${'e'.repeat(64)}`,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
protocol: { major: 2, minor: 1 },
|
||||
supervisor: 'detached-on-demand'
|
||||
},
|
||||
runtime: {
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: '1.2.3',
|
||||
bundleDigest: `sha256:${'c'.repeat(64)}`,
|
||||
manifestDigest: `sha256:${'f'.repeat(64)}`,
|
||||
runtimeAdapterDigest: `sha256:${'1'.repeat(64)}`,
|
||||
acpCapabilitiesDigest: `sha256:${'2'.repeat(64)}`,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
protocol: { major: 1, minor: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
type FixtureOptions = {
|
||||
targets?: SshConnectionPoolTarget[]
|
||||
probeAvailable?: boolean
|
||||
prepare?: ReturnType<typeof vi.fn>
|
||||
prepareUploaded?: ReturnType<typeof vi.fn>
|
||||
commit?: ReturnType<typeof vi.fn>
|
||||
uploadFile?: ReturnType<typeof vi.fn>
|
||||
activateAgent?: ReturnType<typeof vi.fn>
|
||||
activateRuntime?: ReturnType<typeof vi.fn>
|
||||
metadataFiles?: Map<string, Buffer>
|
||||
metadataReplaceFailure?: Error
|
||||
pendingOperation?: PendingRemoteEnvironmentOperation
|
||||
}
|
||||
|
||||
function missingPath(): Error & { code: string } {
|
||||
return Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
function metadataSftp(
|
||||
files: Map<string, Buffer>,
|
||||
calls: string[],
|
||||
replaceFailure?: Error
|
||||
): StagedSftp {
|
||||
const temporary = new Map<string, Buffer>()
|
||||
return {
|
||||
stagingDirectory: '/home/goodbuddy',
|
||||
mkdir: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
writeFile: vi.fn(async (path: string, contents: Buffer) => {
|
||||
temporary.set(path, Buffer.from(contents))
|
||||
}),
|
||||
readFile: vi.fn(async (path: string) => {
|
||||
const contents = files.get(path) ?? temporary.get(path)
|
||||
if (!contents) throw missingPath()
|
||||
return Buffer.from(contents)
|
||||
}),
|
||||
lstat: vi.fn(async (path: string) => {
|
||||
const contents = files.get(path) ?? temporary.get(path)
|
||||
if (!contents) throw missingPath()
|
||||
return {
|
||||
type: 'file' as const,
|
||||
size: contents.byteLength,
|
||||
mode: 0o600,
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
atime: 0,
|
||||
mtime: 0
|
||||
}
|
||||
}),
|
||||
stat: vi.fn(),
|
||||
chmod: vi.fn(),
|
||||
setExecutable: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
replaceFile: vi.fn(async (source: string, destination: string) => {
|
||||
if (replaceFailure) throw replaceFailure
|
||||
const contents = temporary.get(source)
|
||||
if (!contents) throw missingPath()
|
||||
files.set(destination, contents)
|
||||
temporary.delete(source)
|
||||
}),
|
||||
unlink: vi.fn(async (path: string) => {
|
||||
if (!files.delete(path) && !temporary.delete(path)) {
|
||||
throw missingPath()
|
||||
}
|
||||
}),
|
||||
rmdir: vi.fn(),
|
||||
close: vi.fn(() => calls.push('close-metadata'))
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(options: FixtureOptions = {}) {
|
||||
const calls: string[] = []
|
||||
const targets = options.targets ?? [target()]
|
||||
let resolveIndex = 0
|
||||
const resolver = {
|
||||
resolve: vi.fn(async () => {
|
||||
calls.push('resolve')
|
||||
return targets[Math.min(resolveIndex++, targets.length - 1)]!
|
||||
})
|
||||
}
|
||||
const leaseIdentity = {
|
||||
hostId: targets[0]!.host.id,
|
||||
hostRevision: targets[0]!.hostRevision,
|
||||
hostKeyGeneration: targets[0]!.hostKeyGeneration,
|
||||
authenticationIdentity: '3'.repeat(64)
|
||||
}
|
||||
const metadataFiles = options.metadataFiles ?? new Map<string, Buffer>()
|
||||
const metadata = metadataSftp(
|
||||
metadataFiles,
|
||||
calls,
|
||||
options.metadataReplaceFailure
|
||||
)
|
||||
const uploadFile = options.uploadFile ?? vi.fn(async (
|
||||
path: string
|
||||
) => {
|
||||
calls.push(`upload:${path}`)
|
||||
})
|
||||
const uploadSftp = {
|
||||
stagingDirectory: '/home/goodbuddy/.goodbuddy/install-operations/op',
|
||||
uploadFile,
|
||||
setExecutable: vi.fn(async () => {
|
||||
calls.push('executable')
|
||||
}),
|
||||
close: vi.fn(() => calls.push('close-upload'))
|
||||
}
|
||||
const regularRelease = vi.fn(() => calls.push('release-probe'))
|
||||
const stopCandidate = vi.fn(async () => {
|
||||
calls.push('stop-candidate')
|
||||
})
|
||||
const regularLease = {
|
||||
identity: leaseIdentity,
|
||||
isUsable: vi.fn(() => true),
|
||||
runAgentBootstrapProbe: vi.fn(async () => {
|
||||
calls.push('platform-probe')
|
||||
return {
|
||||
ready: true,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
canonicalHomeDirectory: '/home/goodbuddy',
|
||||
uid: 1000,
|
||||
shell: '/bin/sh',
|
||||
procfs: 'ready'
|
||||
}
|
||||
}),
|
||||
openStagedSftp: vi.fn(async (root: string) => {
|
||||
if (root.includes('install-operations')) {
|
||||
calls.push('open-upload')
|
||||
return uploadSftp
|
||||
}
|
||||
calls.push('open-metadata')
|
||||
return metadata
|
||||
}),
|
||||
runAgentLifecycleAction: stopCandidate,
|
||||
release: regularRelease
|
||||
}
|
||||
const prepare = options.prepare ?? vi.fn(async (
|
||||
input: { operationId: string },
|
||||
prepareOptions: {
|
||||
onProgress?: (progress: { phase: 'downloading' | 'verifying' }) => void
|
||||
}
|
||||
) => {
|
||||
calls.push('prepare')
|
||||
prepareOptions.onProgress?.({ phase: 'downloading' })
|
||||
prepareOptions.onProgress?.({ phase: 'verifying' })
|
||||
return {
|
||||
prepared: true,
|
||||
operationId: input.operationId,
|
||||
identity
|
||||
}
|
||||
})
|
||||
const prepareUploaded = options.prepareUploaded ?? vi.fn(async (
|
||||
input: { operationId: string }
|
||||
) => {
|
||||
calls.push('prepare-uploaded')
|
||||
return {
|
||||
prepared: true,
|
||||
operationId: input.operationId,
|
||||
identity
|
||||
}
|
||||
})
|
||||
const commit = options.commit ?? vi.fn(async (
|
||||
input: { operationId: string }
|
||||
) => {
|
||||
calls.push('commit')
|
||||
return {
|
||||
committed: true,
|
||||
operationId: input.operationId,
|
||||
identity: structuredClone(identity)
|
||||
}
|
||||
})
|
||||
const cleanup = vi.fn(async (operationId: string) => {
|
||||
calls.push('cleanup')
|
||||
return { cleaned: true, operationId }
|
||||
})
|
||||
const commitStatus = vi.fn(async (
|
||||
input: { operationId: string }
|
||||
) => ({
|
||||
committed: true,
|
||||
operationId: input.operationId,
|
||||
identity
|
||||
}))
|
||||
const bootstrapRelease = vi.fn(() => calls.push('release-bootstrap'))
|
||||
const probe = vi.fn(async () => {
|
||||
calls.push('method-probe')
|
||||
return options.probeAvailable === false
|
||||
? { available: false, reason: 'curl-unavailable' }
|
||||
: { available: true }
|
||||
})
|
||||
const createUploadStaging = vi.fn(async (
|
||||
input: { operationId: string }
|
||||
) => {
|
||||
calls.push('create-upload-staging')
|
||||
const root =
|
||||
`/home/goodbuddy/.goodbuddy/install-operations/${input.operationId}`
|
||||
return {
|
||||
created: true,
|
||||
operationId: input.operationId,
|
||||
archivePath: `${root}/package.gbagent`,
|
||||
bootstrapNodePath: `${root}/bootstrap/agent/node`
|
||||
}
|
||||
})
|
||||
const bootstrapLease = {
|
||||
identity: { ...leaseIdentity },
|
||||
isUsable: vi.fn(() => true),
|
||||
probe,
|
||||
createUploadStaging,
|
||||
prepare,
|
||||
prepareUploaded,
|
||||
commit,
|
||||
commitStatus,
|
||||
cleanup,
|
||||
release: bootstrapRelease
|
||||
}
|
||||
const archiveRelease = vi.fn(() => calls.push('release-archive'))
|
||||
const archive: AgentPackageArchiveLease = {
|
||||
candidate,
|
||||
path: 'C:/cache/package.gbagent',
|
||||
size: candidate.size,
|
||||
sha256: candidate.sha256,
|
||||
nodePath: 'C:/cache/node',
|
||||
nodeSize: 42,
|
||||
nodeSha256: NODE_SHA256,
|
||||
release: archiveRelease
|
||||
}
|
||||
const acquireInstallArchive = vi.fn(async () => {
|
||||
calls.push('acquire-archive')
|
||||
return archive
|
||||
})
|
||||
const acquireGoodBuddyInstallArchive = vi.fn(async () => {
|
||||
calls.push('acquire-archive')
|
||||
return archive
|
||||
})
|
||||
const activateAgent = options.activateAgent ?? vi.fn(async () => {
|
||||
calls.push('agent')
|
||||
return identity.agent
|
||||
})
|
||||
const activateRuntime = options.activateRuntime ?? vi.fn(async () => {
|
||||
calls.push('runtime')
|
||||
})
|
||||
let pendingOperation = options.pendingOperation
|
||||
const operationStore = {
|
||||
load: vi.fn(async () => pendingOperation),
|
||||
save: vi.fn(async (
|
||||
operation: PendingRemoteEnvironmentOperation
|
||||
) => {
|
||||
calls.push('save-operation')
|
||||
pendingOperation = operation
|
||||
}),
|
||||
remove: vi.fn(async () => {
|
||||
calls.push('remove-operation')
|
||||
pendingOperation = undefined
|
||||
})
|
||||
}
|
||||
const preparer = new RemoteEnvironmentPreparer({
|
||||
resolver,
|
||||
sshPool: {
|
||||
acquire: vi.fn(async () => {
|
||||
calls.push('acquire-probe')
|
||||
return regularLease as never
|
||||
}),
|
||||
acquireRemotePackageBootstrap: vi.fn(async () => {
|
||||
calls.push('acquire-bootstrap')
|
||||
return bootstrapLease as never
|
||||
})
|
||||
},
|
||||
agentPackageManager: {
|
||||
getRemoteInstallCandidate: vi.fn(async () => {
|
||||
calls.push('candidate')
|
||||
return candidate
|
||||
}),
|
||||
acquireInstallArchive,
|
||||
acquireGoodBuddyInstallArchive
|
||||
},
|
||||
agentInstallationManager: {
|
||||
activatePublished: activateAgent
|
||||
} as never,
|
||||
runtimeInstallationManager: {
|
||||
activatePublished: activateRuntime
|
||||
} as never,
|
||||
operationStore
|
||||
})
|
||||
return {
|
||||
preparer,
|
||||
calls,
|
||||
resolver,
|
||||
regularLease,
|
||||
bootstrapLease,
|
||||
metadata,
|
||||
metadataFiles,
|
||||
uploadFile,
|
||||
probe,
|
||||
createUploadStaging,
|
||||
prepare,
|
||||
prepareUploaded,
|
||||
commit,
|
||||
cleanup,
|
||||
activateAgent,
|
||||
activateRuntime,
|
||||
acquireInstallArchive,
|
||||
archiveRelease,
|
||||
regularRelease,
|
||||
bootstrapRelease,
|
||||
stopCandidate,
|
||||
commitStatus,
|
||||
operationStore
|
||||
}
|
||||
}
|
||||
|
||||
describe('RemoteEnvironmentPreparer', () => {
|
||||
it('runs the remote compound graph in order, verifies identity, cleans explicitly, and emits the resolved method', async () => {
|
||||
const value = fixture()
|
||||
const progress: Array<{ method: string; phase: string }> = []
|
||||
const controller = new AbortController()
|
||||
|
||||
await value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
(event) => progress.push(event),
|
||||
controller.signal
|
||||
)
|
||||
|
||||
expect(value.calls).toEqual([
|
||||
'resolve',
|
||||
'acquire-probe',
|
||||
'platform-probe',
|
||||
'candidate',
|
||||
'resolve',
|
||||
'acquire-bootstrap',
|
||||
'save-operation',
|
||||
'prepare',
|
||||
'open-metadata',
|
||||
'resolve',
|
||||
'commit',
|
||||
'resolve',
|
||||
'agent',
|
||||
'resolve',
|
||||
'runtime',
|
||||
'resolve',
|
||||
'cleanup',
|
||||
'remove-operation',
|
||||
'close-metadata',
|
||||
'release-bootstrap',
|
||||
'release-probe'
|
||||
])
|
||||
const remoteCandidate = value.prepare.mock.calls[0]?.[0]
|
||||
expect(remoteCandidate).toMatchObject({
|
||||
urls: candidate.urls,
|
||||
size: candidate.size,
|
||||
sha256: candidate.sha256
|
||||
})
|
||||
expect(remoteCandidate.operationId).toMatch(/^[0-9a-f-]{36}$/u)
|
||||
expect(value.commit.mock.calls[0]?.[0]).toEqual(remoteCandidate)
|
||||
expect(value.activateAgent).toHaveBeenCalledWith(
|
||||
HOST_ID,
|
||||
identity.agent,
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
)
|
||||
expect(value.activateRuntime).toHaveBeenCalledWith(
|
||||
HOST_ID,
|
||||
identity.runtime,
|
||||
expect.objectContaining({
|
||||
agentInstallationId: identity.agent.installationId,
|
||||
signal: controller.signal
|
||||
})
|
||||
)
|
||||
expect(value.cleanup).toHaveBeenCalledWith(
|
||||
remoteCandidate.operationId,
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
expect(value.operationStore.save).toHaveBeenCalledOnce()
|
||||
expect(value.operationStore.remove).toHaveBeenCalledOnce()
|
||||
expect(progress.some((event) =>
|
||||
event.method === 'remote-download' &&
|
||||
event.phase === 'downloading'
|
||||
)).toBe(true)
|
||||
expect(progress.every((event) =>
|
||||
event.method === 'remote-download'
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('GoodBuddy uploads only the compound archive and bootstrap node, releases the archive, and never invokes remote acquisition', async () => {
|
||||
const value = fixture()
|
||||
|
||||
await value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'goodbuddy-transfer',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(value.probe).not.toHaveBeenCalled()
|
||||
expect(value.prepare).not.toHaveBeenCalled()
|
||||
expect(value.createUploadStaging).toHaveBeenCalledOnce()
|
||||
expect(value.uploadFile.mock.calls.map((call) => call[0])).toEqual([
|
||||
'package.gbagent',
|
||||
'bootstrap/agent/node'
|
||||
])
|
||||
expect(value.prepareUploaded).toHaveBeenCalledOnce()
|
||||
expect(value.archiveRelease).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('releases the archive and cleans the operation when a GoodBuddy upload fails', async () => {
|
||||
const uploadFailure = new Error('upload failed')
|
||||
const value = fixture({
|
||||
uploadFile: vi.fn(async () => {
|
||||
throw uploadFailure
|
||||
})
|
||||
})
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'goodbuddy-transfer',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).rejects.toBe(uploadFailure)
|
||||
expect(value.cleanup).toHaveBeenCalledOnce()
|
||||
expect(value.prepareUploaded).not.toHaveBeenCalled()
|
||||
expect(value.archiveRelease).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
available: true,
|
||||
expectedPrepare: 1,
|
||||
expectedUpload: 0,
|
||||
resolved: 'remote-download'
|
||||
},
|
||||
{
|
||||
available: false,
|
||||
expectedPrepare: 0,
|
||||
expectedUpload: 2,
|
||||
resolved: 'goodbuddy-transfer'
|
||||
}
|
||||
] as const)('auto resolves an availability result to $resolved', async ({
|
||||
available,
|
||||
expectedPrepare,
|
||||
expectedUpload,
|
||||
resolved
|
||||
}) => {
|
||||
const value = fixture({ probeAvailable: available })
|
||||
const methods: string[] = []
|
||||
|
||||
await value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'auto',
|
||||
(event) => methods.push(event.method),
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(value.probe).toHaveBeenCalledOnce()
|
||||
expect(value.prepare).toHaveBeenCalledTimes(expectedPrepare)
|
||||
expect(value.uploadFile).toHaveBeenCalledTimes(expectedUpload)
|
||||
expect(methods).toContain(resolved)
|
||||
})
|
||||
|
||||
it('does not cross acquisition methods after auto selected remote download', async () => {
|
||||
const value = fixture({
|
||||
probeAvailable: true,
|
||||
prepare: vi.fn(async () => ({
|
||||
prepared: false,
|
||||
unavailable: true,
|
||||
reason: 'curl-unavailable'
|
||||
}))
|
||||
})
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'auto',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).rejects.toThrow('不可用')
|
||||
expect(value.acquireInstallArchive).not.toHaveBeenCalled()
|
||||
expect(value.createUploadStaging).not.toHaveBeenCalled()
|
||||
expect(value.cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'prepared operation',
|
||||
prepare: vi.fn(async () => ({
|
||||
prepared: true,
|
||||
operationId: 'wrong-operation',
|
||||
identity
|
||||
})),
|
||||
commit: undefined,
|
||||
message: '准备操作身份不匹配'
|
||||
},
|
||||
{
|
||||
label: 'prepared package',
|
||||
prepare: vi.fn(async (input: { operationId: string }) => ({
|
||||
prepared: true,
|
||||
operationId: input.operationId,
|
||||
identity: { ...identity, archiveSha256: '9'.repeat(64) }
|
||||
})),
|
||||
commit: undefined,
|
||||
message: '签名候选不匹配'
|
||||
},
|
||||
{
|
||||
label: 'committed identity',
|
||||
prepare: undefined,
|
||||
commit: vi.fn(async (input: { operationId: string }) => ({
|
||||
committed: true,
|
||||
operationId: input.operationId,
|
||||
identity: {
|
||||
...identity,
|
||||
runtime: {
|
||||
...identity.runtime,
|
||||
runtimeVersion: 'different'
|
||||
}
|
||||
}
|
||||
})),
|
||||
message: '提交身份与准备身份不匹配'
|
||||
}
|
||||
])('rejects a $label mismatch and cleans staging', async ({
|
||||
prepare,
|
||||
commit,
|
||||
message
|
||||
}) => {
|
||||
const value = fixture({ prepare, commit })
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).rejects.toThrow(message)
|
||||
expect(value.cleanup).toHaveBeenCalledOnce()
|
||||
expect(value.activateAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a Host identity change immediately before commit and cleans staging', async () => {
|
||||
const value = fixture({
|
||||
targets: [target(), target(), target({ hostRevision: 4 })]
|
||||
})
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).rejects.toThrow('身份')
|
||||
expect(value.commit).not.toHaveBeenCalled()
|
||||
expect(value.cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleans deterministic cancellation and commit failures', async () => {
|
||||
const controller = new AbortController()
|
||||
const value = fixture({
|
||||
prepare: vi.fn(async () => {
|
||||
controller.abort(new DOMException('cancelled', 'AbortError'))
|
||||
throw controller.signal.reason
|
||||
})
|
||||
})
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
controller.signal
|
||||
)).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(value.cleanup).toHaveBeenCalledOnce()
|
||||
|
||||
const failed = fixture({
|
||||
commit: vi.fn(async () => ({
|
||||
committed: false,
|
||||
reason: 'installer-failed'
|
||||
}))
|
||||
})
|
||||
await expect(failed.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).rejects.toThrow('提交失败')
|
||||
expect(failed.cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not clean or adopt after an indeterminate commit', async () => {
|
||||
const value = fixture({
|
||||
commit: vi.fn(async () => {
|
||||
throw new SshRemotePackageCommitIndeterminateError()
|
||||
})
|
||||
})
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).rejects.toBeInstanceOf(SshRemotePackageCommitIndeterminateError)
|
||||
expect(value.cleanup).not.toHaveBeenCalled()
|
||||
expect(value.operationStore.save).toHaveBeenCalledOnce()
|
||||
expect(value.operationStore.remove).not.toHaveBeenCalled()
|
||||
expect(value.activateAgent).not.toHaveBeenCalled()
|
||||
expect(value.activateRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps rollback-incomplete commits recoverable without cleanup', async () => {
|
||||
const value = fixture({
|
||||
commit: vi.fn(async () => ({
|
||||
committed: false,
|
||||
reason: 'installer-rollback-incomplete'
|
||||
}))
|
||||
})
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).rejects.toBeInstanceOf(
|
||||
SshRemotePackageCommitIndeterminateError
|
||||
)
|
||||
expect(value.cleanup).not.toHaveBeenCalled()
|
||||
expect(value.operationStore.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles and cleans a pending commit before starting new work', async () => {
|
||||
const pendingOperationId =
|
||||
'00000000-0000-4000-8000-000000000777'
|
||||
const originalTarget = target()
|
||||
const renamedTarget = target({
|
||||
host: {
|
||||
...originalTarget.host,
|
||||
name: 'Renamed builder'
|
||||
},
|
||||
hostRevision: 99
|
||||
})
|
||||
const value = fixture({
|
||||
targets: [renamedTarget],
|
||||
pendingOperation: {
|
||||
version: 1,
|
||||
hostId: HOST_ID,
|
||||
targetIdentity:
|
||||
remoteHostRecoveryIdentityKey(originalTarget),
|
||||
operationId: pendingOperationId,
|
||||
size: candidate.size,
|
||||
sha256: candidate.sha256,
|
||||
urls: candidate.urls
|
||||
}
|
||||
})
|
||||
|
||||
await value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(value.commitStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operationId: pendingOperationId
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(value.cleanup.mock.calls[0]?.[0]).toBe(
|
||||
pendingOperationId
|
||||
)
|
||||
expect(value.prepare).toHaveBeenCalledOnce()
|
||||
expect(value.operationStore.remove).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains pending recovery evidence when the real SSH target changes', async () => {
|
||||
const originalTarget = target()
|
||||
const changedTarget = target({
|
||||
host: {
|
||||
...originalTarget.host,
|
||||
hostname: 'different.example'
|
||||
}
|
||||
})
|
||||
const value = fixture({
|
||||
targets: [changedTarget],
|
||||
pendingOperation: {
|
||||
version: 1,
|
||||
hostId: HOST_ID,
|
||||
targetIdentity:
|
||||
remoteHostRecoveryIdentityKey(originalTarget),
|
||||
operationId:
|
||||
'00000000-0000-4000-8000-000000000778',
|
||||
size: candidate.size,
|
||||
sha256: candidate.sha256,
|
||||
urls: candidate.urls
|
||||
}
|
||||
})
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).rejects.toBeInstanceOf(
|
||||
SshRemotePackageCommitIndeterminateError
|
||||
)
|
||||
expect(value.commitStatus).not.toHaveBeenCalled()
|
||||
expect(value.operationStore.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops the committed Agent and restores all five metadata files when Runtime adoption fails', async () => {
|
||||
const originals = new Map<string, Buffer>()
|
||||
for (const [index, path] of REMOTE_ENVIRONMENT_METADATA_PATHS.entries()) {
|
||||
if (index !== 2) originals.set(path, Buffer.from(`original-${index}`))
|
||||
}
|
||||
const files = new Map(
|
||||
[...originals].map(([path, contents]) => [path, Buffer.from(contents)])
|
||||
)
|
||||
const runtimeFailure = new Error('runtime health failed')
|
||||
const value = fixture({
|
||||
metadataFiles: files,
|
||||
activateAgent: vi.fn(async () => {
|
||||
value.metadataFiles.clear()
|
||||
for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) {
|
||||
value.metadataFiles.set(path, Buffer.from('new'))
|
||||
}
|
||||
return identity.agent
|
||||
value.calls.push('agent')
|
||||
}),
|
||||
activateRuntime: vi.fn(async () => {
|
||||
throw runtimeFailure
|
||||
})
|
||||
})
|
||||
|
||||
await expect(value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
)).rejects.toBe(runtimeFailure)
|
||||
expect(value.stopCandidate).toHaveBeenCalledWith(
|
||||
identity.agent.installationId,
|
||||
'stop'
|
||||
)
|
||||
expect([...value.metadataFiles]).toEqual([...originals])
|
||||
expect(value.cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('throws AggregateError when adoption and metadata rollback both fail', async () => {
|
||||
const path = REMOTE_ENVIRONMENT_METADATA_PATHS[0]
|
||||
const files = new Map([[path, Buffer.from('original')]])
|
||||
const runtimeFailure = new Error('runtime failed')
|
||||
const rollbackFailure = new Error('replace failed')
|
||||
const value = fixture({
|
||||
metadataFiles: files,
|
||||
metadataReplaceFailure: rollbackFailure,
|
||||
activateAgent: vi.fn(async () => {
|
||||
files.set(path, Buffer.from('changed'))
|
||||
return identity.agent
|
||||
}),
|
||||
activateRuntime: vi.fn(async () => {
|
||||
throw runtimeFailure
|
||||
})
|
||||
})
|
||||
|
||||
const caught = await value.preparer.prepare(
|
||||
HOST_ID,
|
||||
'remote-download',
|
||||
undefined,
|
||||
new AbortController().signal
|
||||
).catch((error: unknown) => error)
|
||||
expect(caught).toBeInstanceOf(AggregateError)
|
||||
expect((caught as AggregateError).errors[0]).toBe(runtimeFailure)
|
||||
expect((caught as AggregateError).errors[1]).toBeInstanceOf(
|
||||
AggregateError
|
||||
)
|
||||
expect(value.stopCandidate).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,825 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { dirname } from 'node:path'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type {
|
||||
RemoteEnvironmentPreparationMethod,
|
||||
RemoteEnvironmentUpdateProgress
|
||||
} from '../../shared/ssh-host-contracts'
|
||||
import type { StagedSftp } from '../ssh/bounded-sftp'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
import type {
|
||||
SshConnectionLease,
|
||||
SshConnectionPool,
|
||||
SshRemotePackageBootstrapLease
|
||||
} from '../ssh/ssh-connection-pool'
|
||||
import type { SshConnectionTarget } from '../ssh/ssh-host-store'
|
||||
import {
|
||||
assertSshLeaseMatchesTarget,
|
||||
assertValidSshConnectionTarget
|
||||
} from '../ssh/ssh-connection-target'
|
||||
import {
|
||||
SshRemotePackageCommitIndeterminateError,
|
||||
type SshRemotePackageBootstrapPrepareResult,
|
||||
type SshRemotePackageBootstrapProgress,
|
||||
type SshRemotePackageCandidate,
|
||||
type SshRemotePackageIdentity
|
||||
} from '../ssh/ssh-remote-package-bootstrap'
|
||||
import type {
|
||||
AgentInstallationManager,
|
||||
AgentInstallationTargetResolver
|
||||
} from './agent-installation-manager'
|
||||
import type {
|
||||
AgentPackageArchiveLease,
|
||||
AgentPackageManager,
|
||||
VerifiedRemoteAgentInstallCandidate
|
||||
} from './agent-package-manager'
|
||||
import {
|
||||
restoreRemoteEnvironmentMetadata,
|
||||
snapshotRemoteEnvironmentMetadata,
|
||||
type RemoteEnvironmentMetadataSnapshot
|
||||
} from './remote-environment-metadata'
|
||||
import {
|
||||
mapAgentInstallationPhase,
|
||||
mapRuntimeInstallationPhase
|
||||
} from './remote-environment-progress'
|
||||
import type {
|
||||
RemoteEnvironmentOperationStore
|
||||
} from './remote-environment-operation-store'
|
||||
import type {
|
||||
RemoteEnvironmentPreparer as Preparer,
|
||||
RemoteEnvironmentUpdateProgressObserver
|
||||
} from './remote-environment-update-service'
|
||||
import type {
|
||||
RemoteRuntimeInstallationManager
|
||||
} from './remote-runtime-installation-manager'
|
||||
import {
|
||||
remoteHostRecoveryIdentityKey
|
||||
} from './remote-host-target-identity'
|
||||
|
||||
const MAXIMUM_PACKAGE_BYTES = 512 * 1024 * 1024
|
||||
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
||||
|
||||
export type RemoteEnvironmentPreparerOptions = {
|
||||
resolver: AgentInstallationTargetResolver
|
||||
sshPool: Pick<
|
||||
SshConnectionPool,
|
||||
'acquire' | 'acquireRemotePackageBootstrap'
|
||||
>
|
||||
agentPackageManager: Pick<
|
||||
AgentPackageManager,
|
||||
| 'getRemoteInstallCandidate'
|
||||
| 'acquireInstallArchive'
|
||||
| 'acquireGoodBuddyInstallArchive'
|
||||
>
|
||||
agentInstallationManager: Pick<
|
||||
AgentInstallationManager,
|
||||
'activatePublished'
|
||||
>
|
||||
runtimeInstallationManager: Pick<
|
||||
RemoteRuntimeInstallationManager,
|
||||
'activatePublished'
|
||||
>
|
||||
operationStore: Pick<
|
||||
RemoteEnvironmentOperationStore,
|
||||
'load' | 'save' | 'remove'
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares one signed compound environment. The selected method controls only
|
||||
* how the archive reaches the Host; both paths share prepare, commit,
|
||||
* activation, rollback, health checks, and cleanup.
|
||||
*/
|
||||
export class RemoteEnvironmentPreparer implements Preparer {
|
||||
readonly #resolver: AgentInstallationTargetResolver
|
||||
readonly #sshPool: RemoteEnvironmentPreparerOptions['sshPool']
|
||||
readonly #agentPackageManager:
|
||||
RemoteEnvironmentPreparerOptions['agentPackageManager']
|
||||
readonly #agentInstallationManager:
|
||||
RemoteEnvironmentPreparerOptions['agentInstallationManager']
|
||||
readonly #runtimeInstallationManager:
|
||||
RemoteEnvironmentPreparerOptions['runtimeInstallationManager']
|
||||
readonly #operationStore:
|
||||
RemoteEnvironmentPreparerOptions['operationStore']
|
||||
|
||||
constructor(options: RemoteEnvironmentPreparerOptions) {
|
||||
this.#resolver = options.resolver
|
||||
this.#sshPool = options.sshPool
|
||||
this.#agentPackageManager = options.agentPackageManager
|
||||
this.#agentInstallationManager =
|
||||
options.agentInstallationManager
|
||||
this.#runtimeInstallationManager =
|
||||
options.runtimeInstallationManager
|
||||
this.#operationStore = options.operationStore
|
||||
}
|
||||
|
||||
async prepare(
|
||||
hostId: string,
|
||||
requestedMethod: RemoteEnvironmentPreparationMethod,
|
||||
observer: RemoteEnvironmentUpdateProgressObserver | undefined,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const emit = (
|
||||
method: RemoteEnvironmentPreparationMethod,
|
||||
phase: RemoteEnvironmentUpdateProgress['phase']
|
||||
): void => {
|
||||
try {
|
||||
observer?.({ hostId, method, phase })
|
||||
} catch {
|
||||
// Progress observers cannot alter installation state.
|
||||
}
|
||||
}
|
||||
|
||||
signal.throwIfAborted()
|
||||
emit(requestedMethod, 'probing')
|
||||
const target = await this.#resolver.resolve(hostId)
|
||||
signal.throwIfAborted()
|
||||
assertValidSshConnectionTarget(hostId, target)
|
||||
await this.#reconcilePendingOperation(
|
||||
hostId,
|
||||
target,
|
||||
emit,
|
||||
signal
|
||||
)
|
||||
|
||||
let probeLease: SshConnectionLease | undefined
|
||||
let bootstrapLease:
|
||||
| SshRemotePackageBootstrapLease
|
||||
| undefined
|
||||
let archiveLease: AgentPackageArchiveLease | undefined
|
||||
let metadataSftp: StagedSftp | undefined
|
||||
let metadataSnapshots:
|
||||
| readonly RemoteEnvironmentMetadataSnapshot[]
|
||||
| undefined
|
||||
let operationId: string | undefined
|
||||
let operationCleaned = false
|
||||
let pendingOperationSaved = false
|
||||
let committedIdentity: SshRemotePackageIdentity | undefined
|
||||
try {
|
||||
probeLease = await this.#sshPool.acquire(target, signal)
|
||||
assertLeaseMatchesTarget(probeLease, target)
|
||||
const probe = await probeLease.runAgentBootstrapProbe(signal)
|
||||
signal.throwIfAborted()
|
||||
assertLeaseMatchesTarget(probeLease, target)
|
||||
if (!probe.ready) {
|
||||
throw new Error(
|
||||
`远端系统不能运行 GoodBuddy Agent:${probe.reason}`
|
||||
)
|
||||
}
|
||||
|
||||
let candidate: VerifiedRemoteAgentInstallCandidate
|
||||
let method:
|
||||
| 'remote-download'
|
||||
| 'goodbuddy-transfer'
|
||||
| undefined
|
||||
if (requestedMethod === 'goodbuddy-transfer') {
|
||||
archiveLease =
|
||||
await this.#acquireGoodBuddyArchive(
|
||||
probe.architecture,
|
||||
emit,
|
||||
signal
|
||||
)
|
||||
candidate = archiveLease.candidate
|
||||
method = 'goodbuddy-transfer'
|
||||
} else {
|
||||
try {
|
||||
candidate =
|
||||
await this.#agentPackageManager.getRemoteInstallCandidate(
|
||||
probe.architecture,
|
||||
{ signal }
|
||||
)
|
||||
} catch (error) {
|
||||
if (requestedMethod === 'remote-download') {
|
||||
throw error
|
||||
}
|
||||
archiveLease =
|
||||
await this.#acquireGoodBuddyArchive(
|
||||
probe.architecture,
|
||||
emit,
|
||||
signal
|
||||
)
|
||||
candidate = archiveLease.candidate
|
||||
method = 'goodbuddy-transfer'
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
assertCandidate(candidate, probe.architecture)
|
||||
await assertTargetCurrent(this.#resolver, target, signal)
|
||||
|
||||
operationId = randomUUID()
|
||||
const remoteCandidate: SshRemotePackageCandidate = {
|
||||
operationId,
|
||||
urls: candidate.urls,
|
||||
size: candidate.size,
|
||||
sha256: candidate.sha256
|
||||
}
|
||||
bootstrapLease =
|
||||
await this.#sshPool.acquireRemotePackageBootstrap(
|
||||
target,
|
||||
signal
|
||||
)
|
||||
assertLeaseMatchesTarget(bootstrapLease, target)
|
||||
assertSameLeaseIdentity(probeLease, bootstrapLease)
|
||||
|
||||
if (!method) {
|
||||
method = await resolvePreparationMethod(
|
||||
requestedMethod,
|
||||
bootstrapLease,
|
||||
remoteCandidate,
|
||||
signal
|
||||
)
|
||||
}
|
||||
if (
|
||||
method === 'goodbuddy-transfer' &&
|
||||
!archiveLease
|
||||
) {
|
||||
archiveLease =
|
||||
await this.#agentPackageManager.acquireInstallArchive(
|
||||
candidate.architecture,
|
||||
candidate,
|
||||
{
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
emit(
|
||||
method!,
|
||||
progress.phase === 'downloading'
|
||||
? 'downloading'
|
||||
: progress.phase === 'catalog'
|
||||
? 'probing'
|
||||
: 'verifying'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
emit(method, 'probing')
|
||||
|
||||
// Persist recovery identity before either preparation path may create
|
||||
// remote staging. A Main crash must never leave an untracked operation.
|
||||
await this.#operationStore.save({
|
||||
version: 1,
|
||||
hostId,
|
||||
targetIdentity: remoteHostRecoveryIdentityKey(target),
|
||||
operationId,
|
||||
size: remoteCandidate.size,
|
||||
sha256: remoteCandidate.sha256,
|
||||
urls: [...remoteCandidate.urls]
|
||||
})
|
||||
pendingOperationSaved = true
|
||||
|
||||
const prepared =
|
||||
method === 'remote-download'
|
||||
? await bootstrapLease.prepare(remoteCandidate, {
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
emit(method, mapBootstrapPhase(progress))
|
||||
}
|
||||
})
|
||||
: await this.#prepareUploadedArchive({
|
||||
probeLease,
|
||||
bootstrapLease,
|
||||
archive: archiveLease!,
|
||||
remoteCandidate,
|
||||
method,
|
||||
emit,
|
||||
signal
|
||||
})
|
||||
assertPrepared(prepared, operationId)
|
||||
assertPackageIdentity(prepared.identity, candidate)
|
||||
|
||||
// Snapshot the complete environment before commit so an Agent success
|
||||
// followed by a Runtime failure cannot expose a partially adopted pair.
|
||||
metadataSftp = await probeLease.openStagedSftp(
|
||||
probe.canonicalHomeDirectory,
|
||||
{
|
||||
maximumFileBytes: MAXIMUM_METADATA_BYTES,
|
||||
maximumTotalBytes: MAXIMUM_METADATA_BYTES * 24,
|
||||
maximumOperations: 128
|
||||
},
|
||||
signal
|
||||
)
|
||||
metadataSnapshots =
|
||||
await snapshotRemoteEnvironmentMetadata(
|
||||
metadataSftp,
|
||||
probe.uid,
|
||||
signal
|
||||
)
|
||||
|
||||
// Keep the full target re-resolution immediately adjacent to commit.
|
||||
await assertTargetCurrent(this.#resolver, target, signal)
|
||||
assertLeaseMatchesTarget(bootstrapLease, target)
|
||||
signal.throwIfAborted()
|
||||
|
||||
const committed = await bootstrapLease.commit(
|
||||
remoteCandidate,
|
||||
{
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
emit(method, mapBootstrapPhase(progress))
|
||||
}
|
||||
}
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
if (!committed.committed) {
|
||||
if (
|
||||
committed.reason ===
|
||||
'installer-rollback-incomplete'
|
||||
) {
|
||||
throw new SshRemotePackageCommitIndeterminateError(
|
||||
'远端环境发布失败且回滚结果不完整,请重新检查 Host 状态'
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`远端安装提交失败:${committed.reason}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
committed.operationId !== operationId ||
|
||||
!isDeepStrictEqual(
|
||||
committed.identity,
|
||||
prepared.identity
|
||||
)
|
||||
) {
|
||||
throw new Error('远端安装提交身份与准备身份不匹配')
|
||||
}
|
||||
assertPackageIdentity(committed.identity, candidate)
|
||||
committedIdentity = committed.identity
|
||||
|
||||
await assertTargetCurrent(this.#resolver, target, signal)
|
||||
const activeAgent =
|
||||
await this.#agentInstallationManager.activatePublished(
|
||||
hostId,
|
||||
committed.identity.agent,
|
||||
{
|
||||
signal,
|
||||
onProgress: (phase) => {
|
||||
const mapped = mapAgentInstallationPhase(phase)
|
||||
if (mapped) {
|
||||
emit(method, mapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
await assertTargetCurrent(this.#resolver, target, signal)
|
||||
|
||||
await this.#runtimeInstallationManager.activatePublished(
|
||||
hostId,
|
||||
committed.identity.runtime,
|
||||
{
|
||||
agentInstallationId: activeAgent.installationId,
|
||||
signal,
|
||||
onProgress: (phase) => {
|
||||
const mapped = mapRuntimeInstallationPhase(phase)
|
||||
if (mapped) {
|
||||
emit(method, mapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
await assertTargetCurrent(this.#resolver, target, signal)
|
||||
|
||||
const cleanup = await bootstrapLease.cleanup(operationId, {
|
||||
signal
|
||||
})
|
||||
if (!cleanup.cleaned) {
|
||||
throw new Error(
|
||||
`远程环境已安装,但暂存文件清理失败:${cleanup.reason}`
|
||||
)
|
||||
}
|
||||
operationCleaned = true
|
||||
await this.#operationStore.remove(hostId)
|
||||
} catch (error) {
|
||||
let rollbackError: unknown
|
||||
if (
|
||||
committedIdentity &&
|
||||
metadataSftp &&
|
||||
metadataSnapshots
|
||||
) {
|
||||
await stopCandidateBestEffort(
|
||||
probeLease,
|
||||
committedIdentity.agent.installationId
|
||||
)
|
||||
try {
|
||||
await restoreRemoteEnvironmentMetadata(
|
||||
metadataSftp,
|
||||
metadataSnapshots
|
||||
)
|
||||
} catch (reason) {
|
||||
rollbackError = reason
|
||||
}
|
||||
}
|
||||
if (
|
||||
operationId &&
|
||||
bootstrapLease &&
|
||||
!operationCleaned &&
|
||||
!(error instanceof SshRemotePackageCommitIndeterminateError)
|
||||
) {
|
||||
operationCleaned = await cleanupBestEffort(
|
||||
bootstrapLease,
|
||||
operationId
|
||||
)
|
||||
if (operationCleaned && pendingOperationSaved) {
|
||||
await this.#operationStore.remove(hostId)
|
||||
}
|
||||
}
|
||||
if (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
'远程运行环境准备失败,且原有环境元数据未能完整恢复',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
metadataSftp?.close()
|
||||
archiveLease?.release()
|
||||
bootstrapLease?.release()
|
||||
probeLease?.release()
|
||||
}
|
||||
}
|
||||
|
||||
async #prepareUploadedArchive(options: {
|
||||
probeLease: SshConnectionLease
|
||||
bootstrapLease: SshRemotePackageBootstrapLease
|
||||
archive: AgentPackageArchiveLease
|
||||
remoteCandidate: SshRemotePackageCandidate
|
||||
method: 'goodbuddy-transfer'
|
||||
emit: (
|
||||
method: RemoteEnvironmentPreparationMethod,
|
||||
phase: RemoteEnvironmentUpdateProgress['phase']
|
||||
) => void
|
||||
signal: AbortSignal
|
||||
}): Promise<SshRemotePackageBootstrapPrepareResult> {
|
||||
const {
|
||||
probeLease,
|
||||
bootstrapLease,
|
||||
archive,
|
||||
remoteCandidate,
|
||||
method,
|
||||
emit,
|
||||
signal
|
||||
} = options
|
||||
signal.throwIfAborted()
|
||||
|
||||
const staging = await bootstrapLease.createUploadStaging(
|
||||
remoteCandidate,
|
||||
{ signal }
|
||||
)
|
||||
if (!staging.created) {
|
||||
throw new Error(
|
||||
staging.unavailable
|
||||
? `GoodBuddy 传输暂存不可用:${staging.reason}`
|
||||
: `GoodBuddy 传输暂存失败:${staging.reason}`
|
||||
)
|
||||
}
|
||||
if (staging.operationId !== remoteCandidate.operationId) {
|
||||
throw new Error('GoodBuddy 传输暂存操作身份不匹配')
|
||||
}
|
||||
const operationRoot = dirname(staging.archivePath)
|
||||
if (
|
||||
staging.archivePath !==
|
||||
`${operationRoot}/package.gbagent` ||
|
||||
staging.bootstrapNodePath !==
|
||||
`${operationRoot}/bootstrap/agent/node`
|
||||
) {
|
||||
throw new Error('GoodBuddy 传输暂存路径无效')
|
||||
}
|
||||
|
||||
const sftp = await probeLease.openStagedSftp(
|
||||
operationRoot,
|
||||
{
|
||||
maximumFileBytes: MAXIMUM_PACKAGE_BYTES,
|
||||
maximumTotalBytes:
|
||||
archive.size + archive.nodeSize,
|
||||
maximumOperations: 16
|
||||
},
|
||||
signal
|
||||
)
|
||||
try {
|
||||
emit(method, 'downloading')
|
||||
await sftp.uploadFile(
|
||||
'package.gbagent',
|
||||
archive.path,
|
||||
{
|
||||
size: archive.size,
|
||||
sha256: archive.sha256
|
||||
},
|
||||
signal
|
||||
)
|
||||
await sftp.uploadFile(
|
||||
'bootstrap/agent/node',
|
||||
archive.nodePath,
|
||||
{
|
||||
size: archive.nodeSize,
|
||||
sha256: archive.nodeSha256
|
||||
},
|
||||
signal
|
||||
)
|
||||
await sftp.setExecutable(
|
||||
'bootstrap/agent/node',
|
||||
signal
|
||||
)
|
||||
} finally {
|
||||
sftp.close()
|
||||
}
|
||||
|
||||
return bootstrapLease.prepareUploaded(remoteCandidate, {
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
emit(method, mapBootstrapPhase(progress))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#acquireGoodBuddyArchive(
|
||||
architecture: 'x64' | 'arm64',
|
||||
emit: (
|
||||
method: RemoteEnvironmentPreparationMethod,
|
||||
phase: RemoteEnvironmentUpdateProgress['phase']
|
||||
) => void,
|
||||
signal: AbortSignal
|
||||
): Promise<AgentPackageArchiveLease> {
|
||||
return this.#agentPackageManager.acquireGoodBuddyInstallArchive(
|
||||
architecture,
|
||||
{
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
emit(
|
||||
'goodbuddy-transfer',
|
||||
progress.phase === 'downloading'
|
||||
? 'downloading'
|
||||
: progress.phase === 'catalog'
|
||||
? 'probing'
|
||||
: 'verifying'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async #reconcilePendingOperation(
|
||||
hostId: string,
|
||||
target: SshConnectionTarget,
|
||||
emit: (
|
||||
method: RemoteEnvironmentPreparationMethod,
|
||||
phase: RemoteEnvironmentUpdateProgress['phase']
|
||||
) => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const pending = await this.#operationStore.load(hostId)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
pending.targetIdentity !==
|
||||
remoteHostRecoveryIdentityKey(target)
|
||||
) {
|
||||
throw new SshRemotePackageCommitIndeterminateError(
|
||||
'SSH Host 连接目标已变化,无法确认上一次远程环境提交;请恢复原 Host 连接后重试,或删除此 Host 以明确放弃恢复'
|
||||
)
|
||||
}
|
||||
emit('auto', 'applying')
|
||||
const candidate: SshRemotePackageCandidate = {
|
||||
operationId: pending.operationId,
|
||||
size: pending.size,
|
||||
sha256: pending.sha256,
|
||||
urls: pending.urls
|
||||
}
|
||||
const lease =
|
||||
await this.#sshPool.acquireRemotePackageBootstrap(
|
||||
target,
|
||||
signal
|
||||
)
|
||||
try {
|
||||
assertLeaseMatchesTarget(lease, target)
|
||||
const status = await lease.commitStatus(candidate, {
|
||||
signal
|
||||
})
|
||||
if (
|
||||
!status.committed &&
|
||||
status.reason === 'installer-rollback-incomplete'
|
||||
) {
|
||||
throw new SshRemotePackageCommitIndeterminateError(
|
||||
'上一次远程环境发布失败且回滚结果不完整,请检查 Host 状态'
|
||||
)
|
||||
}
|
||||
const cleanup = await lease.cleanup(
|
||||
pending.operationId,
|
||||
{ signal }
|
||||
)
|
||||
if (
|
||||
!cleanup.cleaned &&
|
||||
cleanup.reason !== 'operation-unavailable'
|
||||
) {
|
||||
throw new Error(
|
||||
`上一次远程环境暂存清理失败:${cleanup.reason}`
|
||||
)
|
||||
}
|
||||
await this.#operationStore.remove(hostId)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
SshRemotePackageCommitIndeterminateError
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
throw new SshRemotePackageCommitIndeterminateError(
|
||||
'上一次远程环境提交结果仍不确定,请稍后重试'
|
||||
)
|
||||
} finally {
|
||||
lease.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePreparationMethod(
|
||||
requested: RemoteEnvironmentPreparationMethod,
|
||||
lease: SshRemotePackageBootstrapLease,
|
||||
candidate: SshRemotePackageCandidate,
|
||||
signal: AbortSignal
|
||||
): Promise<'remote-download' | 'goodbuddy-transfer'> {
|
||||
if (requested !== 'auto') {
|
||||
return requested
|
||||
}
|
||||
const availability = await lease.probe(candidate, { signal })
|
||||
return availability.available
|
||||
? 'remote-download'
|
||||
: 'goodbuddy-transfer'
|
||||
}
|
||||
|
||||
function assertPrepared(
|
||||
prepared: SshRemotePackageBootstrapPrepareResult,
|
||||
operationId: string
|
||||
): asserts prepared is Extract<
|
||||
SshRemotePackageBootstrapPrepareResult,
|
||||
{ prepared: true }
|
||||
> {
|
||||
if (!prepared.prepared) {
|
||||
throw new Error(
|
||||
prepared.unavailable
|
||||
? `远端环境准备不可用:${prepared.reason}`
|
||||
: `远端环境准备失败:${prepared.reason}`
|
||||
)
|
||||
}
|
||||
if (prepared.operationId !== operationId) {
|
||||
throw new Error('远端环境准备操作身份不匹配')
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupBestEffort(
|
||||
lease: SshRemotePackageBootstrapLease,
|
||||
operationId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await lease.cleanup(operationId)
|
||||
return (
|
||||
result.cleaned ||
|
||||
result.reason === 'operation-unavailable'
|
||||
)
|
||||
} catch {
|
||||
// Preserve the original deterministic preparation failure.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCandidateBestEffort(
|
||||
lease: SshConnectionLease | undefined,
|
||||
installationId: string
|
||||
): Promise<void> {
|
||||
if (!lease?.isUsable()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await lease.runAgentLifecycleAction(
|
||||
verifyAgentInstallationId(installationId),
|
||||
'stop'
|
||||
)
|
||||
} catch {
|
||||
// Metadata rollback remains the authoritative recovery action.
|
||||
}
|
||||
}
|
||||
|
||||
async function assertTargetCurrent(
|
||||
resolver: AgentInstallationTargetResolver,
|
||||
expected: SshConnectionTarget,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
const current = await resolver.resolve(expected.host.id)
|
||||
signal.throwIfAborted()
|
||||
assertValidSshConnectionTarget(expected.host.id, current)
|
||||
if (!isDeepStrictEqual(current, expected)) {
|
||||
throw new Error(
|
||||
'SSH 主机身份在远端环境安装期间发生变化'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertLeaseMatchesTarget(
|
||||
lease: Pick<SshConnectionLease, 'identity'>,
|
||||
target: SshConnectionTarget
|
||||
): void {
|
||||
assertSshLeaseMatchesTarget(
|
||||
lease,
|
||||
target,
|
||||
'SSH 远端安装连接身份不匹配'
|
||||
)
|
||||
if (
|
||||
!/^[a-f0-9]{64}$/u.test(
|
||||
lease.identity.authenticationIdentity
|
||||
)
|
||||
) {
|
||||
throw new Error('SSH 远端安装连接身份不匹配')
|
||||
}
|
||||
}
|
||||
|
||||
function assertSameLeaseIdentity(
|
||||
probeLease: Pick<SshConnectionLease, 'identity'>,
|
||||
bootstrapLease: Pick<
|
||||
SshRemotePackageBootstrapLease,
|
||||
'identity'
|
||||
>
|
||||
): void {
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
probeLease.identity,
|
||||
bootstrapLease.identity
|
||||
)
|
||||
) {
|
||||
throw new Error('SSH 远端安装认证身份发生变化')
|
||||
}
|
||||
}
|
||||
|
||||
function assertCandidate(
|
||||
candidate: VerifiedRemoteAgentInstallCandidate,
|
||||
architecture: 'x64' | 'arm64'
|
||||
): void {
|
||||
if (
|
||||
candidate.platform !== 'linux' ||
|
||||
candidate.architecture !== architecture
|
||||
) {
|
||||
throw new Error('签名 Agent 安装候选与 Host 架构不匹配')
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackageIdentity(
|
||||
identity: SshRemotePackageIdentity,
|
||||
candidate: VerifiedRemoteAgentInstallCandidate
|
||||
): void {
|
||||
if (
|
||||
identity.archiveSha256 !== candidate.sha256 ||
|
||||
identity.agent.agentVersion !== candidate.version ||
|
||||
identity.agent.platform !== candidate.platform ||
|
||||
identity.agent.architecture !== candidate.architecture ||
|
||||
!isDeepStrictEqual(
|
||||
identity.agent.protocol,
|
||||
candidate.agentProtocol
|
||||
) ||
|
||||
identity.runtime.runtimeId !==
|
||||
candidate.remoteRuntime.runtimeId ||
|
||||
identity.runtime.runtimeVersion !==
|
||||
candidate.remoteRuntime.version ||
|
||||
identity.runtime.bundleDigest !==
|
||||
candidate.remoteRuntime.bundleDigest ||
|
||||
identity.runtime.platform !== candidate.platform ||
|
||||
identity.runtime.architecture !==
|
||||
candidate.architecture ||
|
||||
!isDeepStrictEqual(
|
||||
identity.runtime.protocol,
|
||||
candidate.remoteRuntime.protocol
|
||||
)
|
||||
) {
|
||||
throw new Error('远端准备的安装身份与签名候选不匹配')
|
||||
}
|
||||
}
|
||||
|
||||
function mapBootstrapPhase(
|
||||
progress: SshRemotePackageBootstrapProgress
|
||||
): RemoteEnvironmentUpdateProgress['phase'] {
|
||||
switch (progress.phase) {
|
||||
case 'checking':
|
||||
return 'probing'
|
||||
case 'downloading':
|
||||
return 'downloading'
|
||||
case 'verifying':
|
||||
case 'validating':
|
||||
case 'hashing-archive':
|
||||
case 'verifying-zip':
|
||||
case 'verifying-payload':
|
||||
case 'validating-prepared-state':
|
||||
case 'revalidating-archive':
|
||||
return 'verifying'
|
||||
case 'extracting':
|
||||
case 'preparing':
|
||||
case 'prepared':
|
||||
case 'committing':
|
||||
case 'committed':
|
||||
case 'persisting-prepared-state':
|
||||
case 'extracting-payload':
|
||||
case 'publishing-content':
|
||||
case 'cleaning':
|
||||
return 'applying'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { RemoteEnvironmentUpdateProgress } from '../../shared/ssh-host-contracts'
|
||||
import type { AgentInstallationPhase } from './agent-installation-manager'
|
||||
import type { RemoteRuntimeInstallationPhase } from './remote-runtime-installation-manager'
|
||||
|
||||
export function mapAgentInstallationPhase(
|
||||
phase: AgentInstallationPhase
|
||||
): RemoteEnvironmentUpdateProgress['phase'] | undefined {
|
||||
switch (phase) {
|
||||
case 'inspecting-host':
|
||||
return 'probing'
|
||||
case 'verifying-bundle':
|
||||
return 'verifying'
|
||||
case 'starting-agent':
|
||||
return 'installing-agent'
|
||||
case 'checking-health':
|
||||
return 'checking-health'
|
||||
case 'complete':
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRuntimeInstallationPhase(
|
||||
phase: RemoteRuntimeInstallationPhase
|
||||
): RemoteEnvironmentUpdateProgress['phase'] | undefined {
|
||||
switch (phase) {
|
||||
case 'inspecting-host':
|
||||
return 'probing'
|
||||
case 'verifying-bundle':
|
||||
return 'verifying'
|
||||
case 'activating-runtime':
|
||||
return 'installing-runtime'
|
||||
case 'complete':
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteEnvironmentUpdateService } from './remote-environment-update-service'
|
||||
import type { RemoteEnvironmentUpdateProgress } from '../../shared/ssh-host-contracts'
|
||||
import {
|
||||
RemoteEnvironmentUpdateService,
|
||||
type RemoteEnvironmentPreparer
|
||||
} from './remote-environment-update-service'
|
||||
|
||||
const HOST_ID = '00000000-0000-4000-8000-000000000101'
|
||||
const OTHER_HOST_ID = '00000000-0000-4000-8000-000000000102'
|
||||
|
||||
function createOwner() {
|
||||
function createOwner(id = 1) {
|
||||
let destroyed = false
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
id: 1,
|
||||
id,
|
||||
isDestroyed: vi.fn(() => destroyed),
|
||||
on: vi.fn((_event: 'destroyed', listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
@@ -20,15 +23,17 @@ function createOwner() {
|
||||
),
|
||||
destroy() {
|
||||
destroyed = true
|
||||
for (const listener of [...listeners]) {
|
||||
listener()
|
||||
}
|
||||
for (const listener of [...listeners]) listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function abortablePending(signal: AbortSignal): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason)
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true
|
||||
})
|
||||
@@ -36,222 +41,243 @@ function abortablePending(signal: AbortSignal): Promise<never> {
|
||||
}
|
||||
|
||||
describe('RemoteEnvironmentUpdateService', () => {
|
||||
it('force-updates Agent then locked Runtime with one signal before finalizing', async () => {
|
||||
const sequence: string[] = []
|
||||
const signals: AbortSignal[] = []
|
||||
const agent = {
|
||||
ensureInstalled: vi.fn(
|
||||
async (
|
||||
_hostId: string,
|
||||
options: { force?: boolean; signal?: AbortSignal }
|
||||
) => {
|
||||
sequence.push('agent')
|
||||
expect(options.force).toBe(true)
|
||||
signals.push(options.signal!)
|
||||
return {}
|
||||
}
|
||||
)
|
||||
}
|
||||
const runtime = {
|
||||
ensureInstalled: vi.fn(
|
||||
async (
|
||||
_hostId: string,
|
||||
options: { force?: boolean; signal?: AbortSignal }
|
||||
) => {
|
||||
sequence.push('runtime')
|
||||
expect(options.force).toBe(true)
|
||||
signals.push(options.signal!)
|
||||
return {}
|
||||
}
|
||||
)
|
||||
}
|
||||
const afterUpdate = vi.fn(async () => {
|
||||
sequence.push('finalizing')
|
||||
})
|
||||
const service = new RemoteEnvironmentUpdateService(
|
||||
agent as never,
|
||||
runtime as never,
|
||||
afterUpdate
|
||||
)
|
||||
const progress: string[] = []
|
||||
|
||||
await expect(
|
||||
service.update(createOwner(), HOST_ID, (event) => {
|
||||
progress.push(event.phase)
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(agent.ensureInstalled).toHaveBeenCalledWith(HOST_ID, {
|
||||
force: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(runtime.ensureInstalled).toHaveBeenCalledWith(HOST_ID, {
|
||||
force: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
expect(signals[0]).toBe(signals[1])
|
||||
expect(sequence).toEqual(['agent', 'runtime', 'finalizing'])
|
||||
expect(progress).toEqual(['agent', 'runtime', 'finalizing'])
|
||||
expect(afterUpdate).toHaveBeenCalledWith(HOST_ID)
|
||||
})
|
||||
|
||||
it('does not run the Runtime or finalizer after an Agent error', async () => {
|
||||
const failure = new Error('agent failed')
|
||||
const agent = {
|
||||
ensureInstalled: vi.fn(async () => {
|
||||
throw failure
|
||||
})
|
||||
}
|
||||
const runtime = { ensureInstalled: vi.fn() }
|
||||
const afterUpdate = vi.fn()
|
||||
const service = new RemoteEnvironmentUpdateService(
|
||||
agent as never,
|
||||
runtime as never,
|
||||
afterUpdate
|
||||
)
|
||||
|
||||
await expect(
|
||||
service.update(createOwner(), HOST_ID)
|
||||
).rejects.toBe(failure)
|
||||
expect(runtime.ensureInstalled).not.toHaveBeenCalled()
|
||||
expect(afterUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('joins duplicate updates and rejects a different Host for the same owner', async () => {
|
||||
it('deduplicates the same Host and method across owners and fans out progress', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const agent = {
|
||||
ensureInstalled: vi.fn(async () => {
|
||||
let emit:
|
||||
| ((progress: RemoteEnvironmentUpdateProgress) => void)
|
||||
| undefined
|
||||
const preparer: RemoteEnvironmentPreparer = {
|
||||
prepare: vi.fn(async (_hostId, method, observer) => {
|
||||
expect(method).toBe('remote-download')
|
||||
emit = observer
|
||||
await gate
|
||||
return {}
|
||||
})
|
||||
}
|
||||
const runtime = {
|
||||
ensureInstalled: vi.fn(async () => ({}))
|
||||
}
|
||||
const owner = createOwner()
|
||||
const service = new RemoteEnvironmentUpdateService(
|
||||
agent as never,
|
||||
runtime as never
|
||||
)
|
||||
const service = new RemoteEnvironmentUpdateService(preparer)
|
||||
const firstProgress: string[] = []
|
||||
const secondProgress: string[] = []
|
||||
|
||||
const first = service.update(owner, HOST_ID)
|
||||
expect(service.update(owner, HOST_ID)).toBe(first)
|
||||
await expect(
|
||||
service.update(owner, OTHER_HOST_ID)
|
||||
).rejects.toThrow('another Host')
|
||||
const first = service.update(
|
||||
createOwner(1),
|
||||
{ hostId: HOST_ID, method: 'remote-download' },
|
||||
(progress) => firstProgress.push(progress.phase)
|
||||
)
|
||||
const second = service.update(
|
||||
createOwner(2),
|
||||
{ hostId: HOST_ID, method: 'remote-download' },
|
||||
(progress) => secondProgress.push(progress.phase)
|
||||
)
|
||||
await vi.waitFor(() => expect(emit).toBeTypeOf('function'))
|
||||
emit?.({
|
||||
hostId: HOST_ID,
|
||||
method: 'remote-download',
|
||||
phase: 'downloading'
|
||||
})
|
||||
release()
|
||||
await first
|
||||
|
||||
await Promise.all([first, second])
|
||||
expect(preparer.prepare).toHaveBeenCalledOnce()
|
||||
expect(firstProgress).toEqual([
|
||||
'downloading',
|
||||
'finalizing',
|
||||
'complete'
|
||||
])
|
||||
expect(secondProgress).toEqual(firstProgress)
|
||||
})
|
||||
|
||||
it('aborts on explicit cancellation and owner destruction', async () => {
|
||||
for (const cancel of ['explicit', 'destroyed'] as const) {
|
||||
let signal: AbortSignal | undefined
|
||||
const agent = {
|
||||
ensureInstalled: vi.fn(
|
||||
async (
|
||||
_hostId: string,
|
||||
options: { signal?: AbortSignal }
|
||||
) => {
|
||||
signal = options.signal
|
||||
return abortablePending(options.signal!)
|
||||
}
|
||||
)
|
||||
}
|
||||
const runtime = { ensureInstalled: vi.fn() }
|
||||
const owner = createOwner()
|
||||
const service = new RemoteEnvironmentUpdateService(
|
||||
agent as never,
|
||||
runtime as never
|
||||
)
|
||||
const update = service.update(owner, HOST_ID)
|
||||
await vi.waitFor(() =>
|
||||
expect(signal).toBeInstanceOf(AbortSignal)
|
||||
)
|
||||
|
||||
if (cancel === 'explicit') {
|
||||
service.cancel(owner, HOST_ID)
|
||||
} else {
|
||||
owner.destroy()
|
||||
}
|
||||
|
||||
await expect(update).rejects.toMatchObject({
|
||||
name: 'AbortError'
|
||||
})
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(runtime.ensureInstalled).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts active work and settles cleanly on disposal', async () => {
|
||||
it('rejects a different method while the same Host has active work', async () => {
|
||||
let signal: AbortSignal | undefined
|
||||
const agent = {
|
||||
ensureInstalled: vi.fn(
|
||||
async (
|
||||
_hostId: string,
|
||||
options: { signal?: AbortSignal }
|
||||
) => {
|
||||
signal = options.signal
|
||||
return abortablePending(options.signal!)
|
||||
}
|
||||
)
|
||||
const preparer: RemoteEnvironmentPreparer = {
|
||||
prepare: vi.fn(async (
|
||||
_hostId,
|
||||
_method,
|
||||
_observer,
|
||||
operationSignal
|
||||
) => {
|
||||
signal = operationSignal
|
||||
return abortablePending(operationSignal)
|
||||
})
|
||||
}
|
||||
const service = new RemoteEnvironmentUpdateService(
|
||||
agent as never,
|
||||
{ ensureInstalled: vi.fn() } as never
|
||||
const service = new RemoteEnvironmentUpdateService(preparer)
|
||||
const owner = createOwner()
|
||||
const active = service.update(
|
||||
owner,
|
||||
{ hostId: HOST_ID, method: 'auto' }
|
||||
)
|
||||
|
||||
await expect(service.update(
|
||||
createOwner(2),
|
||||
{ hostId: HOST_ID, method: 'goodbuddy-transfer' }
|
||||
)).rejects.toThrow('conflicting')
|
||||
owner.destroy()
|
||||
await expect(active).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels only one waiter and aborts underlying work when the last owner is destroyed', async () => {
|
||||
let signal: AbortSignal | undefined
|
||||
const preparer: RemoteEnvironmentPreparer = {
|
||||
prepare: vi.fn(async (
|
||||
_hostId,
|
||||
_method,
|
||||
_observer,
|
||||
operationSignal
|
||||
) => {
|
||||
signal = operationSignal
|
||||
return abortablePending(operationSignal)
|
||||
})
|
||||
}
|
||||
const service = new RemoteEnvironmentUpdateService(preparer)
|
||||
const firstOwner = createOwner(1)
|
||||
const secondOwner = createOwner(2)
|
||||
const first = service.update(
|
||||
firstOwner,
|
||||
{ hostId: HOST_ID, method: 'remote-download' }
|
||||
)
|
||||
const second = service.update(
|
||||
secondOwner,
|
||||
{ hostId: HOST_ID, method: 'remote-download' }
|
||||
)
|
||||
const update = service.update(createOwner(), HOST_ID)
|
||||
await vi.waitFor(() =>
|
||||
expect(signal).toBeInstanceOf(AbortSignal)
|
||||
)
|
||||
|
||||
await expect(service.dispose()).resolves.toBeUndefined()
|
||||
await expect(update).rejects.toMatchObject({ name: 'AbortError' })
|
||||
service.cancel(firstOwner, HOST_ID)
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(signal?.aborted).toBe(false)
|
||||
|
||||
secondOwner.destroy()
|
||||
await expect(second).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(() =>
|
||||
service.update(createOwner(), HOST_ID)
|
||||
).toThrow('disposed')
|
||||
expect(firstOwner.removeListener).toHaveBeenCalledOnce()
|
||||
expect(secondOwner.removeListener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('bounds disposal when an installation ignores cancellation', async () => {
|
||||
it('aborts active work and bounds disposal when the preparer ignores cancellation', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let started = false
|
||||
const service = new RemoteEnvironmentUpdateService(
|
||||
{
|
||||
ensureInstalled: vi.fn(async () => {
|
||||
started = true
|
||||
await new Promise(() => undefined)
|
||||
})
|
||||
} as never,
|
||||
{ ensureInstalled: vi.fn() } as never
|
||||
)
|
||||
void service.update(createOwner(), HOST_ID)
|
||||
await vi.waitFor(() => expect(started).toBe(true))
|
||||
let signal: AbortSignal | undefined
|
||||
const preparer: RemoteEnvironmentPreparer = {
|
||||
prepare: vi.fn(async (
|
||||
_hostId,
|
||||
_method,
|
||||
_observer,
|
||||
operationSignal
|
||||
) => {
|
||||
signal = operationSignal
|
||||
await new Promise(() => undefined)
|
||||
})
|
||||
}
|
||||
const service = new RemoteEnvironmentUpdateService(preparer)
|
||||
void service.update(
|
||||
createOwner(),
|
||||
{ hostId: HOST_ID, method: 'remote-download' }
|
||||
).catch(() => undefined)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(signal).toBeInstanceOf(AbortSignal)
|
||||
|
||||
const disposal = service.dispose()
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
await expect(disposal).resolves.toBeUndefined()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(() => service.update(
|
||||
createOwner(2),
|
||||
{ hostId: HOST_ID, method: 'remote-download' }
|
||||
)).toThrow('disposed')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates progress observer errors from the update outcome', async () => {
|
||||
const afterUpdate = vi.fn()
|
||||
it('isolates observers and preserves an auto-resolved method through finalizing and complete', async () => {
|
||||
const afterUpdate = vi.fn(async () => undefined)
|
||||
const preparer: RemoteEnvironmentPreparer = {
|
||||
prepare: vi.fn(async (hostId, method, observer, signal) => {
|
||||
expect(hostId).toBe(HOST_ID)
|
||||
expect(method).toBe('auto')
|
||||
expect(signal.aborted).toBe(false)
|
||||
observer?.({
|
||||
hostId,
|
||||
method: 'remote-download',
|
||||
phase: 'downloading'
|
||||
})
|
||||
})
|
||||
}
|
||||
const service = new RemoteEnvironmentUpdateService(
|
||||
{ ensureInstalled: vi.fn(async () => ({})) } as never,
|
||||
{ ensureInstalled: vi.fn(async () => ({})) } as never,
|
||||
preparer,
|
||||
afterUpdate
|
||||
)
|
||||
const observed: RemoteEnvironmentUpdateProgress[] = []
|
||||
const throwingObserver = vi.fn(() => {
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
const first = service.update(
|
||||
createOwner(1),
|
||||
{ hostId: HOST_ID, method: 'auto' },
|
||||
throwingObserver
|
||||
)
|
||||
const second = service.update(
|
||||
createOwner(2),
|
||||
{ hostId: HOST_ID, method: 'auto' },
|
||||
(event) => observed.push(event)
|
||||
)
|
||||
|
||||
await expect(
|
||||
service.update(createOwner(), HOST_ID, () => {
|
||||
throw new Error('observer failed')
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
undefined,
|
||||
undefined
|
||||
])
|
||||
expect(afterUpdate).toHaveBeenCalledWith(HOST_ID)
|
||||
expect(throwingObserver).toHaveBeenCalledTimes(3)
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
hostId: HOST_ID,
|
||||
method: 'remote-download',
|
||||
phase: 'downloading'
|
||||
},
|
||||
{
|
||||
hostId: HOST_ID,
|
||||
method: 'remote-download',
|
||||
phase: 'finalizing'
|
||||
},
|
||||
{
|
||||
hostId: HOST_ID,
|
||||
method: 'remote-download',
|
||||
phase: 'complete'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let preparer-emitted terminal phases bypass finalization', async () => {
|
||||
const order: string[] = []
|
||||
const preparer: RemoteEnvironmentPreparer = {
|
||||
prepare: vi.fn(async (hostId, method, observer) => {
|
||||
observer?.({ hostId, method, phase: 'finalizing' })
|
||||
observer?.({ hostId, method, phase: 'complete' })
|
||||
order.push('prepared')
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
expect(afterUpdate).toHaveBeenCalledOnce()
|
||||
}
|
||||
const service = new RemoteEnvironmentUpdateService(
|
||||
preparer,
|
||||
async () => {
|
||||
order.push('after')
|
||||
}
|
||||
)
|
||||
|
||||
await service.update(
|
||||
createOwner(),
|
||||
{ hostId: HOST_ID, method: 'goodbuddy-transfer' },
|
||||
(event) => order.push(event.phase)
|
||||
)
|
||||
|
||||
expect(order).toEqual([
|
||||
'prepared',
|
||||
'finalizing',
|
||||
'after',
|
||||
'complete'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type {
|
||||
RemoteEnvironmentUpdateProgress
|
||||
RemoteEnvironmentPreparationMethod,
|
||||
RemoteEnvironmentUpdateProgress,
|
||||
RemoteEnvironmentUpdateRequest
|
||||
} from '../../shared/ssh-host-contracts'
|
||||
import type { AgentInstallationManager } from './agent-installation-manager'
|
||||
import type {
|
||||
RemoteRuntimeInstallationManager
|
||||
} from './remote-runtime-installation-manager'
|
||||
import { settleBoundedly } from './bounded-settlement'
|
||||
|
||||
const DISPOSE_WAIT_TIMEOUT_MS = 5_000
|
||||
@@ -20,114 +18,96 @@ export type RemoteEnvironmentUpdateProgressObserver = (
|
||||
progress: RemoteEnvironmentUpdateProgress
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Narrow boundary for the SSH-side download, verification, installation, and
|
||||
* adoption workflow. Its implementation remains Main-only.
|
||||
*/
|
||||
export interface RemoteEnvironmentPreparer {
|
||||
prepare(
|
||||
hostId: string,
|
||||
method: RemoteEnvironmentPreparationMethod,
|
||||
observer: RemoteEnvironmentUpdateProgressObserver | undefined,
|
||||
signal: AbortSignal
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
type ActiveUpdate = {
|
||||
hostId: string
|
||||
request: RemoteEnvironmentUpdateRequest
|
||||
controller: AbortController
|
||||
observers: Set<RemoteEnvironmentUpdateProgressObserver>
|
||||
waiters: Set<UpdateWaiter>
|
||||
promise: Promise<void>
|
||||
settled: boolean
|
||||
lastEmittedPhase?: RemoteEnvironmentUpdateProgress['phase']
|
||||
lastEmittedMethod?: RemoteEnvironmentPreparationMethod
|
||||
}
|
||||
|
||||
type UpdateWaiter = {
|
||||
owner: RemoteEnvironmentUpdateOwner
|
||||
active: ActiveUpdate
|
||||
observer?: RemoteEnvironmentUpdateProgressObserver
|
||||
promise: Promise<void>
|
||||
resolve: () => void
|
||||
reject: (reason: unknown) => void
|
||||
settled: boolean
|
||||
}
|
||||
|
||||
type OwnerState = {
|
||||
listener: () => void
|
||||
waitersByHost: Map<string, Set<UpdateWaiter>>
|
||||
}
|
||||
|
||||
export class RemoteEnvironmentUpdateService {
|
||||
readonly #agentInstallationManager: Pick<
|
||||
AgentInstallationManager,
|
||||
'ensureInstalled'
|
||||
>
|
||||
readonly #runtimeInstallationManager: Pick<
|
||||
RemoteRuntimeInstallationManager,
|
||||
'ensureInstalled'
|
||||
>
|
||||
readonly #preparer: RemoteEnvironmentPreparer
|
||||
readonly #afterUpdate?: (hostId: string) => void | Promise<void>
|
||||
readonly #active = new Map<
|
||||
RemoteEnvironmentUpdateOwner,
|
||||
ActiveUpdate
|
||||
>()
|
||||
readonly #activeByHost = new Map<string, ActiveUpdate>()
|
||||
readonly #owners = new Map<RemoteEnvironmentUpdateOwner, OwnerState>()
|
||||
#disposed = false
|
||||
#disposePromise?: Promise<void>
|
||||
|
||||
constructor(
|
||||
agentInstallationManager: Pick<
|
||||
AgentInstallationManager,
|
||||
'ensureInstalled'
|
||||
>,
|
||||
runtimeInstallationManager: Pick<
|
||||
RemoteRuntimeInstallationManager,
|
||||
'ensureInstalled'
|
||||
>,
|
||||
preparer: RemoteEnvironmentPreparer,
|
||||
afterUpdate?: (hostId: string) => void | Promise<void>
|
||||
) {
|
||||
this.#agentInstallationManager = agentInstallationManager
|
||||
this.#runtimeInstallationManager = runtimeInstallationManager
|
||||
this.#preparer = preparer
|
||||
this.#afterUpdate = afterUpdate
|
||||
}
|
||||
|
||||
update(
|
||||
owner: RemoteEnvironmentUpdateOwner,
|
||||
hostId: string,
|
||||
onProgress?: RemoteEnvironmentUpdateProgressObserver
|
||||
request: RemoteEnvironmentUpdateRequest,
|
||||
observer?: RemoteEnvironmentUpdateProgressObserver
|
||||
): Promise<void> {
|
||||
this.#assertAvailable(owner)
|
||||
const existing = this.#active.get(owner)
|
||||
if (existing) {
|
||||
if (existing.hostId === hostId) {
|
||||
return existing.promise
|
||||
}
|
||||
const existing = this.#activeByHost.get(request.hostId)
|
||||
if (existing && existing.request.method !== request.method) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'A remote environment update is already running for another Host'
|
||||
'A conflicting remote environment preparation method is already running for this Host'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const destroyedListener = (): void => {
|
||||
controller.abort(
|
||||
new DOMException(
|
||||
'Remote environment update owner was destroyed',
|
||||
'AbortError'
|
||||
)
|
||||
)
|
||||
}
|
||||
const promise = this.#run(
|
||||
hostId,
|
||||
controller.signal,
|
||||
onProgress
|
||||
).finally(() => {
|
||||
owner.removeListener('destroyed', destroyedListener)
|
||||
if (this.#active.get(owner)?.promise === promise) {
|
||||
this.#active.delete(owner)
|
||||
}
|
||||
})
|
||||
const active: ActiveUpdate = {
|
||||
hostId,
|
||||
controller,
|
||||
promise
|
||||
}
|
||||
this.#active.set(owner, active)
|
||||
owner.on('destroyed', destroyedListener)
|
||||
if (owner.isDestroyed()) {
|
||||
destroyedListener()
|
||||
}
|
||||
return promise
|
||||
const active = existing ?? this.#createActive(request)
|
||||
return this.#attachWaiter(owner, active, observer)
|
||||
}
|
||||
|
||||
cancel(
|
||||
owner: RemoteEnvironmentUpdateOwner,
|
||||
hostId: string
|
||||
): void {
|
||||
const active = this.#active.get(owner)
|
||||
if (!active) {
|
||||
const state = this.#owners.get(owner)
|
||||
const waiters = state?.waitersByHost.get(hostId)
|
||||
if (!waiters || waiters.size === 0) {
|
||||
return
|
||||
}
|
||||
if (active.hostId !== hostId) {
|
||||
throw new Error(
|
||||
'The active remote environment update targets another Host'
|
||||
)
|
||||
}
|
||||
active.controller.abort(
|
||||
new DOMException(
|
||||
'Remote environment update was cancelled',
|
||||
'AbortError'
|
||||
)
|
||||
const reason = new DOMException(
|
||||
'Remote environment update was cancelled',
|
||||
'AbortError'
|
||||
)
|
||||
for (const waiter of [...waiters]) {
|
||||
this.#detachWaiter(waiter, reason)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
@@ -135,37 +115,222 @@ export class RemoteEnvironmentUpdateService {
|
||||
return this.#disposePromise
|
||||
}
|
||||
|
||||
async #run(
|
||||
hostId: string,
|
||||
signal: AbortSignal,
|
||||
onProgress?: RemoteEnvironmentUpdateProgressObserver
|
||||
#createActive(
|
||||
request: RemoteEnvironmentUpdateRequest
|
||||
): ActiveUpdate {
|
||||
const controller = new AbortController()
|
||||
const active: ActiveUpdate = {
|
||||
request,
|
||||
controller,
|
||||
observers: new Set<RemoteEnvironmentUpdateProgressObserver>(),
|
||||
waiters: new Set<UpdateWaiter>(),
|
||||
promise: Promise.resolve(),
|
||||
settled: false,
|
||||
lastEmittedPhase: undefined,
|
||||
lastEmittedMethod: undefined
|
||||
}
|
||||
active.promise = Promise.resolve()
|
||||
.then(() => this.#run(active))
|
||||
.finally(() => {
|
||||
active.settled = true
|
||||
if (this.#activeByHost.get(request.hostId) === active) {
|
||||
this.#activeByHost.delete(request.hostId)
|
||||
}
|
||||
})
|
||||
// Every operation promise is observed by its waiters. This additional
|
||||
// handler prevents a late rejection from becoming unhandled if all
|
||||
// waiters cancel before the underlying operation notices cancellation.
|
||||
void active.promise.catch(() => undefined)
|
||||
this.#activeByHost.set(request.hostId, active)
|
||||
return active
|
||||
}
|
||||
|
||||
#attachWaiter(
|
||||
owner: RemoteEnvironmentUpdateOwner,
|
||||
active: ActiveUpdate,
|
||||
observer?: RemoteEnvironmentUpdateProgressObserver
|
||||
): Promise<void> {
|
||||
this.#emit(onProgress, { hostId, phase: 'agent' })
|
||||
await this.#agentInstallationManager.ensureInstalled(hostId, {
|
||||
force: true,
|
||||
signal
|
||||
let resolve!: () => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
signal.throwIfAborted()
|
||||
const waiter: UpdateWaiter = {
|
||||
owner,
|
||||
active,
|
||||
observer,
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
settled: false
|
||||
}
|
||||
active.waiters.add(waiter)
|
||||
if (observer) {
|
||||
active.observers.add(observer)
|
||||
}
|
||||
|
||||
this.#emit(onProgress, { hostId, phase: 'runtime' })
|
||||
await this.#runtimeInstallationManager.ensureInstalled(hostId, {
|
||||
force: true,
|
||||
signal
|
||||
let ownerState = this.#owners.get(owner)
|
||||
if (!ownerState) {
|
||||
const listener = (): void => {
|
||||
this.#detachOwner(
|
||||
owner,
|
||||
new DOMException(
|
||||
'Remote environment update owner was destroyed',
|
||||
'AbortError'
|
||||
)
|
||||
)
|
||||
}
|
||||
ownerState = {
|
||||
listener,
|
||||
waitersByHost: new Map()
|
||||
}
|
||||
this.#owners.set(owner, ownerState)
|
||||
owner.on('destroyed', listener)
|
||||
}
|
||||
let hostWaiters = ownerState.waitersByHost.get(
|
||||
active.request.hostId
|
||||
)
|
||||
if (!hostWaiters) {
|
||||
hostWaiters = new Set()
|
||||
ownerState.waitersByHost.set(
|
||||
active.request.hostId,
|
||||
hostWaiters
|
||||
)
|
||||
}
|
||||
hostWaiters.add(waiter)
|
||||
|
||||
void active.promise.then(
|
||||
() => this.#detachWaiter(waiter),
|
||||
(error) => this.#detachWaiter(waiter, error)
|
||||
)
|
||||
if (owner.isDestroyed()) {
|
||||
ownerState.listener()
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
#detachOwner(
|
||||
owner: RemoteEnvironmentUpdateOwner,
|
||||
reason: unknown
|
||||
): void {
|
||||
const state = this.#owners.get(owner)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
const waiters = [...state.waitersByHost.values()]
|
||||
.flatMap((entries) => [...entries])
|
||||
for (const waiter of waiters) {
|
||||
this.#detachWaiter(waiter, reason)
|
||||
}
|
||||
}
|
||||
|
||||
#detachWaiter(
|
||||
waiter: UpdateWaiter,
|
||||
rejection?: unknown
|
||||
): void {
|
||||
if (waiter.settled) {
|
||||
return
|
||||
}
|
||||
waiter.settled = true
|
||||
const { active, owner, observer } = waiter
|
||||
active.waiters.delete(waiter)
|
||||
if (
|
||||
observer &&
|
||||
![...active.waiters].some(
|
||||
(candidate) => candidate.observer === observer
|
||||
)
|
||||
) {
|
||||
active.observers.delete(observer)
|
||||
}
|
||||
|
||||
const ownerState = this.#owners.get(owner)
|
||||
const hostWaiters = ownerState?.waitersByHost.get(
|
||||
active.request.hostId
|
||||
)
|
||||
hostWaiters?.delete(waiter)
|
||||
if (hostWaiters?.size === 0) {
|
||||
ownerState?.waitersByHost.delete(active.request.hostId)
|
||||
}
|
||||
if (ownerState?.waitersByHost.size === 0) {
|
||||
owner.removeListener('destroyed', ownerState.listener)
|
||||
this.#owners.delete(owner)
|
||||
}
|
||||
|
||||
if (rejection === undefined) {
|
||||
waiter.resolve()
|
||||
} else {
|
||||
waiter.reject(rejection)
|
||||
}
|
||||
if (
|
||||
active.waiters.size === 0 &&
|
||||
!active.settled &&
|
||||
!active.controller.signal.aborted
|
||||
) {
|
||||
active.controller.abort(
|
||||
new DOMException(
|
||||
'Remote environment update has no remaining waiters',
|
||||
'AbortError'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async #run(active: ActiveUpdate): Promise<void> {
|
||||
const { request, controller } = active
|
||||
await this.#preparer.prepare(
|
||||
request.hostId,
|
||||
request.method,
|
||||
(progress) => {
|
||||
if (
|
||||
progress.phase === 'finalizing' ||
|
||||
progress.phase === 'complete'
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.#emit(active, {
|
||||
...progress,
|
||||
hostId: request.hostId
|
||||
})
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
controller.signal.throwIfAborted()
|
||||
|
||||
const resolvedMethod =
|
||||
active.lastEmittedMethod ?? request.method
|
||||
this.#emit(active, {
|
||||
hostId: request.hostId,
|
||||
method: resolvedMethod,
|
||||
phase: 'finalizing'
|
||||
})
|
||||
await this.#afterUpdate?.(request.hostId)
|
||||
controller.signal.throwIfAborted()
|
||||
this.#emit(active, {
|
||||
hostId: request.hostId,
|
||||
method: resolvedMethod,
|
||||
phase: 'complete'
|
||||
})
|
||||
signal.throwIfAborted()
|
||||
|
||||
this.#emit(onProgress, { hostId, phase: 'finalizing' })
|
||||
await this.#afterUpdate?.(hostId)
|
||||
}
|
||||
|
||||
#emit(
|
||||
observer: RemoteEnvironmentUpdateProgressObserver | undefined,
|
||||
active: ActiveUpdate,
|
||||
progress: RemoteEnvironmentUpdateProgress
|
||||
): void {
|
||||
try {
|
||||
observer?.(progress)
|
||||
} catch {
|
||||
// Progress observers cannot alter the update outcome.
|
||||
if (
|
||||
active.lastEmittedPhase === progress.phase &&
|
||||
active.lastEmittedMethod === progress.method
|
||||
) {
|
||||
return
|
||||
}
|
||||
active.lastEmittedPhase = progress.phase
|
||||
active.lastEmittedMethod = progress.method
|
||||
for (const observer of active.observers) {
|
||||
try {
|
||||
observer(progress)
|
||||
} catch {
|
||||
// Progress observers cannot alter the update outcome.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +345,7 @@ export class RemoteEnvironmentUpdateService {
|
||||
|
||||
async #dispose(): Promise<void> {
|
||||
this.#disposed = true
|
||||
const operations = [...this.#active.values()]
|
||||
const operations = [...this.#activeByHost.values()]
|
||||
for (const active of operations) {
|
||||
active.controller.abort(
|
||||
new DOMException(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { SshConnectionPoolTarget } from '../ssh/ssh-connection-pool'
|
||||
|
||||
/**
|
||||
* Stable Main-only key for one exact resolved SSH target. All Host mutation
|
||||
* coordinators use the same material so credential and Host Key edits cannot
|
||||
* be overlooked by one component.
|
||||
*/
|
||||
export function remoteHostTargetIdentityKey(
|
||||
target: SshConnectionPoolTarget
|
||||
): string {
|
||||
const hostKey = target.host.hostKey
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([
|
||||
'goodbuddy-remote-host-target-v1',
|
||||
target.host.id,
|
||||
target.host.name,
|
||||
target.host.hostname,
|
||||
target.host.port,
|
||||
target.host.username,
|
||||
target.host.authentication,
|
||||
target.host.password ?? null,
|
||||
target.hostRevision,
|
||||
target.hostKeyGeneration,
|
||||
hostKey?.algorithm ?? null,
|
||||
hostKey?.publicKeyBase64 ?? null,
|
||||
hostKey?.fingerprintSha256 ?? null,
|
||||
hostKey?.acceptedAt ?? null,
|
||||
hostKey?.generation ?? null
|
||||
]))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for recovering a remote operation after an ordinary Host
|
||||
* edit or revalidation. It binds the actual SSH account and Host Key, but not
|
||||
* display names, credentials, acceptance timestamps, or local revisions.
|
||||
*/
|
||||
export function remoteHostRecoveryIdentityKey(
|
||||
target: SshConnectionPoolTarget
|
||||
): string {
|
||||
const hostKey = target.host.hostKey
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([
|
||||
'goodbuddy-remote-host-recovery-v1',
|
||||
target.host.id,
|
||||
target.host.hostname,
|
||||
target.host.port,
|
||||
target.host.username,
|
||||
hostKey?.algorithm ?? null,
|
||||
hostKey?.publicKeyBase64 ?? null
|
||||
]))
|
||||
.digest('hex')
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -26,12 +25,15 @@ afterEach(async () => {
|
||||
|
||||
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')
|
||||
@@ -47,6 +49,39 @@ const draft = {
|
||||
remoteRootPath: root
|
||||
}
|
||||
|
||||
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(
|
||||
overrides: Partial<AssistantProject> = {}
|
||||
): AssistantProject {
|
||||
return {
|
||||
id: projectId,
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
rootPath: root,
|
||||
defaultWorkMode: draft.defaultWorkMode,
|
||||
runtimeSelection: draft.runtimeSelection,
|
||||
kind: 'user',
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId,
|
||||
remoteRootPath: root
|
||||
},
|
||||
status: 'active',
|
||||
createdAt: '2030-01-01T00:00:00.000Z',
|
||||
updatedAt: '2030-01-01T00:00:00.000Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function target(): SshConnectionTarget {
|
||||
return {
|
||||
host: {
|
||||
@@ -69,40 +104,19 @@ function target(): SshConnectionTarget {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
type HarnessOptions = {
|
||||
getProject?: () => AssistantProject
|
||||
validateWorkspace?: (signal?: AbortSignal) => unknown | Promise<unknown>
|
||||
create?: () => AssistantProject
|
||||
update?: () => AssistantProject
|
||||
resolveRuntimeSelection?: (
|
||||
selection: AgentRuntimeSelection
|
||||
) => Promise<AgentRuntimeSelection>
|
||||
} = {}) {
|
||||
}
|
||||
|
||||
function harness(options: HarnessOptions = {}) {
|
||||
const calls: string[] = []
|
||||
const currentTarget = target()
|
||||
const progress: string[] = []
|
||||
const workspace = {
|
||||
workspaceId: 'workspace-id',
|
||||
workspaceIdentity: 'workspace-identity',
|
||||
@@ -119,25 +133,31 @@ function harness(options: {
|
||||
],
|
||||
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
|
||||
const request = vi.fn(
|
||||
async (method: string, _params: unknown, requestOptions?: {
|
||||
signal?: AbortSignal
|
||||
}) => {
|
||||
calls.push(method)
|
||||
if (method === 'workspace/validate') {
|
||||
return options.validateWorkspace
|
||||
? await options.validateWorkspace(requestOptions?.signal)
|
||||
: {
|
||||
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}`)
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`)
|
||||
)
|
||||
const connectionRelease = vi.fn(() => {
|
||||
calls.push('connection-release')
|
||||
})
|
||||
const connection = {
|
||||
identity: {
|
||||
@@ -156,7 +176,7 @@ function harness(options: {
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: digest,
|
||||
daemonBootId: 'boot',
|
||||
agentVersion: '1.2.3',
|
||||
agentVersion: installation.agentVersion,
|
||||
protocol: { major: 1, minor: 0 },
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
@@ -167,7 +187,13 @@ function harness(options: {
|
||||
capabilities: {
|
||||
generation: 1,
|
||||
capabilities: [
|
||||
{ name: 'workspace/read', version: 1, critical: true }
|
||||
{ name: 'workspace/read', version: 1, critical: true },
|
||||
{ name: 'runtime/acp', version: 3, critical: true },
|
||||
{
|
||||
name: 'runtime/model-bridge',
|
||||
version: 1,
|
||||
critical: true
|
||||
}
|
||||
],
|
||||
runtimes: []
|
||||
},
|
||||
@@ -175,68 +201,57 @@ function harness(options: {
|
||||
state: 'ready',
|
||||
release: connectionRelease
|
||||
} as unknown as RemoteAgentConnection
|
||||
const runtimeRelease = vi.fn(() => calls.push('runtime-release'))
|
||||
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 writes: Array<
|
||||
Parameters<
|
||||
RemoteProjectSaveServiceOptions['database']['createSshProject']
|
||||
>[0]
|
||||
> = []
|
||||
const persist = (
|
||||
operation: 'database-create' | 'database-update',
|
||||
write: (typeof writes)[number],
|
||||
result: AssistantProject
|
||||
): AssistantProject => {
|
||||
calls.push(operation)
|
||||
writes.push(write)
|
||||
write.assertCurrent()
|
||||
return result
|
||||
}
|
||||
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 createProject = vi.fn((write: (typeof writes)[number]) =>
|
||||
persist('database-create', write, options.create?.() ?? project())
|
||||
)
|
||||
const updateProject = vi.fn(
|
||||
(
|
||||
_id: string,
|
||||
_updatedAt: string,
|
||||
write: (typeof writes)[number]
|
||||
) => persist('database-update', write, options.update?.() ?? project())
|
||||
)
|
||||
const service = new RemoteProjectSaveService({
|
||||
database: {
|
||||
getProject: vi.fn(() => options.getProject?.() ?? project()),
|
||||
createValidatedSshProject: createProject,
|
||||
updateValidatedSshProject: updateProject
|
||||
createSshProject: createProject,
|
||||
updateSshProject: updateProject
|
||||
},
|
||||
sshHosts: {
|
||||
resolveConnectionTarget: vi.fn(async () => {
|
||||
calls.push('host')
|
||||
return structuredClone(currentTarget)
|
||||
return target()
|
||||
}),
|
||||
assertConnectionTargetCurrent: vi.fn(() => calls.push('host-current'))
|
||||
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
|
||||
activateInstalled: vi.fn(async () => {
|
||||
calls.push('activate')
|
||||
return installation
|
||||
})
|
||||
},
|
||||
connectionManager: {
|
||||
@@ -249,15 +264,9 @@ function harness(options: {
|
||||
options.resolveRuntimeSelection ??
|
||||
(async (selection) => selection),
|
||||
runtimeValidator: {
|
||||
validate: vi.fn(async (input) => {
|
||||
validate: vi.fn(async () => {
|
||||
calls.push('runtime')
|
||||
return {
|
||||
...runtimeLease,
|
||||
evidence: {
|
||||
...runtimeLease.evidence,
|
||||
runtimeSelectionKey: input.runtimeSelectionKey
|
||||
}
|
||||
}
|
||||
return runtimeLease
|
||||
})
|
||||
},
|
||||
notify: (_owner, value) => progress.push(value.phase)
|
||||
@@ -266,20 +275,24 @@ function harness(options: {
|
||||
return {
|
||||
service,
|
||||
calls,
|
||||
progress,
|
||||
writes,
|
||||
createProject,
|
||||
updateProject,
|
||||
runtimeRelease,
|
||||
request,
|
||||
connectionRelease,
|
||||
progress
|
||||
runtimeRelease
|
||||
}
|
||||
}
|
||||
|
||||
describe('RemoteProjectSaveService awaited save', () => {
|
||||
it('validates and creates in one awaited request, then cleans up in reverse order', async () => {
|
||||
describe('RemoteProjectSaveService', () => {
|
||||
it('prepares the current Host and creates one stable project record', 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',
|
||||
@@ -287,50 +300,58 @@ describe('RemoteProjectSaveService awaited save', () => {
|
||||
'runtime',
|
||||
'saving'
|
||||
])
|
||||
expect(value.createProject).toHaveBeenCalledOnce()
|
||||
expect(value.calls.slice(-3)).toEqual([
|
||||
expect(value.writes[0]).toMatchObject({
|
||||
project: {
|
||||
rootPath: root,
|
||||
runtimeSelection: { provider: 'opencode' }
|
||||
},
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId,
|
||||
remoteRootPath: root
|
||||
}
|
||||
})
|
||||
expect(value.calls).toEqual([
|
||||
'host',
|
||||
'activate',
|
||||
'attach',
|
||||
'workspace/validate',
|
||||
'runtime',
|
||||
'runtime-current',
|
||||
'database-create',
|
||||
'host-current',
|
||||
'runtime-current',
|
||||
'runtime-release',
|
||||
'workspace/close',
|
||||
'connection-release'
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts a readable non-Git remote workspace', async () => {
|
||||
it('persists the resolved Runtime selection', async () => {
|
||||
const selection = {
|
||||
provider: 'opencode' as const,
|
||||
profileId: '00000000-0000-4000-8000-000000000099'
|
||||
}
|
||||
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'
|
||||
})
|
||||
resolveRuntimeSelection: vi.fn(async () => selection)
|
||||
})
|
||||
|
||||
await expect(
|
||||
value.service.save(new Owner(1), {
|
||||
intent: 'create',
|
||||
draft
|
||||
})
|
||||
).resolves.toMatchObject({ id: projectId })
|
||||
expect(value.createProject).toHaveBeenCalledOnce()
|
||||
await value.service.save(new Owner(2), {
|
||||
intent: 'create',
|
||||
draft
|
||||
})
|
||||
|
||||
expect(value.writes[0]?.project.runtimeSelection).toEqual(selection)
|
||||
})
|
||||
|
||||
it('updates using the project revision captured before remote validation', async () => {
|
||||
it('uses the preflight project revision for updates', async () => {
|
||||
const value = harness()
|
||||
await value.service.save(new Owner(1), {
|
||||
|
||||
await value.service.save(new Owner(3), {
|
||||
intent: 'update',
|
||||
draft: { projectId, ...draft }
|
||||
draft: { ...draft, projectId }
|
||||
})
|
||||
|
||||
expect(value.updateProject).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
'2030-01-01T00:00:00.000Z',
|
||||
@@ -338,173 +359,68 @@ describe('RemoteProjectSaveService awaited save', () => {
|
||||
)
|
||||
})
|
||||
|
||||
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')
|
||||
throw new Error('workspace denied')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
value.service.save(new Owner(1), { intent: 'create', draft })
|
||||
).rejects.toThrow('workspace unavailable')
|
||||
value.service.save(new Owner(7), { intent: 'create', draft })
|
||||
).rejects.toThrow('workspace denied')
|
||||
expect(value.createProject).not.toHaveBeenCalled()
|
||||
expect(value.connectionRelease).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels the current owner save without an operation id', async () => {
|
||||
it('cancels the active owner request', async () => {
|
||||
const value = harness({
|
||||
ensureInstalled: (signal) =>
|
||||
new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true
|
||||
})
|
||||
validateWorkspace: async (signal) =>
|
||||
await 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'))
|
||||
const owner = new Owner(8)
|
||||
const pending = value.service.save(owner, {
|
||||
intent: 'create',
|
||||
draft
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(value.calls).toContain('workspace/validate')
|
||||
})
|
||||
|
||||
value.service.cancelCurrent(owner)
|
||||
await expect(saving).rejects.toMatchObject({ name: 'AbortError' })
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(value.createProject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels when the renderer owner is destroyed', async () => {
|
||||
it('cancels when the owner is destroyed', async () => {
|
||||
const value = harness({
|
||||
ensureInstalled: (signal) =>
|
||||
new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true
|
||||
})
|
||||
validateWorkspace: async (signal) =>
|
||||
await 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(9)
|
||||
const pending = value.service.save(owner, {
|
||||
intent: 'create',
|
||||
draft
|
||||
})
|
||||
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 })
|
||||
await vi.waitFor(() => {
|
||||
expect(value.calls).toContain('workspace/validate')
|
||||
})
|
||||
|
||||
owner.destroy()
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type {
|
||||
AssistantProject,
|
||||
ProjectRuntimeValidation,
|
||||
SshExecutionValidation
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { projectRuntimeValidationSchema } from '../../shared/assistant-contracts'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import {
|
||||
remoteProjectSaveRequestSchema,
|
||||
type RemoteProjectSavePhase,
|
||||
@@ -17,27 +11,26 @@ import {
|
||||
remoteWorkspaceValidateResultSchema,
|
||||
type RemoteWorkspaceHandle
|
||||
} from '../../shared/remote-agent-contracts'
|
||||
import {
|
||||
agentRuntimeSelectionKey,
|
||||
type AgentRuntimeSelection
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
import type {
|
||||
AssistantDatabase,
|
||||
ValidatedSshHostPrecondition,
|
||||
ValidatedSshProjectWrite
|
||||
SshProjectWrite
|
||||
} from '../assistant/assistant-database'
|
||||
import type {
|
||||
CurrentSshConnectionTarget,
|
||||
SshConnectionTarget,
|
||||
SshHostStore
|
||||
} from '../ssh/ssh-host-store'
|
||||
import {
|
||||
assertValidSshConnectionTarget,
|
||||
toCurrentSshConnectionTarget
|
||||
} from '../ssh/ssh-connection-target'
|
||||
import type {
|
||||
AgentInstallationIdentity,
|
||||
AgentInstallationManager
|
||||
} from './agent-installation-manager'
|
||||
import type {
|
||||
RemoteAgentConnection,
|
||||
RemoteAgentConnectionManager
|
||||
import {
|
||||
type RemoteAgentConnection,
|
||||
type RemoteAgentConnectionManager
|
||||
} from './remote-agent-connection-manager'
|
||||
|
||||
const CLEANUP_TIMEOUT_MS = 5_000
|
||||
@@ -56,8 +49,6 @@ export interface RemoteProjectSaveOwner {
|
||||
|
||||
export type RemoteProjectRuntimeValidationInput = Readonly<{
|
||||
selection: AgentRuntimeSelection
|
||||
runtimeSelectionKey: string
|
||||
workMode: 'ask' | 'execute'
|
||||
host: Readonly<{
|
||||
hostId: string
|
||||
hostRevision: number
|
||||
@@ -65,29 +56,17 @@ export type RemoteProjectRuntimeValidationInput = Readonly<{
|
||||
remoteUsername: string
|
||||
}>
|
||||
agent: Readonly<{
|
||||
installationId: string
|
||||
installationId: AgentInstallationIdentity['installationId']
|
||||
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
|
||||
}
|
||||
@@ -111,24 +90,38 @@ type ActiveSave = {
|
||||
done: Promise<void>
|
||||
resolveDone: () => void
|
||||
destroyedListener: () => void
|
||||
installation?: AgentInstallationIdentity
|
||||
connection?: RemoteAgentConnection
|
||||
workspace?: RemoteWorkspaceHandle
|
||||
runtimeLease?: RemoteProjectRuntimeValidationLease
|
||||
}
|
||||
|
||||
type RemoteProjectPreparationDraft = Readonly<{
|
||||
hostId: string
|
||||
remoteRootPath: string
|
||||
runtimeSelection: AgentRuntimeSelection
|
||||
}>
|
||||
|
||||
type PreparedRemoteProject = Readonly<{
|
||||
runtimeSelection: AgentRuntimeSelection
|
||||
target: SshConnectionTarget
|
||||
installation: AgentInstallationIdentity
|
||||
connection: RemoteAgentConnection
|
||||
workspace: RemoteWorkspaceHandle
|
||||
runtimeLease: RemoteProjectRuntimeValidationLease
|
||||
}>
|
||||
|
||||
export type RemoteProjectSaveServiceOptions = {
|
||||
database: Pick<
|
||||
AssistantDatabase,
|
||||
| 'getProject'
|
||||
| 'createValidatedSshProject'
|
||||
| 'updateValidatedSshProject'
|
||||
| 'createSshProject'
|
||||
| 'updateSshProject'
|
||||
>
|
||||
sshHosts: Pick<
|
||||
SshHostStore,
|
||||
'resolveConnectionTarget' | 'assertConnectionTargetCurrent'
|
||||
>
|
||||
installationManager: Pick<AgentInstallationManager, 'ensureInstalled'>
|
||||
installationManager: Pick<AgentInstallationManager, 'activateInstalled'>
|
||||
connectionManager: Pick<RemoteAgentConnectionManager, 'acquire'>
|
||||
resolveRuntimeSelection(
|
||||
selection: AgentRuntimeSelection
|
||||
@@ -151,10 +144,6 @@ export class RemoteProjectSaveService {
|
||||
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) {
|
||||
@@ -186,75 +175,12 @@ export class RemoteProjectSaveService {
|
||||
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)
|
||||
const active = this.#start(owner)
|
||||
|
||||
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)
|
||||
}
|
||||
await this.#finish(owner, active)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,33 +207,60 @@ export class RemoteProjectSaveService {
|
||||
active: ActiveSave,
|
||||
request: RemoteProjectSaveRequest
|
||||
): Promise<AssistantProject> {
|
||||
const { signal } = active.controller
|
||||
const expectedUpdatedAt =
|
||||
request.intent === 'update'
|
||||
? this.#readUpdateRevision(request)
|
||||
: undefined
|
||||
const prepared = await this.#prepare(
|
||||
owner,
|
||||
active,
|
||||
request.draft
|
||||
)
|
||||
|
||||
this.#progress(owner, 'saving')
|
||||
active.controller.signal.throwIfAborted()
|
||||
const write = this.#projectWrite(request, prepared)
|
||||
return request.intent === 'create'
|
||||
? this.#database.createSshProject(write)
|
||||
: this.#database.updateSshProject(
|
||||
request.draft.projectId,
|
||||
expectedUpdatedAt!,
|
||||
write
|
||||
)
|
||||
}
|
||||
|
||||
async #prepare(
|
||||
owner: RemoteProjectSaveOwner,
|
||||
active: ActiveSave,
|
||||
draft: RemoteProjectPreparationDraft
|
||||
): Promise<PreparedRemoteProject> {
|
||||
const { signal } = active.controller
|
||||
const runtimeSelection = await this.#resolveRuntimeSelection(
|
||||
request.draft.runtimeSelection
|
||||
draft.runtimeSelection
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
|
||||
this.#progress(owner, 'host')
|
||||
const target = await this.#sshHosts.resolveConnectionTarget(
|
||||
request.draft.hostId
|
||||
draft.hostId
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
assertTarget(request.draft.hostId, target)
|
||||
assertValidSshConnectionTarget(
|
||||
draft.hostId,
|
||||
target,
|
||||
'SSH Host binding is unavailable'
|
||||
)
|
||||
|
||||
this.#progress(owner, 'agent')
|
||||
const installation = await this.#installationManager.ensureInstalled(
|
||||
request.draft.hostId,
|
||||
{ signal }
|
||||
)
|
||||
const installation =
|
||||
await this.#installationManager.activateInstalled(
|
||||
draft.hostId,
|
||||
{ signal }
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
assertInstallation(installation)
|
||||
active.installation = installation
|
||||
const connection = await this.#connectionManager.acquire(
|
||||
request.draft.hostId,
|
||||
draft.hostId,
|
||||
{
|
||||
...installation,
|
||||
requiredCapabilities: [WORKSPACE_READ_CAPABILITY]
|
||||
@@ -323,9 +276,11 @@ export class RemoteProjectSaveService {
|
||||
await connection.client.request(
|
||||
'workspace/validate',
|
||||
{
|
||||
remoteRootPath: request.draft.remoteRootPath,
|
||||
remoteRootPath: draft.remoteRootPath,
|
||||
requestedAccess: 'read-only',
|
||||
requiredCapabilities: [...REMOTE_WORKSPACE_READ_CAPABILITIES]
|
||||
requiredCapabilities: [
|
||||
...REMOTE_WORKSPACE_READ_CAPABILITIES
|
||||
]
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -334,21 +289,15 @@ export class RemoteProjectSaveService {
|
||||
signal.throwIfAborted()
|
||||
assertWorkspace(validated.handle)
|
||||
if (
|
||||
validated.handle.canonicalDisplayPath !==
|
||||
request.draft.remoteRootPath
|
||||
validated.handle.canonicalDisplayPath !== 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,
|
||||
@@ -362,49 +311,20 @@ export class RemoteProjectSaveService {
|
||||
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,
|
||||
runtimeLease.assertCurrent()
|
||||
return {
|
||||
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
|
||||
)
|
||||
workspace: validated.handle,
|
||||
runtimeLease
|
||||
}
|
||||
}
|
||||
|
||||
#readUpdateRevision(
|
||||
@@ -426,29 +346,18 @@ export class RemoteProjectSaveService {
|
||||
return project.updatedAt
|
||||
}
|
||||
|
||||
#validatedWrite(
|
||||
#projectWrite(
|
||||
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
|
||||
}
|
||||
prepared: PreparedRemoteProject
|
||||
): SshProjectWrite {
|
||||
const {
|
||||
runtimeSelection,
|
||||
target,
|
||||
installation,
|
||||
connection,
|
||||
workspace,
|
||||
runtimeLease
|
||||
} = prepared
|
||||
return {
|
||||
project: {
|
||||
name: request.draft.name,
|
||||
@@ -460,35 +369,57 @@ export class RemoteProjectSaveService {
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId: target.host.id,
|
||||
remoteRootPath: workspace.canonicalDisplayPath,
|
||||
validation: executionValidation
|
||||
remoteRootPath: workspace.canonicalDisplayPath
|
||||
},
|
||||
runtimeValidation: structuredClone(runtime),
|
||||
assertSshHostCurrent: (expected) => {
|
||||
assertPrecondition(
|
||||
expected,
|
||||
target,
|
||||
installation,
|
||||
workspace
|
||||
)
|
||||
assertCurrent: () => {
|
||||
this.#sshHosts.assertConnectionTargetCurrent(
|
||||
currentTargetEvidence(target)
|
||||
toCurrentSshConnectionTarget(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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#start(owner: RemoteProjectSaveOwner): ActiveSave {
|
||||
const controller = new AbortController()
|
||||
let resolveDone!: () => void
|
||||
const active: ActiveSave = {
|
||||
controller,
|
||||
done: new Promise<void>((resolve) => {
|
||||
resolveDone = resolve
|
||||
}),
|
||||
resolveDone,
|
||||
destroyedListener: () => {
|
||||
controller.abort(
|
||||
new DOMException(
|
||||
'Remote project owner was destroyed',
|
||||
'AbortError'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
this.#active.set(owner, active)
|
||||
owner.on('destroyed', active.destroyedListener)
|
||||
return active
|
||||
}
|
||||
|
||||
async #finish(
|
||||
owner: RemoteProjectSaveOwner,
|
||||
active: ActiveSave
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.#cleanup(active)
|
||||
} finally {
|
||||
owner.removeListener('destroyed', active.destroyedListener)
|
||||
if (this.#active.get(owner) === active) {
|
||||
this.#active.delete(owner)
|
||||
}
|
||||
active.resolveDone()
|
||||
}
|
||||
}
|
||||
|
||||
async #cleanup(active: ActiveSave): Promise<void> {
|
||||
try {
|
||||
active.runtimeLease?.release()
|
||||
@@ -545,18 +476,6 @@ export class RemoteProjectSaveService {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -575,7 +494,10 @@ function assertInstallation(
|
||||
|
||||
function assertConnectionIdentity(
|
||||
target: SshConnectionTarget,
|
||||
installation: AgentInstallationIdentity,
|
||||
installation: Pick<
|
||||
AgentInstallationIdentity,
|
||||
'installationId' | 'binaryDigest'
|
||||
>,
|
||||
connection: RemoteAgentConnection
|
||||
): void {
|
||||
if (
|
||||
@@ -622,52 +544,3 @@ function assertWorkspace(handle: RemoteWorkspaceHandle): void {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ import type {
|
||||
SshConnectionLease,
|
||||
SshConnectionPoolTarget
|
||||
} from '../ssh/ssh-connection-pool'
|
||||
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
|
||||
import {
|
||||
RemoteRuntimeBundleResourcesUnavailableError,
|
||||
RemoteRuntimeInstallationError,
|
||||
RemoteRuntimeInstallationManager,
|
||||
type VerifiedRemoteRuntimeInstallationBundle
|
||||
type RemoteRuntimeActivator
|
||||
} from './remote-runtime-installation-manager'
|
||||
import type { VerifiedRemoteRuntimeResourceBundle } from './remote-runtime-resource-loader'
|
||||
|
||||
type RemoteEntry = {
|
||||
type: 'file' | 'directory' | 'symbolic-link'
|
||||
@@ -82,6 +82,10 @@ class MemorySftp implements StagedSftp {
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(): Promise<void> {
|
||||
throw new Error('uploadFile is unused in this fixture')
|
||||
}
|
||||
|
||||
async mkdir(path: string): Promise<void> {
|
||||
this.operations.push(`mkdir:${path}`)
|
||||
const absolute = this.absolute(path)
|
||||
@@ -310,7 +314,7 @@ function target(
|
||||
}
|
||||
|
||||
async function bundleFixture(): Promise<
|
||||
VerifiedRemoteRuntimeInstallationBundle
|
||||
VerifiedRemoteRuntimeResourceBundle
|
||||
> {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'runtime-manager-')
|
||||
@@ -409,79 +413,6 @@ async function bundleFixture(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(options: {
|
||||
sftp?: MemorySftp
|
||||
resolverTargets?: SshConnectionPoolTarget[]
|
||||
bootstrapGate?: Promise<void>
|
||||
maximumConcurrentHosts?: number
|
||||
} = {}) {
|
||||
const bundle = await bundleFixture()
|
||||
const sftp = options.sftp ?? new MemorySftp()
|
||||
const release = vi.fn()
|
||||
const activate = vi.fn(async () => undefined)
|
||||
const lease = {
|
||||
identity: {
|
||||
hostId: 'host-1',
|
||||
hostRevision: 1,
|
||||
hostKeyGeneration: 1,
|
||||
authenticationIdentity: 'b'.repeat(64)
|
||||
},
|
||||
isUsable: () => true,
|
||||
runAgentBootstrapProbe: vi.fn(
|
||||
async (signal?: AbortSignal) => {
|
||||
if (options.bootstrapGate) {
|
||||
await waitForGate(options.bootstrapGate, signal)
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
canonicalHomeDirectory: home,
|
||||
uid,
|
||||
shell: '/bin/bash',
|
||||
procfs: 'ready'
|
||||
} as const
|
||||
}
|
||||
),
|
||||
openStagedSftp: vi.fn(async () => sftp),
|
||||
release
|
||||
} as unknown as SshConnectionLease
|
||||
let resolveCount = 0
|
||||
const targets = options.resolverTargets ?? [target()]
|
||||
const resolver = {
|
||||
resolve: vi.fn(async () =>
|
||||
targets[
|
||||
Math.min(resolveCount++, targets.length - 1)
|
||||
]!
|
||||
)
|
||||
}
|
||||
const sshPool = {
|
||||
acquire: vi.fn(async () => lease)
|
||||
}
|
||||
const manager = new RemoteRuntimeInstallationManager({
|
||||
resolver,
|
||||
sshPool,
|
||||
loadVerifiedBundle: async () => bundle,
|
||||
activate,
|
||||
maximumConcurrentHosts: options.maximumConcurrentHosts
|
||||
})
|
||||
const destination =
|
||||
`.goodbuddy/runtimes/opencode/${
|
||||
bundle.manifest.bundleDigest.slice('sha256:'.length)
|
||||
}`
|
||||
return {
|
||||
manager,
|
||||
bundle,
|
||||
sftp,
|
||||
release,
|
||||
activate,
|
||||
lease,
|
||||
resolver,
|
||||
sshPool,
|
||||
destination
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForGate(
|
||||
gate: Promise<void>,
|
||||
signal?: AbortSignal
|
||||
@@ -512,54 +443,18 @@ async function waitForGate(
|
||||
})
|
||||
}
|
||||
|
||||
function populateBundle(
|
||||
fixture: Awaited<ReturnType<typeof harness>>
|
||||
): void {
|
||||
fixture.sftp.add(fixture.destination, {
|
||||
type: 'directory',
|
||||
mode: 0o700,
|
||||
uid
|
||||
})
|
||||
fixture.sftp.add(`${fixture.destination}/bin`, {
|
||||
type: 'directory',
|
||||
mode: 0o700,
|
||||
uid
|
||||
})
|
||||
fixture.sftp.add(`${fixture.destination}/licenses`, {
|
||||
type: 'directory',
|
||||
mode: 0o700,
|
||||
uid
|
||||
})
|
||||
fixture.sftp.add(`${fixture.destination}/bin/opencode`, {
|
||||
type: 'file',
|
||||
mode: 0o755,
|
||||
uid,
|
||||
contents: Buffer.from('opencode')
|
||||
})
|
||||
fixture.sftp.add(`${fixture.destination}/licenses/LICENSE`, {
|
||||
type: 'file',
|
||||
mode: 0o644,
|
||||
uid,
|
||||
contents: Buffer.from('license')
|
||||
})
|
||||
fixture.sftp.add(`${fixture.destination}/manifest.json`, {
|
||||
type: 'file',
|
||||
mode: 0o644,
|
||||
uid,
|
||||
contents: canonical(fixture.bundle.manifest)
|
||||
})
|
||||
fixture.sftp.add(`${fixture.destination}/manifest.sig`, {
|
||||
type: 'file',
|
||||
mode: 0o644,
|
||||
uid,
|
||||
contents: Buffer.alloc(64, 7)
|
||||
})
|
||||
}
|
||||
|
||||
async function metadataOnlyHarness(options: {
|
||||
corruptPath?: string
|
||||
remoteReleaseKeyBytes?: Buffer
|
||||
remoteRuntimeLockBytes?: Buffer
|
||||
remoteReleaseKeysAbsent?: boolean
|
||||
remoteRuntimeLockAbsent?: boolean
|
||||
resolverTargets?: SshConnectionPoolTarget[]
|
||||
runtimeRegistryBytes?: Buffer
|
||||
runtimeRegistryAbsent?: boolean
|
||||
activationFailure?: boolean
|
||||
activationMutatesRegistry?: boolean
|
||||
bootstrapGate?: Promise<void>
|
||||
} = {}) {
|
||||
const baseBundle = await bundleFixture()
|
||||
const keyPair = generateKeyPairSync('ed25519')
|
||||
@@ -585,7 +480,7 @@ async function metadataOnlyHarness(options: {
|
||||
)
|
||||
const releaseKeyRegistryBytes = canonical(signedRegistry)
|
||||
const remoteRuntimeLockBytes = canonical(runtimeLock)
|
||||
const bundle: VerifiedRemoteRuntimeInstallationBundle = {
|
||||
const bundle: VerifiedRemoteRuntimeResourceBundle = {
|
||||
bundleDirectory: baseBundle.bundleDirectory,
|
||||
manifest: baseBundle.manifest,
|
||||
manifestDigest: baseBundle.manifestDigest,
|
||||
@@ -625,26 +520,33 @@ async function metadataOnlyHarness(options: {
|
||||
bundle.manifest.acpCapabilitiesDigest
|
||||
}]
|
||||
})
|
||||
for (const [path, contents] of [
|
||||
[
|
||||
'.goodbuddy/runtimes/registry.json',
|
||||
runtimeRegistryBytes
|
||||
],
|
||||
[
|
||||
'.goodbuddy/runtimes/release-keys.json',
|
||||
options.remoteReleaseKeyBytes ??
|
||||
releaseKeyRegistryBytes
|
||||
],
|
||||
[
|
||||
'.goodbuddy/runtimes/remote-runtime-lock.json',
|
||||
remoteRuntimeLockBytes
|
||||
]
|
||||
] as const) {
|
||||
sftp.add(path, {
|
||||
if (!options.remoteReleaseKeysAbsent) {
|
||||
sftp.add('.goodbuddy/runtimes/release-keys.json', {
|
||||
type: 'file',
|
||||
mode: 0o600,
|
||||
uid,
|
||||
contents
|
||||
contents:
|
||||
options.remoteReleaseKeyBytes ??
|
||||
releaseKeyRegistryBytes
|
||||
})
|
||||
}
|
||||
if (!options.remoteRuntimeLockAbsent) {
|
||||
sftp.add('.goodbuddy/runtimes/remote-runtime-lock.json', {
|
||||
type: 'file',
|
||||
mode: 0o600,
|
||||
uid,
|
||||
contents:
|
||||
options.remoteRuntimeLockBytes ??
|
||||
remoteRuntimeLockBytes
|
||||
})
|
||||
}
|
||||
if (!options.runtimeRegistryAbsent) {
|
||||
sftp.add('.goodbuddy/runtimes/registry.json', {
|
||||
type: 'file',
|
||||
mode: 0o600,
|
||||
uid,
|
||||
contents:
|
||||
options.runtimeRegistryBytes ?? runtimeRegistryBytes
|
||||
})
|
||||
}
|
||||
for (const file of bundle.manifest.files) {
|
||||
@@ -678,8 +580,17 @@ async function metadataOnlyHarness(options: {
|
||||
})
|
||||
|
||||
const release = vi.fn()
|
||||
const activate = vi.fn(async () => {
|
||||
const activate = vi.fn<RemoteRuntimeActivator>(async () => {
|
||||
if (options.activationMutatesRegistry) {
|
||||
sftp.add('.goodbuddy/runtimes/registry.json', {
|
||||
type: 'file',
|
||||
mode: 0o600,
|
||||
uid,
|
||||
contents: Buffer.from('changed registry bytes\n')
|
||||
})
|
||||
}
|
||||
if (
|
||||
options.activationFailure ||
|
||||
options.corruptPath !== undefined &&
|
||||
options.corruptPath !== 'manifest.sig'
|
||||
) {
|
||||
@@ -694,7 +605,11 @@ async function metadataOnlyHarness(options: {
|
||||
authenticationIdentity: 'b'.repeat(64)
|
||||
},
|
||||
isUsable: () => true,
|
||||
runAgentBootstrapProbe: vi.fn(async () => ({
|
||||
runAgentBootstrapProbe: vi.fn(async (signal?: AbortSignal) => {
|
||||
if (options.bootstrapGate) {
|
||||
await waitForGate(options.bootstrapGate, signal)
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
@@ -702,7 +617,8 @@ async function metadataOnlyHarness(options: {
|
||||
uid,
|
||||
shell: '/bin/bash',
|
||||
procfs: 'ready'
|
||||
}) as const),
|
||||
} as const
|
||||
}),
|
||||
openStagedSftp: vi.fn(async () => sftp),
|
||||
release
|
||||
} as unknown as SshConnectionLease
|
||||
@@ -716,9 +632,6 @@ async function metadataOnlyHarness(options: {
|
||||
const manager = new RemoteRuntimeInstallationManager({
|
||||
resolver,
|
||||
sshPool: { acquire: vi.fn(async () => lease) },
|
||||
loadVerifiedBundle: async () => {
|
||||
throw new RemoteRuntimeBundleResourcesUnavailableError()
|
||||
},
|
||||
loadVerificationMetadata: async () => ({
|
||||
releaseKeyRegistry: signedRegistry,
|
||||
runtimeLock,
|
||||
@@ -732,6 +645,18 @@ async function metadataOnlyHarness(options: {
|
||||
return {
|
||||
manager,
|
||||
bundle,
|
||||
publishedIdentity: {
|
||||
runtimeId: 'opencode' as const,
|
||||
runtimeVersion: bundle.manifest.runtimeVersion,
|
||||
bundleDigest: bundle.manifest.bundleDigest,
|
||||
manifestDigest: bundle.manifestDigest,
|
||||
runtimeAdapterDigest: bundle.manifest.adapterDigest,
|
||||
acpCapabilitiesDigest:
|
||||
bundle.manifest.acpCapabilitiesDigest,
|
||||
platform: 'linux' as const,
|
||||
architecture: bundle.manifest.architecture,
|
||||
protocol: { ...bundle.manifest.protocol }
|
||||
},
|
||||
activate,
|
||||
destination,
|
||||
release,
|
||||
@@ -743,9 +668,13 @@ async function metadataOnlyHarness(options: {
|
||||
describe('RemoteRuntimeInstallationManager', () => {
|
||||
it('cryptographically reuses an exact current Host Runtime when bundle resources are absent', async () => {
|
||||
const fixture = await metadataOnlyHarness()
|
||||
const agentInstallationId =
|
||||
verifyAgentInstallationId('agent-current')
|
||||
|
||||
await expect(
|
||||
fixture.manager.ensureInstalled('host-1')
|
||||
fixture.manager.activateInstalled('host-1', {
|
||||
agentInstallationId
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: fixture.bundle.manifest.runtimeVersion,
|
||||
@@ -753,6 +682,9 @@ describe('RemoteRuntimeInstallationManager', () => {
|
||||
architecture: 'x64'
|
||||
})
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
expect(fixture.activate.mock.calls[0]?.[4]).toBe(
|
||||
agentInstallationId
|
||||
)
|
||||
expect(
|
||||
fixture.sftp.operations.some((operation) =>
|
||||
operation.startsWith('write:')
|
||||
@@ -761,6 +693,265 @@ describe('RemoteRuntimeInstallationManager', () => {
|
||||
expect(fixture.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reuses the current Runtime identity until the Host is invalidated', async () => {
|
||||
const fixture = await metadataOnlyHarness()
|
||||
const agentInstallationId =
|
||||
verifyAgentInstallationId('agent-current')
|
||||
|
||||
const first = await fixture.manager.activateInstalled('host-1', {
|
||||
agentInstallationId
|
||||
})
|
||||
await expect(
|
||||
fixture.manager.activateInstalled('host-1', {
|
||||
agentInstallationId
|
||||
})
|
||||
).resolves.toEqual(first)
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
expect(fixture.release).toHaveBeenCalledOnce()
|
||||
|
||||
fixture.manager.invalidateHost('host-1')
|
||||
await expect(
|
||||
fixture.manager.activateInstalled('host-1', {
|
||||
agentInstallationId
|
||||
})
|
||||
).resolves.toEqual(first)
|
||||
expect(fixture.activate).toHaveBeenCalledTimes(2)
|
||||
expect(fixture.release).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('activates an exact installed Runtime without loading or publishing installable payloads', async () => {
|
||||
const fixture = await metadataOnlyHarness()
|
||||
|
||||
await expect(
|
||||
fixture.manager.activateInstalled('host-1')
|
||||
).resolves.toMatchObject({
|
||||
runtimeId: 'opencode',
|
||||
bundleDigest: fixture.bundle.manifest.bundleDigest,
|
||||
architecture: 'x64'
|
||||
})
|
||||
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
fixture.sftp.operations.filter((operation) =>
|
||||
/^(?:mkdir|write|chmod|rename|replace|unlink|rmdir):/u
|
||||
.test(operation)
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('adopts the exact package-published Runtime without loading or mutating payloads', async () => {
|
||||
const fixture = await metadataOnlyHarness()
|
||||
|
||||
await expect(
|
||||
fixture.manager.activatePublished(
|
||||
'host-1',
|
||||
fixture.publishedIdentity
|
||||
)
|
||||
).resolves.toEqual({
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: fixture.bundle.manifest.runtimeVersion,
|
||||
bundleDigest: fixture.bundle.manifest.bundleDigest,
|
||||
manifestDigest: fixture.bundle.manifestDigest,
|
||||
runtimeAdapterDigest:
|
||||
fixture.bundle.manifest.adapterDigest,
|
||||
acpCapabilitiesDigest:
|
||||
fixture.bundle.manifest.acpCapabilitiesDigest,
|
||||
platform: 'linux',
|
||||
architecture: 'x64'
|
||||
})
|
||||
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
fixture.sftp.operations.filter((operation) =>
|
||||
/^(?:mkdir|write|chmod|rename|replace|unlink|rmdir):/u
|
||||
.test(operation)
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('adopts a package-published Runtime when all global Runtime metadata is absent', async () => {
|
||||
const fixture = await metadataOnlyHarness({
|
||||
remoteReleaseKeysAbsent: true,
|
||||
remoteRuntimeLockAbsent: true,
|
||||
runtimeRegistryAbsent: true
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager.activatePublished(
|
||||
'host-1',
|
||||
fixture.publishedIdentity
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
runtimeId: 'opencode',
|
||||
bundleDigest: fixture.bundle.manifest.bundleDigest
|
||||
})
|
||||
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
fixture.sftp.entries.has(
|
||||
`${home}/.goodbuddy/runtimes/release-keys.json`
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
fixture.sftp.entries.has(
|
||||
`${home}/.goodbuddy/runtimes/remote-runtime-lock.json`
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
fixture.sftp.entries.has(
|
||||
`${home}/.goodbuddy/runtimes/registry.json`
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('verifies a published Runtime with packaged trust instead of stale Host metadata', async () => {
|
||||
const fixture = await metadataOnlyHarness({
|
||||
remoteReleaseKeyBytes: canonical({
|
||||
formatVersion: 1,
|
||||
keys: [],
|
||||
revocations: []
|
||||
}),
|
||||
remoteRuntimeLockBytes: Buffer.from(
|
||||
'{ "legacy": "runtime lock" }\n'
|
||||
)
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager.activatePublished(
|
||||
'host-1',
|
||||
fixture.publishedIdentity
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
runtimeId: 'opencode'
|
||||
})
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a package-published Runtime identity mismatch before activation', async () => {
|
||||
const fixture = await metadataOnlyHarness()
|
||||
|
||||
await expect(
|
||||
fixture.manager.activatePublished('host-1', {
|
||||
...fixture.publishedIdentity,
|
||||
runtimeAdapterDigest: `sha256:${'f'.repeat(64)}`
|
||||
})
|
||||
).rejects.toMatchObject({ reason: 'corrupt' })
|
||||
|
||||
expect(fixture.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not transfer installer-verified Runtime payloads back through SFTP', async () => {
|
||||
const fixture = await metadataOnlyHarness()
|
||||
|
||||
await expect(
|
||||
fixture.manager.activatePublished(
|
||||
'host-1',
|
||||
fixture.publishedIdentity
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
bundleDigest: fixture.bundle.manifest.bundleDigest
|
||||
})
|
||||
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
for (const file of fixture.bundle.manifest.files) {
|
||||
expect(fixture.sftp.operations).not.toContain(
|
||||
`read:${fixture.destination}/${file.path}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects package-published Runtime adoption after Host identity changes', async () => {
|
||||
const fixture = await metadataOnlyHarness({
|
||||
resolverTargets: [target(), target(2)]
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager.activatePublished(
|
||||
'host-1',
|
||||
fixture.publishedIdentity
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
reason: 'host-identity-changed'
|
||||
})
|
||||
|
||||
expect(fixture.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores exact Runtime registry bytes when published activation fails', async () => {
|
||||
const oldRegistry = Buffer.from(
|
||||
'{ "legacy": true, "spacing": "preserved" }\n'
|
||||
)
|
||||
const oldReleaseKeys = Buffer.from(
|
||||
'{ "legacy": "release keys" }\n'
|
||||
)
|
||||
const oldRuntimeLock = Buffer.from(
|
||||
'{ "legacy": "runtime lock" }\n'
|
||||
)
|
||||
const fixture = await metadataOnlyHarness({
|
||||
runtimeRegistryBytes: oldRegistry,
|
||||
remoteReleaseKeyBytes: oldReleaseKeys,
|
||||
remoteRuntimeLockBytes: oldRuntimeLock,
|
||||
activationFailure: true,
|
||||
activationMutatesRegistry: true
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager.activatePublished(
|
||||
'host-1',
|
||||
fixture.publishedIdentity
|
||||
)
|
||||
).rejects.toMatchObject({ reason: 'activation' })
|
||||
|
||||
expect(
|
||||
fixture.sftp.entries.get(
|
||||
`${home}/.goodbuddy/runtimes/registry.json`
|
||||
)?.contents
|
||||
).toEqual(oldRegistry)
|
||||
expect(
|
||||
fixture.sftp.entries.get(
|
||||
`${home}/.goodbuddy/runtimes/release-keys.json`
|
||||
)?.contents
|
||||
).toEqual(oldReleaseKeys)
|
||||
expect(
|
||||
fixture.sftp.entries.get(
|
||||
`${home}/.goodbuddy/runtimes/remote-runtime-lock.json`
|
||||
)?.contents
|
||||
).toEqual(oldRuntimeLock)
|
||||
})
|
||||
|
||||
it('restores absent Runtime metadata when published activation fails', async () => {
|
||||
const fixture = await metadataOnlyHarness({
|
||||
runtimeRegistryAbsent: true,
|
||||
remoteReleaseKeysAbsent: true,
|
||||
remoteRuntimeLockAbsent: true,
|
||||
activationFailure: true,
|
||||
activationMutatesRegistry: true
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager.activatePublished(
|
||||
'host-1',
|
||||
fixture.publishedIdentity
|
||||
)
|
||||
).rejects.toMatchObject({ reason: 'activation' })
|
||||
|
||||
expect(
|
||||
fixture.sftp.entries.has(
|
||||
`${home}/.goodbuddy/runtimes/registry.json`
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
fixture.sftp.entries.has(
|
||||
`${home}/.goodbuddy/runtimes/release-keys.json`
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
fixture.sftp.entries.has(
|
||||
`${home}/.goodbuddy/runtimes/remote-runtime-lock.json`
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects metadata-only Runtime reuse when trust metadata differs', async () => {
|
||||
const fixture = await metadataOnlyHarness({
|
||||
remoteReleaseKeyBytes: canonical({
|
||||
@@ -771,7 +962,7 @@ describe('RemoteRuntimeInstallationManager', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager.ensureInstalled('host-1')
|
||||
fixture.manager.activateInstalled('host-1')
|
||||
).rejects.toMatchObject({ reason: 'corrupt' })
|
||||
expect(fixture.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -782,7 +973,7 @@ describe('RemoteRuntimeInstallationManager', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager.ensureInstalled('host-1')
|
||||
fixture.manager.activateInstalled('host-1')
|
||||
).rejects.toMatchObject({ reason: 'corrupt' })
|
||||
expect(fixture.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -793,7 +984,7 @@ describe('RemoteRuntimeInstallationManager', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager.ensureInstalled('host-1')
|
||||
fixture.manager.activateInstalled('host-1')
|
||||
).rejects.toMatchObject({ reason: 'activation' })
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -804,242 +995,68 @@ describe('RemoteRuntimeInstallationManager', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager.ensureInstalled('host-1')
|
||||
fixture.manager.activateInstalled('host-1')
|
||||
).rejects.toMatchObject({
|
||||
reason: 'host-identity-changed'
|
||||
})
|
||||
expect(fixture.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installs, atomically publishes, then activates OpenCode', async () => {
|
||||
const fixture = await harness()
|
||||
const identity = await fixture.manager.ensureInstalled('host-1')
|
||||
|
||||
expect(identity).toEqual({
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: fixture.bundle.manifest.runtimeVersion,
|
||||
bundleDigest: fixture.bundle.manifest.bundleDigest,
|
||||
manifestDigest: fixture.bundle.manifestDigest,
|
||||
runtimeAdapterDigest:
|
||||
fixture.bundle.manifest.adapterDigest,
|
||||
acpCapabilitiesDigest:
|
||||
fixture.bundle.manifest.acpCapabilitiesDigest,
|
||||
platform: 'linux',
|
||||
architecture: 'x64'
|
||||
})
|
||||
expect(
|
||||
fixture.sftp.entries.has(`${home}/${fixture.destination}`)
|
||||
).toBe(true)
|
||||
expect(
|
||||
fixture.sftp.operations.filter((entry) =>
|
||||
entry.startsWith('replace:.goodbuddy/runtimes/')
|
||||
)
|
||||
).toHaveLength(2)
|
||||
const publishIndex = fixture.sftp.operations.findIndex(
|
||||
(entry) =>
|
||||
entry.startsWith(
|
||||
'rename:.goodbuddy/runtimes/staging/op-'
|
||||
)
|
||||
)
|
||||
expect(publishIndex).toBeGreaterThan(-1)
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
expect(fixture.activate).toHaveBeenCalledWith(
|
||||
fixture.lease,
|
||||
'opencode',
|
||||
fixture.bundle.manifest.bundleDigest,
|
||||
'x64',
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(fixture.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fully verifies and reuses an existing digest destination', async () => {
|
||||
const fixture = await harness()
|
||||
populateBundle(fixture)
|
||||
|
||||
const identity =
|
||||
await fixture.manager.ensureInstalled('host-1')
|
||||
|
||||
expect(identity).toEqual({
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: fixture.bundle.manifest.runtimeVersion,
|
||||
bundleDigest: fixture.bundle.manifest.bundleDigest,
|
||||
manifestDigest: fixture.bundle.manifestDigest,
|
||||
runtimeAdapterDigest:
|
||||
fixture.bundle.manifest.adapterDigest,
|
||||
acpCapabilitiesDigest:
|
||||
fixture.bundle.manifest.acpCapabilitiesDigest,
|
||||
platform: 'linux',
|
||||
architecture: 'x64'
|
||||
})
|
||||
expect(
|
||||
fixture.sftp.operations.some((entry) =>
|
||||
entry.startsWith(
|
||||
'rename:.goodbuddy/runtimes/staging/op-'
|
||||
)
|
||||
)
|
||||
).toBe(false)
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects mutated remote readback, cleans staging, and never activates', async () => {
|
||||
const fixture = await harness()
|
||||
fixture.sftp.mutateRead = (path, contents) =>
|
||||
path.endsWith('/bin/opencode')
|
||||
? Buffer.from('mutated')
|
||||
: contents
|
||||
|
||||
await expect(
|
||||
fixture.manager.ensureInstalled('host-1')
|
||||
).rejects.toMatchObject({
|
||||
reason: 'corrupt'
|
||||
})
|
||||
expect(fixture.activate).not.toHaveBeenCalled()
|
||||
expect(
|
||||
[...fixture.sftp.entries.keys()].some((path) =>
|
||||
path.includes('/runtimes/staging/op-')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an identity race before publication and activation', async () => {
|
||||
const fixture = await harness({
|
||||
resolverTargets: [target(1), target(2)]
|
||||
})
|
||||
|
||||
await expect(
|
||||
fixture.manager.ensureInstalled('host-1')
|
||||
).rejects.toMatchObject({
|
||||
reason: 'host-identity-changed'
|
||||
})
|
||||
expect(fixture.activate).not.toHaveBeenCalled()
|
||||
expect(
|
||||
[...fixture.sftp.entries.keys()].some((path) =>
|
||||
path.includes('/runtimes/staging/op-')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels the shared operation when its final waiter leaves and cleans up', async () => {
|
||||
const sftp = new MemorySftp()
|
||||
let payloadStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
payloadStarted = resolve
|
||||
})
|
||||
const originalWrite = sftp.writeFile.bind(sftp)
|
||||
sftp.writeFile = vi.fn(async (
|
||||
path: string,
|
||||
contents: Buffer,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
await originalWrite(path, contents)
|
||||
if (path.endsWith('/bin/opencode')) {
|
||||
payloadStarted()
|
||||
await waitForGate(
|
||||
new Promise<void>(() => undefined),
|
||||
signal
|
||||
)
|
||||
}
|
||||
})
|
||||
const fixture = await harness({ sftp })
|
||||
const controller = new AbortController()
|
||||
const installation = fixture.manager.ensureInstalled('host-1', {
|
||||
signal: controller.signal
|
||||
})
|
||||
await started
|
||||
controller.abort()
|
||||
|
||||
await expect(installation).rejects.toMatchObject({
|
||||
name: 'AbortError'
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(fixture.release).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(
|
||||
[...sftp.entries.keys()].some((path) =>
|
||||
path.includes('/runtimes/staging/op-')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates one host and enforces concurrent host capacity', async () => {
|
||||
it('deduplicates installed activation and lets one waiter cancel', async () => {
|
||||
let releaseGate!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseGate = resolve
|
||||
})
|
||||
const fixture = await harness({
|
||||
bootstrapGate: gate,
|
||||
maximumConcurrentHosts: 1,
|
||||
resolverTargets: [
|
||||
target(),
|
||||
target(),
|
||||
target(1, 'host-2'),
|
||||
target()
|
||||
]
|
||||
const fixture = await metadataOnlyHarness({ bootstrapGate: gate })
|
||||
const controller = new AbortController()
|
||||
const canceled = fixture.manager.activateInstalled('host-1', {
|
||||
signal: controller.signal
|
||||
})
|
||||
const first = fixture.manager.ensureInstalled('host-1')
|
||||
const second = fixture.manager.ensureInstalled('host-1', {
|
||||
force: true
|
||||
const survivor = fixture.manager.activateInstalled('host-1')
|
||||
await vi.waitFor(() =>
|
||||
expect(fixture.resolver.resolve).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
|
||||
controller.abort()
|
||||
await expect(canceled).rejects.toMatchObject({ name: 'AbortError' })
|
||||
releaseGate()
|
||||
await expect(survivor).resolves.toMatchObject({ runtimeId: 'opencode' })
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
expect(fixture.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels installed activation when its final waiter leaves', async () => {
|
||||
const fixture = await metadataOnlyHarness({
|
||||
bootstrapGate: new Promise<void>(() => undefined)
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const activation = fixture.manager.activateInstalled('host-1', {
|
||||
signal: controller.signal
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(fixture.sshPool.acquire).toHaveBeenCalledOnce()
|
||||
expect(fixture.resolver.resolve).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
await expect(
|
||||
fixture.manager.ensureInstalled('host-2')
|
||||
).rejects.toMatchObject({
|
||||
reason: 'capacity'
|
||||
controller.abort()
|
||||
await expect(activation).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(fixture.release).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('dispose aborts active activation and rejects future calls', async () => {
|
||||
const fixture = await metadataOnlyHarness({
|
||||
bootstrapGate: new Promise<void>(() => undefined)
|
||||
})
|
||||
releaseGate()
|
||||
await expect(Promise.all([first, second])).resolves.toHaveLength(2)
|
||||
expect(fixture.sshPool.acquire).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reuses a successful Runtime installation for the same Host identity', async () => {
|
||||
const fixture = await harness()
|
||||
const first = await fixture.manager.ensureInstalled('host-1')
|
||||
const phases: string[] = []
|
||||
const second = await fixture.manager.ensureInstalled('host-1', {
|
||||
onProgress: (phase) => phases.push(phase)
|
||||
})
|
||||
|
||||
expect(second).toEqual(first)
|
||||
expect(fixture.sshPool.acquire).toHaveBeenCalledOnce()
|
||||
expect(fixture.activate).toHaveBeenCalledOnce()
|
||||
expect(phases).toEqual(['inspecting-host', 'complete'])
|
||||
})
|
||||
|
||||
it('force bypasses the settled Runtime cache and refreshes it after verification', async () => {
|
||||
const fixture = await harness()
|
||||
const first = await fixture.manager.ensureInstalled('host-1')
|
||||
|
||||
const refreshed = await fixture.manager.ensureInstalled(
|
||||
'host-1',
|
||||
{ force: true }
|
||||
)
|
||||
const cached = await fixture.manager.ensureInstalled('host-1')
|
||||
|
||||
expect(refreshed).toEqual(first)
|
||||
expect(cached).toEqual(refreshed)
|
||||
expect(fixture.sshPool.acquire).toHaveBeenCalledTimes(2)
|
||||
expect(fixture.activate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports activation errors distinctly after verified publication', async () => {
|
||||
const fixture = await harness()
|
||||
fixture.activate.mockRejectedValueOnce(
|
||||
new Error('fixed activation command failed')
|
||||
const activation = fixture.manager.activateInstalled('host-1')
|
||||
await vi.waitFor(() =>
|
||||
expect(fixture.resolver.resolve).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
const first = fixture.manager.dispose()
|
||||
expect(fixture.manager.dispose()).toBe(first)
|
||||
await expect(activation).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await first
|
||||
await expect(
|
||||
fixture.manager.ensureInstalled('host-1')
|
||||
).rejects.toBeInstanceOf(RemoteRuntimeInstallationError)
|
||||
await expect(
|
||||
fixture.manager.ensureInstalled('host-1')
|
||||
).resolves.toMatchObject({ runtimeId: 'opencode' })
|
||||
expect(
|
||||
fixture.sftp.entries.has(`${home}/${fixture.destination}`)
|
||||
).toBe(true)
|
||||
expect(fixture.activate).toHaveBeenCalledTimes(2)
|
||||
fixture.manager.activateInstalled('host-1')
|
||||
).rejects.toThrow('Remote Runtime installation manager is disposed')
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,11 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, 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 {
|
||||
AgentArchitecture,
|
||||
AgentReleaseKeyRegistry
|
||||
} from '../../shared/agent-installation-contracts'
|
||||
import {
|
||||
digestRemoteRuntimeBundleIdentity,
|
||||
digestRemoteRuntimeBundleManifest,
|
||||
type RemoteRuntimeBundleManifest,
|
||||
type RemoteRuntimeLock
|
||||
} from '../../shared/remote-runtime-launch-contracts'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentReleaseKeyRegistry } from '../../shared/agent-installation-contracts'
|
||||
import 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'
|
||||
import { loadRemoteRuntimeVerificationMetadata } from './remote-runtime-resource-loader'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const registry: AgentReleaseKeyRegistry = {
|
||||
@@ -45,124 +23,60 @@ const runtimeLock: RemoteRuntimeLock = {
|
||||
entrypointIdentity: 'opencode-acp',
|
||||
argvPrefix: ['acp'],
|
||||
allowedEnvironmentNames: [
|
||||
'HOME',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'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=='
|
||||
integrity: `sha512-${'A'.repeat(86)}==`
|
||||
},
|
||||
arm64: {
|
||||
package: 'opencode-linux-arm64',
|
||||
integrity:
|
||||
'sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A=='
|
||||
integrity: `sha512-${'B'.repeat(86)}==`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canonical(value: unknown): Buffer {
|
||||
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
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 () => {
|
||||
async function createFixture(): Promise<{
|
||||
root: string
|
||||
paths: BundledRemoteRuntimeResourcePaths
|
||||
}> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'runtime-metadata-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, 'unused-x64'),
|
||||
arm64: join(root, 'unused-arm64')
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
writeFile(paths.keyRegistryPath, canonical(registry)),
|
||||
writeFile(paths.runtimeLockPath, canonical(runtimeLock))
|
||||
])
|
||||
return { root, paths }
|
||||
}
|
||||
|
||||
describe('remote Runtime verification metadata loader', () => {
|
||||
it('loads canonical verification metadata without Runtime payloads', 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({
|
||||
@@ -173,13 +87,11 @@ describe('remote Runtime resource loader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts equivalent key-registry formatting and returns canonical bytes', async () => {
|
||||
it('canonicalizes equivalent release-key registry formatting', async () => {
|
||||
const fixture = await createFixture()
|
||||
await writeFile(
|
||||
fixture.paths.keyRegistryPath,
|
||||
Buffer.from(
|
||||
canonical(registry).toString('utf8').replace(/\n/gu, '\r\n')
|
||||
)
|
||||
canonical(registry).toString('utf8').replace(/\n/gu, '\r\n')
|
||||
)
|
||||
|
||||
await expect(
|
||||
@@ -190,207 +102,28 @@ describe('remote Runtime resource loader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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([
|
||||
{ bytes: Buffer.from('not json\n'), error: 'invalid JSON' },
|
||||
{
|
||||
name: 'malformed',
|
||||
bytes: Buffer.from('not json\n'),
|
||||
error: 'invalid JSON'
|
||||
},
|
||||
{
|
||||
name: 'oversize',
|
||||
bytes: Buffer.alloc(1024 * 1024 + 1, 0x20),
|
||||
error: 'exceeds its safety limit'
|
||||
}
|
||||
])('rejects $name metadata', async ({ bytes, error }) => {
|
||||
])('rejects invalid bounded metadata', async ({ bytes, error }) => {
|
||||
const fixture = await createFixture()
|
||||
await writeFile(fixture.paths.keyRegistryPath, bytes)
|
||||
await writeFile(fixture.paths.runtimeLockPath, bytes)
|
||||
|
||||
await expect(loadWithTestVerifier(fixture.paths, vi.fn()))
|
||||
.rejects.toThrow(error)
|
||||
await expect(
|
||||
loadRemoteRuntimeVerificationMetadata(fixture.paths)
|
||||
).rejects.toThrow(error)
|
||||
})
|
||||
|
||||
it('rejects metadata paths that are not regular files', async () => {
|
||||
const fixture = await createFixture()
|
||||
await rm(fixture.paths.keyRegistryPath)
|
||||
await mkdir(fixture.paths.keyRegistryPath)
|
||||
|
||||
await expect(
|
||||
loadRemoteRuntimeVerificationMetadata(fixture.paths)
|
||||
).rejects.toThrow('not a regular file')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -18,13 +18,9 @@ import {
|
||||
digestRemoteRuntimeBundleManifest,
|
||||
remoteRuntimeBundleManifestSchema,
|
||||
remoteRuntimeLockSchema,
|
||||
type RemoteRuntimeBundleManifest,
|
||||
type RemoteRuntimeLock
|
||||
} from '../../shared/remote-runtime-launch-contracts'
|
||||
import {
|
||||
RemoteRuntimeBundleResourcesUnavailableError,
|
||||
type RemoteRuntimeInstallationBundleLoader,
|
||||
type VerifiedRemoteRuntimeInstallationBundle
|
||||
} from './remote-runtime-installation-manager'
|
||||
import {
|
||||
getBundledRemoteRuntimeRoot,
|
||||
type BundledRemoteRuntimeResourcePaths
|
||||
@@ -59,24 +55,19 @@ export type RemoteRuntimeVerificationMetadata = {
|
||||
canonicalRemoteRuntimeLockBytes: Buffer
|
||||
}
|
||||
|
||||
export function createRemoteRuntimeResourceLoader(
|
||||
paths: BundledRemoteRuntimeResourcePaths,
|
||||
options: RemoteRuntimeResourceLoaderOptions = {}
|
||||
): RemoteRuntimeInstallationBundleLoader {
|
||||
assertSafeVerificationInjection(options)
|
||||
return (architecture) =>
|
||||
loadVerifiedRemoteRuntimeResourceBundle(
|
||||
paths,
|
||||
architecture,
|
||||
options
|
||||
)
|
||||
export type VerifiedRemoteRuntimeResourceBundle = {
|
||||
bundleDirectory: string
|
||||
manifest: RemoteRuntimeBundleManifest
|
||||
manifestDigest: string
|
||||
canonicalReleaseKeyRegistryBytes: Buffer
|
||||
canonicalRemoteRuntimeLockBytes: Buffer
|
||||
}
|
||||
|
||||
export async function loadVerifiedRemoteRuntimeResourceBundle(
|
||||
paths: BundledRemoteRuntimeResourcePaths,
|
||||
architecture: AgentArchitecture,
|
||||
options: RemoteRuntimeResourceLoaderOptions = {}
|
||||
): Promise<VerifiedRemoteRuntimeInstallationBundle> {
|
||||
): Promise<VerifiedRemoteRuntimeResourceBundle> {
|
||||
assertSafeVerificationInjection(options)
|
||||
const verificationEnvironment =
|
||||
options.verificationEnvironment ?? 'production'
|
||||
@@ -234,7 +225,10 @@ async function resolveSingleDigestDirectory(
|
||||
rootStat = await lstat(runtimeRoot)
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
throw new RemoteRuntimeBundleResourcesUnavailableError(error)
|
||||
throw new Error(
|
||||
'OpenCode Runtime resources are not included in this package',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -273,7 +267,7 @@ async function validateVerifiedBundle(
|
||||
architecture: AgentArchitecture,
|
||||
keyRegistryBytes: Buffer,
|
||||
runtimeLockBytes: Buffer
|
||||
): Promise<VerifiedRemoteRuntimeInstallationBundle> {
|
||||
): Promise<VerifiedRemoteRuntimeResourceBundle> {
|
||||
const manifest = remoteRuntimeBundleManifestSchema.parse(
|
||||
verified.manifest
|
||||
)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BoundedStagedSftp } from './bounded-sftp'
|
||||
|
||||
@@ -153,11 +157,28 @@ function createSftp(root = '/safe/staging') {
|
||||
open: vi.fn(
|
||||
(
|
||||
path: string,
|
||||
_mode: string,
|
||||
callback: (error?: Error, handle?: Buffer) => void
|
||||
mode: string,
|
||||
attributesOrCallback:
|
||||
| { mode: number }
|
||||
| ((error?: Error, handle?: Buffer) => void),
|
||||
possibleCallback?: (
|
||||
error?: Error,
|
||||
handle?: Buffer
|
||||
) => void
|
||||
) => {
|
||||
const entry = entries.get(path)
|
||||
if (!entry) {
|
||||
const callback =
|
||||
typeof attributesOrCallback === 'function'
|
||||
? attributesOrCallback
|
||||
: possibleCallback!
|
||||
let entry = entries.get(path)
|
||||
if (mode === 'wx') {
|
||||
if (entry) {
|
||||
callback(new Error('destination exists'))
|
||||
return
|
||||
}
|
||||
entry = { type: 'file', contents: Buffer.alloc(0) }
|
||||
entries.set(path, entry)
|
||||
} else if (!entry) {
|
||||
callback(missingError())
|
||||
return
|
||||
}
|
||||
@@ -166,6 +187,41 @@ function createSftp(root = '/safe/staging') {
|
||||
callback(undefined, handle)
|
||||
}
|
||||
),
|
||||
write: vi.fn(
|
||||
(
|
||||
handle: Buffer,
|
||||
buffer: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
position: number,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
bytesWritten: number
|
||||
) => void
|
||||
) => {
|
||||
const entry = handles.get(handle.toString())
|
||||
if (!entry) {
|
||||
callback(missingError(), 0)
|
||||
return
|
||||
}
|
||||
const requiredSize = position + length
|
||||
if (
|
||||
!entry.contents ||
|
||||
entry.contents.byteLength < requiredSize
|
||||
) {
|
||||
const grown = Buffer.alloc(requiredSize)
|
||||
entry.contents?.copy(grown)
|
||||
entry.contents = grown
|
||||
}
|
||||
buffer.copy(
|
||||
entry.contents,
|
||||
position,
|
||||
offset,
|
||||
offset + length
|
||||
)
|
||||
callback(undefined, length)
|
||||
}
|
||||
),
|
||||
fstat: vi.fn(
|
||||
(
|
||||
handle: Buffer,
|
||||
@@ -221,6 +277,188 @@ function createSftp(root = '/safe/staging') {
|
||||
}
|
||||
|
||||
describe('bounded staged SFTP', () => {
|
||||
it('streams local files in bounded chunks and verifies both identities', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-sftp-upload-')
|
||||
)
|
||||
try {
|
||||
const sourcePath = join(directory, 'package.gbagent')
|
||||
const contents = Buffer.alloc(3 * 64 * 1024 + 17, 0x5a)
|
||||
await writeFile(sourcePath, contents)
|
||||
const { sftp, entries } = createSftp()
|
||||
const staged = new BoundedStagedSftp(
|
||||
sftp as never,
|
||||
'/safe/staging',
|
||||
{
|
||||
maximumFileBytes: contents.byteLength,
|
||||
maximumTotalBytes: contents.byteLength
|
||||
}
|
||||
)
|
||||
|
||||
await staged.uploadFile('package.gbagent', sourcePath, {
|
||||
size: contents.byteLength,
|
||||
sha256: createHash('sha256')
|
||||
.update(contents)
|
||||
.digest('hex')
|
||||
})
|
||||
|
||||
expect(
|
||||
entries.get('/safe/staging/package.gbagent')?.contents
|
||||
).toEqual(contents)
|
||||
expect(sftp.write).toHaveBeenCalledTimes(4)
|
||||
expect(
|
||||
Math.max(
|
||||
...sftp.write.mock.calls.map(
|
||||
([, , , length]) => length
|
||||
)
|
||||
)
|
||||
).toBe(64 * 1024)
|
||||
expect(
|
||||
sftp.write.mock.calls.every(
|
||||
([, buffer]) => buffer.byteLength <= 64 * 1024
|
||||
)
|
||||
).toBe(true)
|
||||
expect(sftp.open).toHaveBeenCalledWith(
|
||||
'/safe/staging/package.gbagent',
|
||||
'wx',
|
||||
{ mode: 0o600 },
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(sftp.fstat).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects local upload size and hash mismatches', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-sftp-upload-')
|
||||
)
|
||||
try {
|
||||
const sourcePath = join(directory, 'package.gbagent')
|
||||
const contents = Buffer.alloc(64 * 1024 + 1, 0x41)
|
||||
await writeFile(sourcePath, contents)
|
||||
|
||||
const sizeHarness = createSftp()
|
||||
const sizeSftp = new BoundedStagedSftp(
|
||||
sizeHarness.sftp as never,
|
||||
'/safe/staging',
|
||||
{
|
||||
maximumFileBytes: contents.byteLength,
|
||||
maximumTotalBytes: contents.byteLength
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
sizeSftp.uploadFile('size', sourcePath, {
|
||||
size: contents.byteLength - 1,
|
||||
sha256: createHash('sha256')
|
||||
.update(contents)
|
||||
.digest('hex')
|
||||
})
|
||||
).rejects.toThrow('预期的普通文件')
|
||||
expect(sizeHarness.sftp.open).not.toHaveBeenCalled()
|
||||
|
||||
const hashHarness = createSftp()
|
||||
const hashSftp = new BoundedStagedSftp(
|
||||
hashHarness.sftp as never,
|
||||
'/safe/staging',
|
||||
{
|
||||
maximumFileBytes: contents.byteLength,
|
||||
maximumTotalBytes: contents.byteLength
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
hashSftp.uploadFile('hash', sourcePath, {
|
||||
size: contents.byteLength,
|
||||
sha256: '0'.repeat(64)
|
||||
})
|
||||
).rejects.toThrow('SHA-256 校验失败')
|
||||
expect(hashHarness.sftp.close).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels a streaming upload and rejects unsafe boundaries', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-sftp-upload-')
|
||||
)
|
||||
try {
|
||||
const sourcePath = join(directory, 'package.gbagent')
|
||||
const contents = Buffer.alloc(2 * 64 * 1024, 0x42)
|
||||
await writeFile(sourcePath, contents)
|
||||
const hash = createHash('sha256').update(contents).digest('hex')
|
||||
|
||||
const cancellationHarness = createSftp()
|
||||
cancellationHarness.sftp.write.mockImplementation(
|
||||
() => undefined
|
||||
)
|
||||
const cancellable = new BoundedStagedSftp(
|
||||
cancellationHarness.sftp as never,
|
||||
'/safe/staging',
|
||||
{
|
||||
maximumFileBytes: contents.byteLength,
|
||||
maximumTotalBytes: contents.byteLength
|
||||
}
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const upload = cancellable.uploadFile(
|
||||
'package.gbagent',
|
||||
sourcePath,
|
||||
{ size: contents.byteLength, sha256: hash },
|
||||
controller.signal
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
cancellationHarness.sftp.write
|
||||
).toHaveBeenCalledOnce()
|
||||
)
|
||||
controller.abort()
|
||||
await expect(upload).rejects.toMatchObject({
|
||||
name: 'AbortError'
|
||||
})
|
||||
expect(
|
||||
cancellationHarness.sftp.end
|
||||
).toHaveBeenCalledOnce()
|
||||
|
||||
const boundaryHarness = createSftp()
|
||||
const bounded = new BoundedStagedSftp(
|
||||
boundaryHarness.sftp as never,
|
||||
'/safe/staging',
|
||||
{
|
||||
maximumFileBytes: contents.byteLength - 1,
|
||||
maximumTotalBytes: contents.byteLength
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
bounded.uploadFile('too-large', sourcePath, {
|
||||
size: contents.byteLength,
|
||||
sha256: hash
|
||||
})
|
||||
).rejects.toThrow('大小超过安全限制')
|
||||
boundaryHarness.entries.set('/safe/staging/link', {
|
||||
type: 'symbolic-link'
|
||||
})
|
||||
const ancestors = new BoundedStagedSftp(
|
||||
boundaryHarness.sftp as never,
|
||||
'/safe/staging',
|
||||
{
|
||||
maximumFileBytes: contents.byteLength,
|
||||
maximumTotalBytes: contents.byteLength
|
||||
}
|
||||
)
|
||||
await expect(
|
||||
ancestors.uploadFile('link/package', sourcePath, {
|
||||
size: contents.byteLength,
|
||||
sha256: hash
|
||||
})
|
||||
).rejects.toThrow('符号链接')
|
||||
expect(boundaryHarness.sftp.open).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps writes inside staging and supports bounded metadata and reads', async () => {
|
||||
const { sftp } = createSftp(
|
||||
'/home/builder/.goodbuddy/staging/random'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import { lstat as localLstat, open as localOpen } from 'node:fs/promises'
|
||||
import type { SFTPWrapper, Stats } from 'ssh2'
|
||||
|
||||
const DEFAULT_OPERATION_TIMEOUT_MS = 30_000
|
||||
@@ -5,6 +8,7 @@ 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 MAX_MAXIMUM_FILE_BYTES = 512 * 1024 * 1024
|
||||
const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024
|
||||
const DEFAULT_MAX_OPERATIONS = 2_048
|
||||
const MAX_REMOTE_PATH_BYTES = 4_096
|
||||
@@ -37,6 +41,11 @@ export type SftpEntryMetadata = {
|
||||
|
||||
export type AllowedSftpMode = (typeof ALLOWED_MODES)[number]
|
||||
|
||||
export type SftpUploadIdentity = {
|
||||
size: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export type StagedSftp = {
|
||||
readonly stagingDirectory: string
|
||||
mkdir(relativePath: string, signal?: AbortSignal): Promise<void>
|
||||
@@ -45,6 +54,12 @@ export type StagedSftp = {
|
||||
contents: Buffer,
|
||||
signal?: AbortSignal
|
||||
): Promise<void>
|
||||
uploadFile(
|
||||
relativePath: string,
|
||||
sourcePath: string,
|
||||
identity: SftpUploadIdentity,
|
||||
signal?: AbortSignal
|
||||
): Promise<void>
|
||||
readFile(
|
||||
relativePath: string,
|
||||
signal?: AbortSignal
|
||||
@@ -100,6 +115,7 @@ type SftpLike = Pick<
|
||||
| 'stat'
|
||||
| 'open'
|
||||
| 'read'
|
||||
| 'write'
|
||||
| 'close'
|
||||
| 'fstat'
|
||||
| 'end'
|
||||
@@ -246,7 +262,7 @@ export class BoundedStagedSftp implements StagedSftp {
|
||||
this.maximumFileBytes = validatePositiveLimit(
|
||||
limits.maximumFileBytes,
|
||||
DEFAULT_MAX_FILE_BYTES,
|
||||
DEFAULT_MAX_FILE_BYTES
|
||||
MAX_MAXIMUM_FILE_BYTES
|
||||
)
|
||||
this.maximumTotalBytes = validatePositiveLimit(
|
||||
limits.maximumTotalBytes,
|
||||
@@ -316,6 +332,146 @@ export class BoundedStagedSftp implements StagedSftp {
|
||||
)
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
relativePath: string,
|
||||
sourcePath: string,
|
||||
identity: SftpUploadIdentity,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const validated = validateRelativePath(relativePath)
|
||||
const remotePath = this.resolveValidated(validated)
|
||||
if (
|
||||
!Number.isSafeInteger(identity.size) ||
|
||||
identity.size < 0 ||
|
||||
identity.size > this.maximumFileBytes
|
||||
) {
|
||||
throw new Error('SFTP 文件大小超过安全限制')
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/u.test(identity.sha256)) {
|
||||
throw new Error('SFTP 文件 SHA-256 无效')
|
||||
}
|
||||
return this.enqueue(
|
||||
signal,
|
||||
async () => {
|
||||
if (
|
||||
this.totalBytes + identity.size >
|
||||
this.maximumTotalBytes
|
||||
) {
|
||||
throw new Error('SFTP 传输总量超过安全限制')
|
||||
}
|
||||
await this.assertSafeAncestors(validated)
|
||||
const pathMetadata = await localLstat(sourcePath)
|
||||
if (
|
||||
!pathMetadata.isFile() ||
|
||||
pathMetadata.isSymbolicLink() ||
|
||||
pathMetadata.size !== identity.size
|
||||
) {
|
||||
throw new Error('SFTP 本地上传源不是预期的普通文件')
|
||||
}
|
||||
const source = await localOpen(
|
||||
sourcePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
|
||||
)
|
||||
let remoteHandle: Buffer | undefined
|
||||
const closeSourceOnAbort = (): void => {
|
||||
void source.close().catch(() => undefined)
|
||||
}
|
||||
signal?.addEventListener('abort', closeSourceOnAbort, {
|
||||
once: true
|
||||
})
|
||||
try {
|
||||
const openedMetadata = await source.stat()
|
||||
if (
|
||||
!openedMetadata.isFile() ||
|
||||
openedMetadata.size !== identity.size ||
|
||||
openedMetadata.dev !== pathMetadata.dev ||
|
||||
openedMetadata.ino !== pathMetadata.ino
|
||||
) {
|
||||
throw new Error(
|
||||
'SFTP 本地上传源在打开期间发生变化'
|
||||
)
|
||||
}
|
||||
remoteHandle = await this.call<Buffer>((done) => {
|
||||
this.sftp.open(
|
||||
remotePath,
|
||||
'wx',
|
||||
{ mode: 0o600 },
|
||||
done
|
||||
)
|
||||
})
|
||||
const initialRemoteMetadata = metadataFromStats(
|
||||
await this.call<Stats>((done) => {
|
||||
this.sftp.fstat(remoteHandle!, done)
|
||||
})
|
||||
)
|
||||
this.assertType(initialRemoteMetadata, 'file')
|
||||
if (initialRemoteMetadata.size !== 0) {
|
||||
throw new Error('SFTP 上传目标初始大小无效')
|
||||
}
|
||||
|
||||
const hash = createHash('sha256')
|
||||
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES)
|
||||
let position = 0
|
||||
while (true) {
|
||||
signal?.throwIfAborted()
|
||||
const { bytesRead } = await source.read(
|
||||
buffer,
|
||||
0,
|
||||
buffer.byteLength,
|
||||
position
|
||||
)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
position += bytesRead
|
||||
if (
|
||||
position > identity.size ||
|
||||
this.totalBytes + bytesRead >
|
||||
this.maximumTotalBytes
|
||||
) {
|
||||
throw new Error('SFTP 文件大小超过安全限制')
|
||||
}
|
||||
const chunk = buffer.subarray(0, bytesRead)
|
||||
hash.update(chunk)
|
||||
await this.writeHandle(
|
||||
remoteHandle,
|
||||
chunk,
|
||||
position - bytesRead
|
||||
)
|
||||
this.totalBytes += bytesRead
|
||||
}
|
||||
if (position !== identity.size) {
|
||||
throw new Error('SFTP 本地上传源大小与预期不一致')
|
||||
}
|
||||
if (hash.digest('hex') !== identity.sha256) {
|
||||
throw new Error('SFTP 本地上传源 SHA-256 校验失败')
|
||||
}
|
||||
const remoteMetadata = metadataFromStats(
|
||||
await this.call<Stats>((done) => {
|
||||
this.sftp.fstat(remoteHandle!, done)
|
||||
})
|
||||
)
|
||||
this.assertType(remoteMetadata, 'file')
|
||||
if (remoteMetadata.size !== identity.size) {
|
||||
throw new Error('SFTP 上传目标大小与预期不一致')
|
||||
}
|
||||
} finally {
|
||||
signal?.removeEventListener(
|
||||
'abort',
|
||||
closeSourceOnAbort
|
||||
)
|
||||
await Promise.allSettled([
|
||||
source.close(),
|
||||
remoteHandle
|
||||
? this.closeHandle(remoteHandle)
|
||||
: Promise.resolve()
|
||||
])
|
||||
}
|
||||
},
|
||||
this.transferTimeoutMs(identity.size)
|
||||
)
|
||||
}
|
||||
|
||||
async readFile(
|
||||
relativePath: string,
|
||||
signal?: AbortSignal
|
||||
@@ -713,6 +869,23 @@ export class BoundedStagedSftp implements StagedSftp {
|
||||
: contents.subarray(0, actualSize)
|
||||
}
|
||||
|
||||
private async writeHandle(
|
||||
handle: Buffer,
|
||||
contents: Buffer,
|
||||
position: number
|
||||
): Promise<void> {
|
||||
await this.call<void>((done) => {
|
||||
this.sftp.write(
|
||||
handle,
|
||||
contents,
|
||||
0,
|
||||
contents.byteLength,
|
||||
position,
|
||||
done
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async closeHandle(handle: Buffer): Promise<void> {
|
||||
try {
|
||||
await this.call<void>((done) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+610
-459
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
CurrentSshConnectionTarget,
|
||||
SshConnectionTarget
|
||||
} from './ssh-host-store'
|
||||
|
||||
export function toCurrentSshConnectionTarget(
|
||||
target: SshConnectionTarget
|
||||
): CurrentSshConnectionTarget {
|
||||
return {
|
||||
hostId: target.host.id,
|
||||
hostRevision: target.hostRevision,
|
||||
hostKeyGeneration: target.hostKeyGeneration,
|
||||
username: target.host.username
|
||||
}
|
||||
}
|
||||
|
||||
export function assertValidSshConnectionTarget(
|
||||
requestedHostId: string,
|
||||
target: SshConnectionTarget,
|
||||
message = 'SSH 主机连接目标无效'
|
||||
): void {
|
||||
if (
|
||||
target.host.id !== requestedHostId ||
|
||||
target.hostRevision < 1 ||
|
||||
target.hostKeyGeneration < 1 ||
|
||||
!target.host.hostKey ||
|
||||
target.host.hostKey.generation !== target.hostKeyGeneration
|
||||
) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSshLeaseMatchesTarget(
|
||||
lease: {
|
||||
identity: {
|
||||
hostId: string
|
||||
hostRevision: number
|
||||
hostKeyGeneration: number
|
||||
}
|
||||
},
|
||||
target: SshConnectionTarget,
|
||||
message: string
|
||||
): void {
|
||||
if (
|
||||
lease.identity.hostId !== target.host.id ||
|
||||
lease.identity.hostRevision !== target.hostRevision ||
|
||||
lease.identity.hostKeyGeneration !== target.hostKeyGeneration
|
||||
) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,10 @@ function createLease(
|
||||
openAgentAttach: vi.fn(),
|
||||
runAgentDoctor: vi.fn(),
|
||||
runAgentBootstrapProbe: vi.fn(),
|
||||
probeRemotePackageBootstrap: vi.fn(),
|
||||
prepareRemotePackageBootstrap: vi.fn(),
|
||||
commitRemotePackageBootstrap: vi.fn(),
|
||||
cleanupRemotePackageBootstrap: vi.fn(),
|
||||
runAgentLifecycleAction: vi.fn(),
|
||||
runAgentRuntimeAction: vi.fn(),
|
||||
openStagedSftp: vi.fn(),
|
||||
|
||||
@@ -2,14 +2,17 @@ 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'
|
||||
import {
|
||||
assertSshLeaseMatchesTarget,
|
||||
assertValidSshConnectionTarget,
|
||||
toCurrentSshConnectionTarget
|
||||
} from './ssh-connection-target'
|
||||
|
||||
export type SshHostDirectoryBrowserOptions = {
|
||||
sshHosts: Pick<
|
||||
@@ -19,47 +22,6 @@ export type SshHostDirectoryBrowserOptions = {
|
||||
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']
|
||||
@@ -78,18 +40,26 @@ export class SshHostDirectoryBrowser {
|
||||
const target =
|
||||
await this.#sshHosts.resolveConnectionTarget(hostId)
|
||||
signal?.throwIfAborted()
|
||||
assertTarget(hostId, target)
|
||||
const expected = currentTarget(target)
|
||||
assertValidSshConnectionTarget(hostId, target)
|
||||
const expected = toCurrentSshConnectionTarget(target)
|
||||
this.#sshHosts.assertConnectionTargetCurrent(expected)
|
||||
|
||||
let lease: SshConnectionLease | undefined
|
||||
try {
|
||||
lease = await this.#sshPool.acquire(target, signal)
|
||||
signal?.throwIfAborted()
|
||||
assertLeaseIdentity(target, lease)
|
||||
assertSshLeaseMatchesTarget(
|
||||
lease,
|
||||
target,
|
||||
'SSH 目录浏览连接身份不匹配'
|
||||
)
|
||||
const listing = await lease.listDirectories(path, signal)
|
||||
signal?.throwIfAborted()
|
||||
assertLeaseIdentity(target, lease)
|
||||
assertSshLeaseMatchesTarget(
|
||||
lease,
|
||||
target,
|
||||
'SSH 目录浏览连接身份不匹配'
|
||||
)
|
||||
this.#sshHosts.assertConnectionTargetCurrent(expected)
|
||||
return listing
|
||||
} finally {
|
||||
|
||||
@@ -2,8 +2,12 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { StagedSftp } from './bounded-sftp'
|
||||
import type {
|
||||
SshConnectionLease,
|
||||
SshConnectionPoolTarget
|
||||
SshConnectionPoolTarget,
|
||||
SshRemotePackageBootstrapLease
|
||||
} from './ssh-connection-pool'
|
||||
import type {
|
||||
SshRemotePackageBootstrapProbeResult
|
||||
} from './ssh-remote-package-bootstrap'
|
||||
import {
|
||||
SshHostRemoteEnvironmentInspector
|
||||
} from './ssh-host-remote-environment'
|
||||
@@ -57,11 +61,37 @@ function missing(): Error & { code: number } {
|
||||
return Object.assign(new Error('missing'), { code: 2 })
|
||||
}
|
||||
|
||||
function harness(registries: {
|
||||
agent?: unknown
|
||||
runtime?: unknown
|
||||
} = {}) {
|
||||
type HarnessOptions = {
|
||||
registries?: {
|
||||
agent?: unknown
|
||||
runtime?: unknown
|
||||
}
|
||||
candidate?: {
|
||||
source: 'github' | 'mirror'
|
||||
size: number
|
||||
sha256: string
|
||||
urls: readonly string[]
|
||||
} | null
|
||||
candidateError?: Error
|
||||
candidateFailure?: {
|
||||
reason: 'package-unavailable' | 'probe-failed'
|
||||
source: 'github' | 'mirror' | null
|
||||
packageSize: number | null
|
||||
}
|
||||
bootstrapResult?: SshRemotePackageBootstrapProbeResult
|
||||
bootstrapError?: Error
|
||||
bootstrapIdentity?: {
|
||||
hostId: string
|
||||
hostRevision: number
|
||||
hostKeyGeneration: number
|
||||
authenticationIdentity: string
|
||||
}
|
||||
}
|
||||
|
||||
function harness(options: HarnessOptions = {}) {
|
||||
const registries = options.registries ?? {}
|
||||
const release = vi.fn()
|
||||
const bootstrapRelease = vi.fn()
|
||||
const close = vi.fn()
|
||||
const readFile = vi.fn(async (path: string) => {
|
||||
const value =
|
||||
@@ -96,25 +126,66 @@ function harness(registries: {
|
||||
openStagedSftp: vi.fn(async () => sftp),
|
||||
release
|
||||
} as unknown as SshConnectionLease
|
||||
const probe = vi.fn(async () => {
|
||||
if (options.bootstrapError) {
|
||||
throw options.bootstrapError
|
||||
}
|
||||
return options.bootstrapResult ?? { available: true as const }
|
||||
})
|
||||
const prepare = vi.fn()
|
||||
const commit = vi.fn()
|
||||
const bootstrapLease = {
|
||||
identity: options.bootstrapIdentity ?? lease.identity,
|
||||
isUsable: vi.fn(() => true),
|
||||
probe,
|
||||
prepare,
|
||||
commit,
|
||||
cleanup: vi.fn(),
|
||||
release: bootstrapRelease
|
||||
} as unknown as SshRemotePackageBootstrapLease
|
||||
const assertConnectionTargetCurrent = vi.fn()
|
||||
const acquireRemotePackageBootstrap = vi.fn(
|
||||
async () => bootstrapLease
|
||||
)
|
||||
const loadRemoteEnvironmentCatalog = vi.fn(async () => {
|
||||
const candidate =
|
||||
options.candidateError
|
||||
? null
|
||||
: options.candidate === undefined
|
||||
? {
|
||||
source: 'mirror' as const,
|
||||
size: 12_345,
|
||||
sha256: 'a'.repeat(64),
|
||||
urls: [
|
||||
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent.zip'
|
||||
]
|
||||
}
|
||||
: options.candidate
|
||||
return {
|
||||
expected: {
|
||||
agent: { version: '0.11.1' },
|
||||
runtimes: [{
|
||||
runtimeId: 'opencode' as const,
|
||||
provider: 'opencode' as const,
|
||||
version: '1.18.9'
|
||||
}]
|
||||
},
|
||||
candidate,
|
||||
candidateFailure: options.candidateFailure
|
||||
}
|
||||
})
|
||||
const inspector = new SshHostRemoteEnvironmentInspector({
|
||||
sshHosts: {
|
||||
resolveConnectionTarget: vi.fn(async () => currentTarget),
|
||||
assertConnectionTargetCurrent
|
||||
},
|
||||
sshPool: {
|
||||
acquire: vi.fn(async () => lease)
|
||||
acquire: vi.fn(async () => lease),
|
||||
acquireRemotePackageBootstrap
|
||||
},
|
||||
agentRuntimeLockPath: 'unused',
|
||||
remoteRuntimeLockPath: 'unused',
|
||||
loadExpectedCatalog: async () => ({
|
||||
agent: { version: '0.11.1' },
|
||||
runtimes: [{
|
||||
runtimeId: 'opencode',
|
||||
provider: 'opencode',
|
||||
version: '1.18.9'
|
||||
}]
|
||||
}),
|
||||
loadRemoteEnvironmentCatalog,
|
||||
now: () => new Date('2030-01-01T00:00:00.000Z')
|
||||
})
|
||||
return {
|
||||
@@ -122,13 +193,19 @@ function harness(registries: {
|
||||
lease,
|
||||
close,
|
||||
release,
|
||||
bootstrapRelease,
|
||||
probe,
|
||||
prepare,
|
||||
commit,
|
||||
acquireRemotePackageBootstrap,
|
||||
loadRemoteEnvironmentCatalog,
|
||||
assertConnectionTargetCurrent
|
||||
}
|
||||
}
|
||||
|
||||
describe('SshHostRemoteEnvironmentInspector', () => {
|
||||
it('reports installed Agent and Runtime versions against the desktop catalog', async () => {
|
||||
const value = harness({
|
||||
const value = harness({ registries: {
|
||||
agent: {
|
||||
formatVersion: 1,
|
||||
minimumTrustedReleaseSequence: 1,
|
||||
@@ -153,7 +230,7 @@ describe('SshHostRemoteEnvironmentInspector', () => {
|
||||
signingKeyId: 'test-key'
|
||||
}]
|
||||
}
|
||||
})
|
||||
} })
|
||||
|
||||
await expect(value.inspector.inspect(hostId)).resolves.toEqual({
|
||||
hostId,
|
||||
@@ -182,20 +259,26 @@ describe('SshHostRemoteEnvironmentInspector', () => {
|
||||
version: '1.18.9',
|
||||
architecture: 'x64'
|
||||
}
|
||||
}]
|
||||
}],
|
||||
remoteDownload: {
|
||||
available: true,
|
||||
source: 'mirror',
|
||||
packageSize: 12_345
|
||||
}
|
||||
})
|
||||
expect(value.close).toHaveBeenCalledOnce()
|
||||
expect(value.release).toHaveBeenCalledOnce()
|
||||
expect(value.assertConnectionTargetCurrent).toHaveBeenCalledTimes(2)
|
||||
expect(value.bootstrapRelease).toHaveBeenCalledOnce()
|
||||
expect(value.assertConnectionTargetCurrent).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('reports an older Agent and a missing Runtime without installing either', async () => {
|
||||
const value = harness({
|
||||
const value = harness({ registries: {
|
||||
agent: {
|
||||
formatVersion: 1,
|
||||
current: agentEntry('0.11.0', 'b')
|
||||
}
|
||||
})
|
||||
} })
|
||||
|
||||
await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({
|
||||
agent: {
|
||||
@@ -210,15 +293,17 @@ describe('SshHostRemoteEnvironmentInspector', () => {
|
||||
})
|
||||
expect(value.lease.runAgentBootstrapProbe).toHaveBeenCalledOnce()
|
||||
expect(value.lease.openStagedSftp).toHaveBeenCalledOnce()
|
||||
expect(value.prepare).not.toHaveBeenCalled()
|
||||
expect(value.commit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects corrupt remote metadata and still closes every lease', async () => {
|
||||
const value = harness({
|
||||
const value = harness({ registries: {
|
||||
agent: {
|
||||
formatVersion: 1,
|
||||
current: { command: 'untrusted' }
|
||||
}
|
||||
})
|
||||
} })
|
||||
|
||||
await expect(value.inspector.inspect(hostId)).rejects.toThrow(
|
||||
'元数据已损坏'
|
||||
@@ -226,4 +311,170 @@ describe('SshHostRemoteEnvironmentInspector', () => {
|
||||
expect(value.close).toHaveBeenCalledOnce()
|
||||
expect(value.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing-curl', 'missing-tools'],
|
||||
['missing-sha256sum', 'missing-tools'],
|
||||
['missing-unzip', 'missing-tools'],
|
||||
['bootstrap-tools-unavailable', 'missing-tools'],
|
||||
['managed-path-unavailable', 'home-unwritable'],
|
||||
['insufficient-disk-space', 'insufficient-disk-space'],
|
||||
['download-unavailable', 'source-unreachable']
|
||||
] as const)(
|
||||
'maps direct probe reason %s to %s',
|
||||
async (probeReason, reason) => {
|
||||
const value = harness({
|
||||
bootstrapResult: {
|
||||
available: false,
|
||||
reason: probeReason
|
||||
}
|
||||
})
|
||||
|
||||
await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({
|
||||
agent: { state: 'not-installed' },
|
||||
runtimes: [{ state: 'not-installed' }],
|
||||
remoteDownload: {
|
||||
available: false,
|
||||
source: 'mirror',
|
||||
packageSize: 12_345,
|
||||
reason
|
||||
}
|
||||
})
|
||||
expect(value.bootstrapRelease).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('sanitizes catalog failures without hiding version status', async () => {
|
||||
const value = harness({
|
||||
candidateError: new Error(
|
||||
'private catalog URL and credential details'
|
||||
)
|
||||
})
|
||||
|
||||
await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({
|
||||
agent: { state: 'not-installed' },
|
||||
runtimes: [{ state: 'not-installed' }],
|
||||
remoteDownload: {
|
||||
available: false,
|
||||
source: null,
|
||||
packageSize: null,
|
||||
reason: 'package-unavailable'
|
||||
}
|
||||
})
|
||||
expect(value.acquireRemotePackageBootstrap).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('probes and reports an available signed format-v1 candidate', async () => {
|
||||
const value = harness({
|
||||
candidate: {
|
||||
source: 'github',
|
||||
size: 99,
|
||||
sha256: 'b'.repeat(64),
|
||||
urls: ['https://github.com/example/package.zip']
|
||||
}
|
||||
})
|
||||
|
||||
await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({
|
||||
remoteDownload: {
|
||||
available: true,
|
||||
source: 'github',
|
||||
packageSize: 99
|
||||
}
|
||||
})
|
||||
expect(value.acquireRemotePackageBootstrap).toHaveBeenCalledOnce()
|
||||
expect(value.probe).toHaveBeenCalledWith(
|
||||
{
|
||||
operationId: expect.any(String),
|
||||
urls: ['https://github.com/example/package.zip'],
|
||||
size: 99,
|
||||
sha256: 'b'.repeat(64)
|
||||
},
|
||||
{ signal: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a missing compatible package without source metadata', async () => {
|
||||
const value = harness({ candidate: null })
|
||||
|
||||
await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({
|
||||
remoteDownload: {
|
||||
available: false,
|
||||
source: null,
|
||||
packageSize: null,
|
||||
reason: 'package-unavailable'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a catalog candidate probe failure and its source metadata', async () => {
|
||||
const value = harness({
|
||||
candidate: null,
|
||||
candidateFailure: {
|
||||
reason: 'probe-failed',
|
||||
source: 'mirror',
|
||||
packageSize: 12_345
|
||||
}
|
||||
})
|
||||
|
||||
await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({
|
||||
remoteDownload: {
|
||||
available: false,
|
||||
source: 'mirror',
|
||||
packageSize: 12_345,
|
||||
reason: 'probe-failed'
|
||||
}
|
||||
})
|
||||
expect(value.acquireRemotePackageBootstrap).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports direct bootstrap probe failures without hiding version status', async () => {
|
||||
const value = harness({
|
||||
bootstrapError: new Error('sensitive SSH diagnostic')
|
||||
})
|
||||
|
||||
await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({
|
||||
agent: { state: 'not-installed' },
|
||||
remoteDownload: {
|
||||
available: false,
|
||||
source: 'mirror',
|
||||
packageSize: 12_345,
|
||||
reason: 'probe-failed'
|
||||
}
|
||||
})
|
||||
expect(value.bootstrapRelease).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a changed Host identity and releases both leases', async () => {
|
||||
const value = harness({
|
||||
bootstrapIdentity: {
|
||||
hostId,
|
||||
hostRevision: 4,
|
||||
hostKeyGeneration: 2,
|
||||
authenticationIdentity: 'b'.repeat(64)
|
||||
}
|
||||
})
|
||||
|
||||
await expect(value.inspector.inspect(hostId)).rejects.toThrow(
|
||||
'连接身份不匹配'
|
||||
)
|
||||
expect(value.bootstrapRelease).toHaveBeenCalledOnce()
|
||||
expect(value.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('honors cancellation while loading the remote environment catalog', async () => {
|
||||
const controller = new AbortController()
|
||||
const value = harness()
|
||||
value.loadRemoteEnvironmentCatalog.mockImplementationOnce(
|
||||
async () => {
|
||||
controller.abort()
|
||||
throw controller.signal.reason
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
value.inspector.inspect(hostId, controller.signal)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(value.acquireRemotePackageBootstrap).not.toHaveBeenCalled()
|
||||
expect(value.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
agentRuntimeLockSchema,
|
||||
@@ -23,10 +24,19 @@ import type {
|
||||
SshConnectionTarget,
|
||||
SshHostStore
|
||||
} from './ssh-host-store'
|
||||
import {
|
||||
assertSshLeaseMatchesTarget,
|
||||
assertValidSshConnectionTarget,
|
||||
toCurrentSshConnectionTarget
|
||||
} from './ssh-connection-target'
|
||||
import type {
|
||||
SshConnectionLease,
|
||||
SshRemotePackageBootstrapLease,
|
||||
SshConnectionPool
|
||||
} from './ssh-connection-pool'
|
||||
import type {
|
||||
SshRemotePackageBootstrapUnavailableReason
|
||||
} from './ssh-remote-package-bootstrap'
|
||||
|
||||
const AGENT_REGISTRY_PATH = '.goodbuddy/agent/registry.json'
|
||||
const RUNTIME_REGISTRY_PATH = '.goodbuddy/runtimes/registry.json'
|
||||
@@ -43,38 +53,63 @@ type ExpectedCatalog = {
|
||||
}>
|
||||
}
|
||||
|
||||
type RemoteInstallCandidate = {
|
||||
source: 'github' | 'mirror'
|
||||
size: number
|
||||
sha256: string
|
||||
urls: readonly string[]
|
||||
}
|
||||
|
||||
type RemoteEnvironmentCatalog = {
|
||||
expected: ExpectedCatalog
|
||||
candidate: RemoteInstallCandidate | null
|
||||
candidateFailure?: {
|
||||
reason: 'package-unavailable' | 'probe-failed'
|
||||
source: 'github' | 'mirror' | null
|
||||
packageSize: number | null
|
||||
}
|
||||
}
|
||||
|
||||
export type SshHostRemoteEnvironmentInspectorOptions = {
|
||||
sshHosts: Pick<
|
||||
SshHostStore,
|
||||
'resolveConnectionTarget' | 'assertConnectionTargetCurrent'
|
||||
>
|
||||
sshPool: Pick<SshConnectionPool, 'acquire'>
|
||||
sshPool: Pick<
|
||||
SshConnectionPool,
|
||||
'acquire' | 'acquireRemotePackageBootstrap'
|
||||
>
|
||||
agentRuntimeLockPath: string
|
||||
remoteRuntimeLockPath: string
|
||||
loadExpectedCatalog?: (
|
||||
architecture: 'x64' | 'arm64'
|
||||
) => Promise<ExpectedCatalog>
|
||||
loadRemoteEnvironmentCatalog?: (
|
||||
architecture: 'x64' | 'arm64',
|
||||
options: { signal?: AbortSignal }
|
||||
) => Promise<RemoteEnvironmentCatalog>
|
||||
now?: () => Date
|
||||
}
|
||||
|
||||
export class SshHostRemoteEnvironmentInspector {
|
||||
readonly #sshHosts: SshHostRemoteEnvironmentInspectorOptions['sshHosts']
|
||||
readonly #sshPool: SshHostRemoteEnvironmentInspectorOptions['sshPool']
|
||||
readonly #loadExpectedCatalog: (
|
||||
architecture: 'x64' | 'arm64'
|
||||
) => Promise<ExpectedCatalog>
|
||||
readonly #loadRemoteEnvironmentCatalog: NonNullable<
|
||||
SshHostRemoteEnvironmentInspectorOptions[
|
||||
'loadRemoteEnvironmentCatalog'
|
||||
]
|
||||
>
|
||||
readonly #now: () => Date
|
||||
|
||||
constructor(options: SshHostRemoteEnvironmentInspectorOptions) {
|
||||
this.#sshHosts = options.sshHosts
|
||||
this.#sshPool = options.sshPool
|
||||
this.#loadExpectedCatalog =
|
||||
options.loadExpectedCatalog ??
|
||||
(() =>
|
||||
loadExpectedCatalog(
|
||||
this.#loadRemoteEnvironmentCatalog =
|
||||
options.loadRemoteEnvironmentCatalog ??
|
||||
(async () => ({
|
||||
expected: await loadExpectedCatalog(
|
||||
options.agentRuntimeLockPath,
|
||||
options.remoteRuntimeLockPath
|
||||
))
|
||||
),
|
||||
candidate: null
|
||||
}))
|
||||
this.#now = options.now ?? (() => new Date())
|
||||
}
|
||||
|
||||
@@ -86,15 +121,19 @@ export class SshHostRemoteEnvironmentInspector {
|
||||
const target =
|
||||
await this.#sshHosts.resolveConnectionTarget(hostId)
|
||||
signal?.throwIfAborted()
|
||||
assertTarget(hostId, target)
|
||||
const expectedTarget = currentTarget(target)
|
||||
assertValidSshConnectionTarget(hostId, target)
|
||||
const expectedTarget = toCurrentSshConnectionTarget(target)
|
||||
this.#sshHosts.assertConnectionTargetCurrent(expectedTarget)
|
||||
|
||||
let lease: SshConnectionLease | undefined
|
||||
let sftp: StagedSftp | undefined
|
||||
try {
|
||||
lease = await this.#sshPool.acquire(target, signal)
|
||||
assertLeaseIdentity(target, lease)
|
||||
assertSshLeaseMatchesTarget(
|
||||
lease,
|
||||
target,
|
||||
'SSH 远端运行环境连接身份不匹配'
|
||||
)
|
||||
const probe = await lease.runAgentBootstrapProbe(signal)
|
||||
signal?.throwIfAborted()
|
||||
if (!probe.ready) {
|
||||
@@ -114,7 +153,10 @@ export class SshHostRemoteEnvironmentInspector {
|
||||
)
|
||||
const [catalog, agentRegistry, runtimeRegistry] =
|
||||
await Promise.all([
|
||||
this.#expected(probe.architecture),
|
||||
this.#loadRemoteEnvironmentCatalog(
|
||||
probe.architecture,
|
||||
{ signal }
|
||||
),
|
||||
readOptionalRegistry(
|
||||
sftp,
|
||||
AGENT_REGISTRY_PATH,
|
||||
@@ -129,18 +171,36 @@ export class SshHostRemoteEnvironmentInspector {
|
||||
)
|
||||
])
|
||||
signal?.throwIfAborted()
|
||||
assertLeaseIdentity(target, lease)
|
||||
assertSshLeaseMatchesTarget(
|
||||
lease,
|
||||
target,
|
||||
'SSH 远端运行环境连接身份不匹配'
|
||||
)
|
||||
this.#sshHosts.assertConnectionTargetCurrent(expectedTarget)
|
||||
const remoteDownload = await this.#remoteDownload(
|
||||
target,
|
||||
expectedTarget,
|
||||
catalog.candidate,
|
||||
catalog.candidateFailure,
|
||||
signal
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
assertSshLeaseMatchesTarget(
|
||||
lease,
|
||||
target,
|
||||
'SSH 远端运行环境连接身份不匹配'
|
||||
)
|
||||
this.#sshHosts.assertConnectionTargetCurrent(expectedTarget)
|
||||
return sshHostRemoteEnvironmentSchema.parse({
|
||||
hostId,
|
||||
checkedAt: this.#now().toISOString(),
|
||||
architecture: probe.architecture,
|
||||
agent: agentStatus(
|
||||
catalog.agent,
|
||||
catalog.expected.agent,
|
||||
agentRegistry?.current,
|
||||
probe.architecture
|
||||
),
|
||||
runtimes: catalog.runtimes.map((runtime) =>
|
||||
runtimes: catalog.expected.runtimes.map((runtime) =>
|
||||
runtimeStatus(
|
||||
runtime,
|
||||
runtimeRegistry?.current.find(
|
||||
@@ -150,7 +210,8 @@ export class SshHostRemoteEnvironmentInspector {
|
||||
),
|
||||
probe.architecture
|
||||
)
|
||||
)
|
||||
),
|
||||
remoteDownload
|
||||
})
|
||||
} finally {
|
||||
sftp?.close()
|
||||
@@ -158,49 +219,134 @@ export class SshHostRemoteEnvironmentInspector {
|
||||
}
|
||||
}
|
||||
|
||||
#expected(
|
||||
architecture: 'x64' | 'arm64'
|
||||
): Promise<ExpectedCatalog> {
|
||||
return this.#loadExpectedCatalog(architecture)
|
||||
async #remoteDownload(
|
||||
target: SshConnectionTarget,
|
||||
expectedTarget: CurrentSshConnectionTarget,
|
||||
candidate: RemoteInstallCandidate | null,
|
||||
candidateFailure:
|
||||
| RemoteEnvironmentCatalog['candidateFailure']
|
||||
| undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<SshHostRemoteEnvironment['remoteDownload']> {
|
||||
if (!candidate) {
|
||||
return unavailableRemoteDownload(
|
||||
candidateFailure?.reason ?? 'package-unavailable',
|
||||
candidateFailure
|
||||
? {
|
||||
source: candidateFailure.source,
|
||||
packageSize: candidateFailure.packageSize
|
||||
}
|
||||
: null
|
||||
)
|
||||
}
|
||||
const exposure = {
|
||||
source: candidate.source,
|
||||
packageSize: candidate.size
|
||||
}
|
||||
let bootstrapLease:
|
||||
| SshRemotePackageBootstrapLease
|
||||
| undefined
|
||||
try {
|
||||
this.#sshHosts.assertConnectionTargetCurrent(expectedTarget)
|
||||
bootstrapLease =
|
||||
await this.#sshPool.acquireRemotePackageBootstrap(
|
||||
target,
|
||||
signal
|
||||
)
|
||||
assertSshLeaseMatchesTarget(
|
||||
bootstrapLease,
|
||||
target,
|
||||
'SSH 远端运行环境连接身份不匹配'
|
||||
)
|
||||
const result = await bootstrapLease.probe(
|
||||
{
|
||||
operationId: randomUUID(),
|
||||
urls: candidate.urls,
|
||||
size: candidate.size,
|
||||
sha256: candidate.sha256
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
assertSshLeaseMatchesTarget(
|
||||
bootstrapLease,
|
||||
target,
|
||||
'SSH 远端运行环境连接身份不匹配'
|
||||
)
|
||||
this.#sshHosts.assertConnectionTargetCurrent(expectedTarget)
|
||||
return result.available
|
||||
? {
|
||||
available: true,
|
||||
...exposure
|
||||
}
|
||||
: unavailableRemoteDownload(
|
||||
mapRemoteUnavailableReason(result.reason),
|
||||
exposure
|
||||
)
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
if (bootstrapLease) {
|
||||
assertSshLeaseMatchesTarget(
|
||||
bootstrapLease,
|
||||
target,
|
||||
'SSH 远端运行环境连接身份不匹配'
|
||||
)
|
||||
this.#sshHosts.assertConnectionTargetCurrent(
|
||||
expectedTarget
|
||||
)
|
||||
}
|
||||
return unavailableRemoteDownload(
|
||||
'probe-failed',
|
||||
exposure
|
||||
)
|
||||
} finally {
|
||||
bootstrapLease?.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function currentTarget(
|
||||
target: SshConnectionTarget
|
||||
): CurrentSshConnectionTarget {
|
||||
type RemoteDownloadUnavailable = Extract<
|
||||
SshHostRemoteEnvironment['remoteDownload'],
|
||||
{ available: false }
|
||||
>
|
||||
|
||||
function unavailableRemoteDownload(
|
||||
reason: RemoteDownloadUnavailable['reason'],
|
||||
exposure: {
|
||||
source: 'github' | 'mirror' | null
|
||||
packageSize: number | null
|
||||
} | null
|
||||
): SshHostRemoteEnvironment['remoteDownload'] {
|
||||
return {
|
||||
hostId: target.host.id,
|
||||
hostRevision: target.hostRevision,
|
||||
hostKeyGeneration: target.hostKeyGeneration,
|
||||
username: target.host.username
|
||||
available: false,
|
||||
source: exposure?.source ?? null,
|
||||
packageSize: exposure?.packageSize ?? null,
|
||||
reason
|
||||
}
|
||||
}
|
||||
|
||||
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 远端运行环境连接身份不匹配')
|
||||
function mapRemoteUnavailableReason(
|
||||
reason: SshRemotePackageBootstrapUnavailableReason
|
||||
): Extract<
|
||||
SshHostRemoteEnvironment['remoteDownload'],
|
||||
{ available: false }
|
||||
>['reason'] {
|
||||
switch (reason) {
|
||||
case 'missing-curl':
|
||||
case 'missing-sha256sum':
|
||||
case 'missing-unzip':
|
||||
case 'bootstrap-tools-unavailable':
|
||||
return 'missing-tools'
|
||||
case 'managed-path-unavailable':
|
||||
return 'home-unwritable'
|
||||
case 'insufficient-disk-space':
|
||||
return 'insufficient-disk-space'
|
||||
case 'download-unavailable':
|
||||
return 'source-unreachable'
|
||||
default:
|
||||
throw new Error(
|
||||
`未知的远端下载安装探测结果:${String(reason)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -650,6 +650,16 @@ describe('SshHostService', () => {
|
||||
expect(onHostEdited.mock.calls[0]).toEqual([host.id])
|
||||
expect(onHostRemoved).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
harness.service.remove(host.id, async () => {
|
||||
throw new Error('project cleanup failed')
|
||||
})
|
||||
).rejects.toThrow('project cleanup failed')
|
||||
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
|
||||
hosts: [expect.objectContaining({ id: host.id })]
|
||||
})
|
||||
expect(onHostRemoved).not.toHaveBeenCalled()
|
||||
|
||||
await harness.service.remove(host.id)
|
||||
|
||||
expect(onHostRemoved).toHaveBeenCalledWith(host.id)
|
||||
|
||||
@@ -73,14 +73,25 @@ export class SshHostService {
|
||||
return this.store.getSnapshot()
|
||||
}
|
||||
|
||||
async remove(hostId: string): Promise<void> {
|
||||
await this.store.remove(hostId)
|
||||
remove(hostId: string): Promise<void>
|
||||
remove<T>(
|
||||
hostId: string,
|
||||
commitLocalDeletion: () => T
|
||||
): Promise<T>
|
||||
async remove<T>(
|
||||
hostId: string,
|
||||
commitLocalDeletion?: () => T
|
||||
): Promise<T | void> {
|
||||
const result = commitLocalDeletion
|
||||
? await this.store.remove(hostId, commitLocalDeletion)
|
||||
: await this.store.remove(hostId)
|
||||
for (const [candidateId, pending] of this.pendingHostKeys) {
|
||||
if (pending.hostId === hostId) {
|
||||
this.pendingHostKeys.delete(candidateId)
|
||||
}
|
||||
}
|
||||
this.lifecycleHooks.onHostRemoved?.(hostId)
|
||||
return result
|
||||
}
|
||||
|
||||
async inspectDraftHostKey(
|
||||
|
||||
@@ -133,6 +133,32 @@ describe('SshHostStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('restores a Host when its related local deletion fails', async () => {
|
||||
const { filePath, store } = await createHarness()
|
||||
const created = await store.commitValidated({
|
||||
input: passwordHostInput(),
|
||||
hostKey: keyA
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.remove(created.id, async () => {
|
||||
throw new Error('project cleanup failed')
|
||||
})
|
||||
).rejects.toThrow('project cleanup failed')
|
||||
|
||||
await expect(store.resolveHost(created.id)).resolves.toMatchObject({
|
||||
id: created.id,
|
||||
password: 'private password'
|
||||
})
|
||||
expect(
|
||||
(
|
||||
JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
hosts: Array<{ id: string }>
|
||||
}
|
||||
).hosts
|
||||
).toEqual([expect.objectContaining({ id: created.id })])
|
||||
})
|
||||
|
||||
it('resolves one atomic connection target snapshot with credentials and generations', async () => {
|
||||
const { store } = await createHarness()
|
||||
const created = await store.commitValidated({
|
||||
|
||||
@@ -366,19 +366,43 @@ export class SshHostStore {
|
||||
})
|
||||
}
|
||||
|
||||
remove(hostId: string): Promise<void> {
|
||||
remove(hostId: string): Promise<void>
|
||||
remove<T>(hostId: string, commitLocalDeletion: () => T): Promise<T>
|
||||
remove<T>(
|
||||
hostId: string,
|
||||
commitLocalDeletion?: () => T
|
||||
): Promise<T | void> {
|
||||
return this.queue(async () => {
|
||||
const settings = cloneSettings(await this.load())
|
||||
const index = settings.hosts.findIndex(
|
||||
const previousSettings = cloneSettings(await this.load())
|
||||
const nextSettings = cloneSettings(previousSettings)
|
||||
const index = nextSettings.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)
|
||||
nextSettings.hosts.splice(index, 1)
|
||||
await this.persist(nextSettings)
|
||||
try {
|
||||
const result = await commitLocalDeletion?.()
|
||||
this.settings = nextSettings
|
||||
this.credentialSourceCache.delete(hostId)
|
||||
return result
|
||||
} catch (error) {
|
||||
try {
|
||||
await this.persist(previousSettings)
|
||||
} catch (rollbackError) {
|
||||
this.settings = undefined
|
||||
this.settingsLoad = undefined
|
||||
this.credentialSourceCache.clear()
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
'SSH 主机删除失败,且主机设置恢复失败',
|
||||
{ cause: rollbackError }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,20 +13,11 @@ import {
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
const binding: RemoteWorkspaceProjectBinding = {
|
||||
hostId: 'host-1',
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'builder',
|
||||
remoteRootPath: '/srv/project',
|
||||
workspaceIdentity: 'identity-1',
|
||||
agentInstallationId: 'installation-1',
|
||||
agentBinaryDigest: digest,
|
||||
agentVersion: '0.11.0',
|
||||
agentArchitecture: 'x64',
|
||||
agentProtocolMajor: 1
|
||||
remoteRootPath: '/srv/project'
|
||||
}
|
||||
const handle: RemoteWorkspaceHandle = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceIdentity: binding.workspaceIdentity,
|
||||
workspaceIdentity: 'identity-1',
|
||||
canonicalDisplayPath: binding.remoteRootPath,
|
||||
access: 'read-only',
|
||||
git: 'available',
|
||||
@@ -47,25 +38,20 @@ function createLease(
|
||||
return {
|
||||
binding: {
|
||||
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,
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3,
|
||||
remoteUsername: 'builder',
|
||||
agentInstallationId: 'installation-1',
|
||||
agentBinaryDigest: digest,
|
||||
agentVersion: '0.11.0',
|
||||
agentArchitecture: 'x64',
|
||||
agentProtocolMajor: 1,
|
||||
capabilityGeneration: 6
|
||||
},
|
||||
validateWorkspace: vi.fn(async () => ({
|
||||
handle,
|
||||
validatedAt: '2026-08-21T00:00:00.000Z'
|
||||
})),
|
||||
openWorkspace: vi.fn(async () => handle),
|
||||
resumeWorkspace: vi.fn(async () => ({
|
||||
resumed: true,
|
||||
handle
|
||||
})),
|
||||
closeWorkspace: vi.fn(async (request) => ({
|
||||
...request,
|
||||
closed: true as const
|
||||
@@ -278,64 +264,59 @@ describe('RemoteWorkspaceAccess', () => {
|
||||
expect(getGitDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resumes a previous handle and opens a replacement when resume fails', async () => {
|
||||
const lease = createLease({
|
||||
resumableHandle: handle,
|
||||
resumeWorkspace: vi.fn(async () => ({
|
||||
resumed: false,
|
||||
handle
|
||||
}))
|
||||
})
|
||||
it('validates the configured path instead of resuming persisted state', async () => {
|
||||
const lease = createLease()
|
||||
const { access } = createAccess(lease)
|
||||
|
||||
await access.getIdentity()
|
||||
|
||||
expect(lease.resumeWorkspace).toHaveBeenCalledWith(
|
||||
expect(lease.validateWorkspace).toHaveBeenCalledWith(
|
||||
{
|
||||
workspaceId: 'workspace-1',
|
||||
generation: 5,
|
||||
workspaceIdentity: 'identity-1'
|
||||
remoteRootPath: binding.remoteRootPath,
|
||||
requestedAccess: 'read-only',
|
||||
requiredCapabilities: [
|
||||
'list',
|
||||
'stat',
|
||||
'read-text',
|
||||
'search'
|
||||
]
|
||||
},
|
||||
undefined
|
||||
)
|
||||
expect(lease.openWorkspace).toHaveBeenCalledWith(
|
||||
{
|
||||
workspaceIdentity: 'identity-1',
|
||||
requestedAccess: 'read-only'
|
||||
},
|
||||
undefined
|
||||
)
|
||||
expect(lease.validateWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects immutable transport and workspace binding mismatches', async () => {
|
||||
for (const changedBinding of [
|
||||
{ hostKeyGeneration: 99 },
|
||||
{ agentBinaryDigest: `sha256:${'b'.repeat(64)}` },
|
||||
{ agentVersion: '0.11.1' },
|
||||
{ agentArchitecture: 'arm64' as const }
|
||||
]) {
|
||||
const release = vi.fn()
|
||||
const lease = createLease({
|
||||
binding: {
|
||||
...createLease().binding,
|
||||
...changedBinding
|
||||
},
|
||||
release
|
||||
})
|
||||
const { access } = createAccess(lease)
|
||||
it('uses live Agent identity but rejects a different Host or path', async () => {
|
||||
const currentAgent = createLease({
|
||||
binding: {
|
||||
...createLease().binding,
|
||||
hostRevision: 99,
|
||||
agentBinaryDigest: `sha256:${'b'.repeat(64)}`,
|
||||
agentVersion: '0.11.1',
|
||||
agentArchitecture: 'arm64'
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
createAccess(currentAgent).access.getIdentity()
|
||||
).resolves.toMatchObject({
|
||||
kind: 'remote',
|
||||
canonicalDisplayPath: binding.remoteRootPath
|
||||
})
|
||||
|
||||
await expect(access.getIdentity()).rejects.toThrow(
|
||||
'绑定已失效'
|
||||
)
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
}
|
||||
const otherHost = createLease({
|
||||
binding: {
|
||||
...createLease().binding,
|
||||
hostId: 'host-2'
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
createAccess(otherHost).access.getIdentity()
|
||||
).rejects.toThrow('绑定已失效')
|
||||
|
||||
const identityMismatch = createLease({
|
||||
validateWorkspace: vi.fn(async () => ({
|
||||
handle: {
|
||||
...handle,
|
||||
workspaceIdentity: 'identity-2'
|
||||
canonicalDisplayPath: '/srv/other'
|
||||
},
|
||||
validatedAt: '2026-08-21T00:00:00.000Z'
|
||||
}))
|
||||
|
||||
@@ -10,12 +10,8 @@ import {
|
||||
remoteWorkspaceHandleSchema,
|
||||
remoteWorkspaceListRequestSchema,
|
||||
remoteWorkspaceListResultSchema,
|
||||
remoteWorkspaceOpenRequestSchema,
|
||||
remoteWorkspaceOpenResultSchema,
|
||||
remoteWorkspaceReadTextRequestSchema,
|
||||
remoteWorkspaceReadTextResultSchema,
|
||||
remoteWorkspaceResumeRequestSchema,
|
||||
remoteWorkspaceResumeResultSchema,
|
||||
remoteWorkspaceSearchRequestSchema,
|
||||
remoteWorkspaceSearchResultSchema,
|
||||
remoteWorkspaceStatRequestSchema,
|
||||
@@ -31,12 +27,8 @@ import {
|
||||
type RemoteWorkspaceHandle,
|
||||
type RemoteWorkspaceListRequest,
|
||||
type RemoteWorkspaceListResult,
|
||||
type RemoteWorkspaceOpenRequest,
|
||||
type RemoteWorkspaceOpenResult,
|
||||
type RemoteWorkspaceReadTextRequest,
|
||||
type RemoteWorkspaceReadTextResult,
|
||||
type RemoteWorkspaceResumeRequest,
|
||||
type RemoteWorkspaceResumeResult,
|
||||
type RemoteWorkspaceSearchRequest,
|
||||
type RemoteWorkspaceSearchResult,
|
||||
type RemoteWorkspaceStatRequest,
|
||||
@@ -67,16 +59,7 @@ const DEFAULT_READ_BYTES = 256 * 1024
|
||||
const DEFAULT_SEARCH_RESULTS = 100
|
||||
export type RemoteWorkspaceProjectBinding = {
|
||||
hostId: string
|
||||
hostRevision: number
|
||||
hostKeyGeneration: number
|
||||
remoteUsername: string
|
||||
remoteRootPath: string
|
||||
workspaceIdentity: string
|
||||
agentInstallationId: string
|
||||
agentBinaryDigest: string
|
||||
agentVersion: string
|
||||
agentArchitecture: 'x64' | 'arm64'
|
||||
agentProtocolMajor: number
|
||||
}
|
||||
|
||||
export type RemoteWorkspaceTransportBinding = {
|
||||
@@ -98,19 +81,10 @@ export type RemoteWorkspaceTransportBinding = {
|
||||
*/
|
||||
export interface RemoteWorkspaceTransportLease {
|
||||
readonly binding: RemoteWorkspaceTransportBinding
|
||||
readonly resumableHandle?: RemoteWorkspaceHandle
|
||||
validateWorkspace(
|
||||
request: RemoteWorkspaceValidateRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceValidateResult>
|
||||
openWorkspace(
|
||||
request: RemoteWorkspaceOpenRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceOpenResult>
|
||||
resumeWorkspace(
|
||||
request: RemoteWorkspaceResumeRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<RemoteWorkspaceResumeResult>
|
||||
closeWorkspace(
|
||||
request: RemoteWorkspaceCloseRequest
|
||||
): Promise<RemoteWorkspaceCloseResult>
|
||||
@@ -182,15 +156,7 @@ function assertTransportBinding(
|
||||
if (
|
||||
!Number.isSafeInteger(actual.capabilityGeneration) ||
|
||||
actual.capabilityGeneration < 1 ||
|
||||
actual.hostId !== expected.hostId ||
|
||||
actual.hostRevision !== expected.hostRevision ||
|
||||
actual.hostKeyGeneration !== expected.hostKeyGeneration ||
|
||||
actual.remoteUsername !== expected.remoteUsername ||
|
||||
actual.agentInstallationId !== expected.agentInstallationId ||
|
||||
actual.agentBinaryDigest !== expected.agentBinaryDigest ||
|
||||
actual.agentVersion !== expected.agentVersion ||
|
||||
actual.agentArchitecture !== expected.agentArchitecture ||
|
||||
actual.agentProtocolMajor !== expected.agentProtocolMajor
|
||||
actual.hostId !== expected.hostId
|
||||
) {
|
||||
throw new Error('远程工作区连接绑定已失效')
|
||||
}
|
||||
@@ -202,7 +168,7 @@ function assertHandleBinding(
|
||||
): RemoteWorkspaceHandle {
|
||||
const handle = remoteWorkspaceHandleSchema.parse(handleInput)
|
||||
if (
|
||||
handle.workspaceIdentity !== expected.workspaceIdentity ||
|
||||
handle.canonicalDisplayPath !== expected.remoteRootPath ||
|
||||
handle.access !== 'read-only'
|
||||
) {
|
||||
throw new Error('远程工作区身份绑定不匹配')
|
||||
@@ -328,51 +294,22 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
try {
|
||||
assertTransportBinding(this.binding, lease.binding)
|
||||
signal?.throwIfAborted()
|
||||
let handle: RemoteWorkspaceHandle
|
||||
if (lease.resumableHandle) {
|
||||
const previous = assertHandleBinding(
|
||||
this.binding,
|
||||
lease.resumableHandle
|
||||
const validation = remoteWorkspaceValidateResultSchema.parse(
|
||||
await lease.validateWorkspace(
|
||||
remoteWorkspaceValidateRequestSchema.parse({
|
||||
remoteRootPath: this.binding.remoteRootPath,
|
||||
requestedAccess: 'read-only',
|
||||
requiredCapabilities: [
|
||||
...REMOTE_WORKSPACE_READ_CAPABILITIES
|
||||
]
|
||||
}),
|
||||
signal
|
||||
)
|
||||
const resumed = remoteWorkspaceResumeResultSchema.parse(
|
||||
await lease.resumeWorkspace(
|
||||
remoteWorkspaceResumeRequestSchema.parse({
|
||||
workspaceId: previous.workspaceId,
|
||||
generation: previous.generation,
|
||||
workspaceIdentity: this.binding.workspaceIdentity
|
||||
}),
|
||||
signal
|
||||
)
|
||||
)
|
||||
handle = resumed.resumed
|
||||
? assertHandleBinding(this.binding, resumed.handle)
|
||||
: assertHandleBinding(
|
||||
this.binding,
|
||||
remoteWorkspaceOpenResultSchema.parse(
|
||||
await lease.openWorkspace(
|
||||
remoteWorkspaceOpenRequestSchema.parse({
|
||||
workspaceIdentity: this.binding.workspaceIdentity,
|
||||
requestedAccess: 'read-only'
|
||||
}),
|
||||
signal
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const validation = remoteWorkspaceValidateResultSchema.parse(
|
||||
await lease.validateWorkspace(
|
||||
remoteWorkspaceValidateRequestSchema.parse({
|
||||
remoteRootPath: this.binding.remoteRootPath,
|
||||
requestedAccess: 'read-only',
|
||||
requiredCapabilities: [
|
||||
...REMOTE_WORKSPACE_READ_CAPABILITIES
|
||||
]
|
||||
}),
|
||||
signal
|
||||
)
|
||||
)
|
||||
handle = assertHandleBinding(this.binding, validation.handle)
|
||||
}
|
||||
)
|
||||
const handle = assertHandleBinding(
|
||||
this.binding,
|
||||
validation.handle
|
||||
)
|
||||
assertTransportBinding(this.binding, lease.binding)
|
||||
retained = true
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user