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

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:
mesalogo
2026-08-29 10:58:19 +08:00
parent eb947213c8
commit 459cc21e3d
117 changed files with 19876 additions and 9664 deletions
+52 -4
View File
@@ -26,7 +26,10 @@ import {
type ManagedInstallationPaths
} from './cli'
import { InstallationRegistry } from './installation-registry'
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
import {
RegisteredAgentBundleError,
type VerifiedInstalledAgentBundle
} from './installed-bundle-verifier'
import { RuntimeBundleRegistry } from './runtime-bundle-registry'
import type { VerifiedRuntimeBundle } from './runtime-bundle-verifier'
import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts'
@@ -90,7 +93,7 @@ describe('Agent CLI fixed command contract', () => {
expect(runModelBridgeHelper).toHaveBeenCalledOnce()
})
it('passes verified manifest version and protocol into the daemon', async () => {
it('starts the daemon from Host-managed registry evidence without full verification', async () => {
const root = privateTemporaryDirectory()
const paths: ManagedInstallationPaths = {
executablePath: resolve(root, 'install-1', 'goodbuddy-agent'),
@@ -115,6 +118,8 @@ describe('Agent CLI fixed command contract', () => {
status: () => ({ daemonBootId: 'daemon-boot-1' })
}))
const recordCurrentDaemonReady = vi.fn(async () => undefined)
const verifyInstallation = vi.fn(async () => verified)
const loadRegisteredInstallation = vi.fn(async () => verified)
const io = cliIo()
const result = await runAgentCli(
@@ -123,7 +128,8 @@ describe('Agent CLI fixed command contract', () => {
installationPaths: () => paths,
releaseKeyRegistry,
installationRegistry: registry,
verifyInstallation: async () => verified,
verifyInstallation,
loadRegisteredInstallation,
peerIdentityProvider: {
async getPeerIdentity() {
return { uid: 1 }
@@ -156,6 +162,8 @@ describe('Agent CLI fixed command contract', () => {
expect(recordCurrentDaemonReady).toHaveBeenCalledWith(
'daemon-boot-1'
)
expect(loadRegisteredInstallation).toHaveBeenCalledOnce()
expect(verifyInstallation).not.toHaveBeenCalled()
})
it('dispatches generated doctor and lifecycle argv through the real parser', async () => {
@@ -274,6 +282,8 @@ describe('Agent CLI fixed command contract', () => {
storagePath: resolve(root, 'registry.json')
})
installationRegistry.stageCandidate(verified)
const verifyInstallation = vi.fn(async () => verified)
const loadRegisteredInstallation = vi.fn(async () => verified)
const io = cliIo()
const attachResult = await runAgentCli(
@@ -283,7 +293,8 @@ describe('Agent CLI fixed command contract', () => {
installationPaths: () => paths,
releaseKeyRegistry,
installationRegistry,
verifyInstallation: async () => verified,
verifyInstallation,
loadRegisteredInstallation,
io
}
)
@@ -297,6 +308,43 @@ describe('Agent CLI fixed command contract', () => {
secret: Buffer.alloc(32, 7)
})
)
expect(loadRegisteredInstallation).toHaveBeenCalledOnce()
expect(verifyInstallation).not.toHaveBeenCalled()
})
it('emits a fixed repair diagnostic when registered attach metadata is invalid', async () => {
const installationId = verifyAgentInstallationId('install-1')
const root = privateTemporaryDirectory()
const paths = {
executablePath: resolve(root, 'agent', 'goodbuddy-agent'),
stateDirectory: resolve(root, 'state', 'install-1'),
socketPath: resolve(root, 'run', 'install-1.sock')
}
const verified = verifiedInstallation(paths, installationId)
const installationRegistry = new InstallationRegistry({
storagePath: resolve(root, 'registry.json')
})
installationRegistry.stageCandidate(verified)
const io = cliIo()
const result = await runAgentCli(
buildFixedAgentCliArgv(installationId, { kind: 'attach' }),
{
installationPaths: () => paths,
installationRegistry,
loadRegisteredInstallation: vi.fn(async () => {
throw new RegisteredAgentBundleError(
'private installation detail'
)
}),
io
}
)
expect(result).toBe(2)
expect(io.error.read()?.toString()).toBe(
`${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}installation-repair-required\n`
)
})
it('derives fixed Linux paths and rejects untrusted installation IDs', () => {
+73 -17
View File
@@ -28,7 +28,9 @@ import {
} from './private-endpoint'
import { createLinuxPeerIdentityProvider } from './peer-identity-provider'
import {
loadRegisteredAgentBundle,
readManagedAgentReleaseKeyRegistry,
RegisteredAgentBundleError,
verifyInstalledAgentBundle,
type VerifiedInstalledAgentBundle
} from './installed-bundle-verifier'
@@ -79,6 +81,7 @@ export type AgentCliDependencies = {
releaseKeyRegistry: AgentReleaseKeyRegistry
}
) => Promise<VerifiedInstalledAgentBundle>
loadRegisteredInstallation?: typeof loadRegisteredAgentBundle
runtimePaths?: (
installationId: string,
runtimeId: 'opencode',
@@ -124,8 +127,8 @@ export async function runAgentCli(
output: process.stdout,
error: process.stderr
}
const [command, ...rest] = argv
try {
const [command, ...rest] = argv
switch (command) {
case 'daemon':
await runDaemon(rest, dependencies)
@@ -151,9 +154,18 @@ export async function runAgentCli(
)
}
} catch (error) {
io.error.write(
`${error instanceof Error ? error.message : String(error)}\n`
)
if (
error instanceof RegisteredAgentBundleError &&
(command === 'daemon' || command === 'attach-or-bootstrap')
) {
io.error.write(
`${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}installation-repair-required\n`
)
} else {
io.error.write(
`${error instanceof Error ? error.message : String(error)}\n`
)
}
return error instanceof AgentUnsupportedError ? 3 : 2
}
}
@@ -294,12 +306,11 @@ async function runDaemon(
options['installation-id']!
)
const paths = resolveInstallationPaths(installationId, dependencies)
const { verified, registry } = await verifyManagedInstallation(
const { verified } = await loadManagedInstallation(
installationId,
paths,
dependencies
)
registry.assertVerifiedRole(verified, ['current', 'candidate'])
prepareManagedSocketDirectory(paths)
const daemonOptions: ConstructorParameters<typeof AgentDaemon>[0] = {
installationId,
@@ -340,7 +351,8 @@ async function runDaemon(
await createDetachedLifecycle(
installationId,
paths,
dependencies
dependencies,
'registered'
).recordCurrentDaemonReady(daemon.status().daemonBootId)
await (dependencies.waitForShutdown?.() ?? waitForShutdownSignal())
} finally {
@@ -362,12 +374,11 @@ async function runAttach(
installationId,
dependencies
)
const { verified, registry } = await verifyManagedInstallation(
await loadManagedInstallation(
installationId,
paths,
dependencies
)
registry.assertVerifiedRole(verified, ['current', 'candidate'])
prepareManagedSocketDirectory(paths)
const stateDirectory = paths.stateDirectory
ensurePrivateDirectory(stateDirectory, { create: false })
@@ -387,7 +398,8 @@ async function runAttach(
await createDetachedLifecycle(
installationId,
paths,
dependencies
dependencies,
'registered'
).bootstrap()
}
})
@@ -518,7 +530,8 @@ async function runLifecycle(
function createDetachedLifecycle(
installationId: string,
paths: ManagedInstallationPaths,
dependencies: AgentCliDependencies
dependencies: AgentCliDependencies,
identitySource: 'verified' | 'registered' = 'verified'
): DetachedAgentLifecycle {
const options: DetachedAgentLifecycleOptions = {
installationId,
@@ -526,12 +539,25 @@ function createDetachedLifecycle(
stateDirectory: paths.stateDirectory,
socketPath: paths.socketPath,
verifyInstallation: async () => {
const { verified, registry } = await verifyManagedInstallation(
installationId,
paths,
dependencies
)
registry.assertVerifiedRole(verified, ['current', 'candidate'])
if (identitySource === 'registered') {
return (
await loadManagedInstallation(
installationId,
paths,
dependencies
)
).verified
}
const { verified, registry } =
await verifyManagedInstallation(
installationId,
paths,
dependencies
)
registry.assertVerifiedRole(verified, [
'current',
'candidate'
])
return verified
},
probeEndpoint: async (verified) =>
@@ -597,6 +623,36 @@ async function verifyManagedInstallation(
return { verified, registry, releaseKeyRegistry }
}
async function loadManagedInstallation(
installationId: string,
paths: ManagedInstallationPaths,
dependencies: AgentCliDependencies
): Promise<{
verified: VerifiedInstalledAgentBundle
registry: InstallationRegistry
}> {
const architecture = currentAgentArchitecture()
const agentRoot = dirname(dirname(dirname(paths.executablePath)))
const registry =
dependencies.installationRegistry ??
new InstallationRegistry({
storagePath: resolve(agentRoot, 'registry.json')
})
const registered = registry.assertRegisteredRole(
installationId,
['current', 'candidate']
)
const verified = await (
dependencies.loadRegisteredInstallation ??
loadRegisteredAgentBundle
)(dirname(paths.executablePath), {
installationId,
architecture,
registered
})
return { verified, registry }
}
export function deriveManagedInstallationPaths(
installationIdInput: string,
options: {
@@ -62,6 +62,18 @@ describe('Agent installation registry', () => {
expect(() =>
registry.assertVerifiedRole(current, ['current'])
).not.toThrow()
expect(
registry.assertRegisteredRole('install-a', ['current'])
).toMatchObject({
installationId: 'install-a',
manifestSha256: 'a'.repeat(64)
})
expect(() =>
registry.assertRegisteredRole('missing', [
'current',
'candidate'
])
).toThrow('authorized registry role')
})
it('replaces a stale candidate with the requested signed identity', () => {
+22 -7
View File
@@ -105,19 +105,34 @@ export class InstallationRegistry {
roles: readonly ('current' | 'candidate')[]
): InstallationRegistryEntry {
const entry = entryFromVerified(verified)
const registered = this.assertRegisteredRole(
entry.installationId,
roles
)
if (!entryEquals(registered, entry)) {
throw new Error(
'Verified Agent installation does not match its authorized registry role'
)
}
return registered
}
assertRegisteredRole(
installationIdInput: string,
roles: readonly ('current' | 'candidate')[]
): InstallationRegistryEntry {
const installationId = installationRegistryIdSchema.parse(
installationIdInput
)
const registered = roles
.map((role) => this.#state[role])
.find(
(candidate) =>
candidate !== undefined &&
candidate.installationId === entry.installationId
candidate?.installationId === installationId
)
if (
registered === undefined ||
!entryEquals(registered, entry)
) {
if (registered === undefined) {
throw new Error(
'Verified Agent installation does not match its authorized registry role'
'Agent installation does not have an authorized registry role'
)
}
return registered
@@ -23,6 +23,7 @@ import {
import {
canonicalInstalledAgentManifestBytes,
installedAgentManifestSignaturePayload,
loadRegisteredAgentBundle,
verifyInstalledAgentBundle
} from './installed-bundle-verifier'
@@ -132,6 +133,60 @@ describe('installed Agent bundle verifier', () => {
)
})
it('loads Host-managed registry evidence without repeating signature or payload verification', async () => {
writeBundle()
const manifestBytes = readFileSync(
join(installationDirectory, 'manifest.json')
)
const manifestSha256 = createHash('sha256')
.update(manifestBytes)
.digest('hex')
writeFileSync(
join(installationDirectory, 'manifest.sig'),
'not-used-during-attach\n',
{ mode: 0o644 }
)
await expect(
loadRegisteredAgentBundle(installationDirectory, {
installationId: 'install-10',
architecture: 'x64',
registered: {
installationId: 'install-10',
agentVersion: '0.11.0',
manifestSha256,
arch: 'x64'
},
enforceFilesystemMode: process.platform !== 'win32'
})
).resolves.toMatchObject({
installationId: 'install-10',
manifestSha256,
binaryDigest: `sha256:${manifestSha256}`,
manifest: {
agentVersion: '0.11.0',
protocol: { major: 1, minor: 0 }
}
})
writeFileSync(
join(installationDirectory, 'manifest.json'),
Buffer.concat([manifestBytes, Buffer.from(' ')])
)
await expect(
loadRegisteredAgentBundle(installationDirectory, {
installationId: 'install-10',
architecture: 'x64',
registered: {
installationId: 'install-10',
agentVersion: '0.11.0',
manifestSha256,
arch: 'x64'
}
})
).rejects.toThrow('Host-managed registry')
})
})
function resetInstallation(): void {
@@ -17,6 +17,10 @@ import {
type AgentReleaseKey,
type AgentReleaseKeyRegistry
} from '../shared/agent-installation-contracts'
import {
installationRegistryEntrySchema,
type InstallationRegistryEntry
} from '../shared/remote-environment-registry-contracts'
import { assertAbsoluteManagedPath } from './managed-paths'
import {
assertElfArchitecture,
@@ -56,6 +60,22 @@ export type VerifyInstalledAgentBundleOptions = {
uid?: number
}
export type LoadRegisteredAgentBundleOptions = {
installationId: string
architecture: AgentArchitecture
registered: InstallationRegistryEntry
enforceFilesystemMode?: boolean
filesystemPlatform?: NodeJS.Platform
uid?: number
}
export class RegisteredAgentBundleError extends Error {
constructor(message: string, cause?: unknown) {
super(message, { cause })
this.name = 'RegisteredAgentBundleError'
}
}
export async function readManagedAgentReleaseKeyRegistry(
filePath: string,
options: { uid?: number; filesystemPlatform?: NodeJS.Platform } = {}
@@ -202,6 +222,126 @@ export async function verifyInstalledAgentBundle(
}
}
/**
* Reconstructs the identity previously verified and committed by Host
* management. Normal attach/bootstrap uses this bounded metadata path instead
* of rehashing the complete installation on every project switch.
*/
export async function loadRegisteredAgentBundle(
installationDirectoryInput: string,
options: LoadRegisteredAgentBundleOptions
): Promise<VerifiedInstalledAgentBundle> {
try {
return await loadRegisteredAgentBundleUnchecked(
installationDirectoryInput,
options
)
} catch (error) {
if (error instanceof RegisteredAgentBundleError) {
throw error
}
throw new RegisteredAgentBundleError(
error instanceof Error
? error.message
: 'Registered Agent installation is invalid',
error
)
}
}
async function loadRegisteredAgentBundleUnchecked(
installationDirectoryInput: string,
options: LoadRegisteredAgentBundleOptions
): Promise<VerifiedInstalledAgentBundle> {
const installationId = validateInstallationId(options.installationId)
const installationDirectory = resolve(installationDirectoryInput)
assertAbsoluteManagedPath(installationDirectory)
if (basename(installationDirectory) !== installationId) {
throw new Error(
'Agent installation ID does not match its managed directory'
)
}
const registered = installationRegistryEntrySchema.parse(
options.registered
)
if (
registered.installationId !== installationId ||
registered.arch !== options.architecture
) {
throw new Error(
'Registered Agent identity does not match the current installation'
)
}
const rootStat = await lstat(installationDirectory)
assertOwnedDirectory(
rootStat,
'Agent installation directory',
options.uid
)
if (
shouldEnforceMode(options) &&
modeString(rootStat.mode) !== '0700'
) {
throw new Error(
'Agent installation directory permissions must be 0700'
)
}
const manifestBytes = await readMetadataFile(
join(installationDirectory, MANIFEST_FILE_NAME),
'Agent manifest',
options
)
if (sha256Bytes(manifestBytes) !== registered.manifestSha256) {
throw new Error(
'Agent manifest does not match the Host-managed registry'
)
}
let untrusted: unknown
try {
untrusted = JSON.parse(manifestBytes.toString('utf8'))
} catch (error) {
throw new Error('Agent manifest JSON is invalid', { cause: error })
}
const manifest = agentBundleManifestSchema.parse(untrusted)
if (
!canonicalInstalledAgentManifestBytes(manifest).equals(
manifestBytes
) ||
manifest.agentVersion !== registered.agentVersion ||
manifest.arch !== registered.arch
) {
throw new Error(
'Agent manifest does not match the Host-managed registry'
)
}
const executablePath = join(
installationDirectory,
manifest.entrypoint.path
)
await assertRegisteredEntrypoint(executablePath, '0755', options)
await assertRegisteredEntrypoint(
join(installationDirectory, manifest.entrypoint.runtimePath),
'0755',
options
)
await assertRegisteredEntrypoint(
join(installationDirectory, manifest.entrypoint.scriptPath),
'0644',
options
)
return {
installationId,
installationDirectory,
executablePath,
manifest,
manifestSha256: registered.manifestSha256,
binaryDigest: `sha256:${registered.manifestSha256}`
}
}
export function canonicalInstalledAgentManifestBytes(
manifest: AgentBundleManifest
): Buffer {
@@ -351,6 +491,26 @@ function importPublicKey(key: AgentReleaseKey) {
return publicKey
}
async function assertRegisteredEntrypoint(
filePath: string,
expectedMode: string,
options: Pick<
LoadRegisteredAgentBundleOptions,
'uid' | 'filesystemPlatform' | 'enforceFilesystemMode'
>
): Promise<void> {
const stat = await lstat(filePath)
assertOwnedRegularFile(stat, filePath, options.uid)
if (
shouldEnforceMode(options) &&
modeString(stat.mode) !== expectedMode
) {
throw new Error(
`Agent payload mode mismatch: ${basename(filePath)}`
)
}
}
async function readMetadataFile(
filePath: string,
label: string,
+49
View File
@@ -673,6 +673,55 @@ describe('AgentProtocolServer connection bounds', () => {
harness.close()
})
it('marks method context only after exact controller takeover', async () => {
const takeoverStates: Array<boolean | undefined> = []
const harness = createHarness({
reconnect: true,
methods: {
'runtime/openAcpChannel': (_params, context) => {
takeoverStates.push(context.controllerTakeoverProven)
return null
}
}
})
const previous = harness.previousController!
harness.socket.receive(
controlFrame(harness.controller, 'before-resume', '1', 1, {
jsonrpc: '2.0',
id: 1,
method: 'runtime/openAcpChannel',
params: {}
})
)
await waitFor(() => harness.socket.writes.length === 2)
harness.socket.receive(
controlFrame(harness.controller, 'resume', '1', 1, {
jsonrpc: '2.0',
id: 2,
method: 'controller/resume',
params: {
previousGeneration: previous.generation,
previousConnectionId: previous.connectionId,
daemonBootId: daemonStatus.daemonBootId,
capabilityGeneration: previous.capabilityGeneration
}
})
)
await waitFor(() => harness.socket.writes.length === 4)
harness.socket.receive(
controlFrame(harness.controller, 'after-resume', '1', 1, {
jsonrpc: '2.0',
id: 3,
method: 'runtime/openAcpChannel',
params: {}
})
)
await waitFor(() => harness.socket.writes.length === 6)
expect(takeoverStates).toEqual([false, true])
harness.close()
})
it.each([
['previous connection', { previousConnectionId: 'connection-wrong' }],
['capability generation', { capabilityGeneration: 2 }],
+2
View File
@@ -82,6 +82,7 @@ const RUNTIME_ACP_METHODS = [
export type ProtocolMethodContext = {
controller: ControllerLease
channelId: string
controllerTakeoverProven?: boolean
signal?: AbortSignal
}
@@ -841,6 +842,7 @@ class ProtocolConnection {
const result = await handler(request.params, {
controller: this.#controller,
channelId: channel.channelId,
controllerTakeoverProven: this.#takeoverProven,
signal: this.#abortController.signal
})
if (request.method === 'runtime/resumeAcpChannel') {
+172 -11
View File
@@ -155,11 +155,7 @@ class MemoryAcpJournal implements RuntimeAcpJournal {
}
class FakeProcess implements RuntimeAcpProcessOwner {
readonly identity: RuntimeAcpProcessIdentity = {
launchId: 'launch-1',
processId: 'process-1',
supervisorIdentityDigest: digest('9')
}
readonly identity: RuntimeAcpProcessIdentity
readonly writes: Uint8Array[] = []
readonly stops: string[] = []
readonly prompts: Array<{
@@ -167,15 +163,24 @@ class FakeProcess implements RuntimeAcpProcessOwner {
maximumInputBytes: number
}> = []
completions = 0
reconciliation: RuntimeAcpProcessReconciliation = {
identity: this.identity,
state: 'running',
processTree: 'running'
}
reconciliation: RuntimeAcpProcessReconciliation
#listener?: (
output: RuntimeAcpProcessOutput
) => void | Promise<void>
constructor(identity = '1') {
this.identity = {
launchId: `launch-${identity}`,
processId: `process-${identity}`,
supervisorIdentityDigest: digest(identity)
}
this.reconciliation = {
identity: this.identity,
state: 'running',
processTree: 'running'
}
}
beginPrompt(input: {
deadlineAt: string
maximumInputBytes: number
@@ -238,6 +243,8 @@ function harness(input: {
now?: number
maximumOutputBytes?: number
bridgeCloseError?: boolean
uniqueProcesses?: boolean
outputGate?: Promise<void>
} = {}) {
const workMode = input.workMode ?? 'ask'
let now = input.now ?? 1_000
@@ -275,10 +282,13 @@ function harness(input: {
workMode: launch.workMode,
scratch: launch.scratch
})
return process
return input.uniqueProcesses && launches.length > 1
? new FakeProcess(String(launches.length))
: process
}),
now: () => now,
outputSink: vi.fn(async (frame) => {
await input.outputGate
outputFrames.push(Buffer.from(frame.payload).toString())
}),
blobSink,
@@ -644,6 +654,113 @@ describe('RuntimeAcpBackend', () => {
).rejects.toMatchObject({ code: 'stale-controller' })
})
it('reopens a detached idle binding after a proven same-controller takeover', async () => {
const fixture = harness()
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation()
)
await invoke(fixture, 'runtime/completePrompt', {
bindingId: 'binding-1',
operationId: 'request-1',
requestId: 'request-1'
})
fixture.context.abort.abort()
await new Promise<void>((resolve) => setImmediate(resolve))
const replacement = protocolContext({
generation: 2,
connectionId: 'connection-2'
})
replacement.controllerTakeoverProven = true
await expect(
fixture.backend.methods['runtime/openAcpChannel']!(
fixture.openRequest,
replacement
)
).resolves.toMatchObject({
bindingId: 'binding-1',
channelEpoch: '1001'
})
expect(fixture.process.stops).toEqual(['binding-closed'])
expect(fixture.journal.retired).toContainEqual({
controllerId: 'controller-1',
bindingId: 'binding-1',
channelEpoch: '1000'
})
})
it('drains the detached old epoch before replacing an idle binding', async () => {
let releaseOutput!: () => void
const outputGate = new Promise<void>((resolve) => {
releaseOutput = resolve
})
const fixture = harness({ outputGate })
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation()
)
const pendingOutput = fixture.process.emit('stdout', 'pending')
await new Promise<void>((resolve) => setImmediate(resolve))
await invoke(fixture, 'runtime/completePrompt', {
bindingId: 'binding-1',
operationId: 'request-1',
requestId: 'request-1'
})
fixture.context.abort.abort()
await new Promise<void>((resolve) => setImmediate(resolve))
const replacement = protocolContext({
generation: 2,
connectionId: 'connection-2'
})
replacement.controllerTakeoverProven = true
let replaced = false
const reopen = fixture.backend.methods[
'runtime/openAcpChannel'
]!(fixture.openRequest, replacement).then((result) => {
replaced = true
return result
})
await new Promise<void>((resolve) => setImmediate(resolve))
expect(replaced).toBe(false)
releaseOutput()
await pendingOutput
await expect(reopen).resolves.toMatchObject({
bindingId: 'binding-1',
channelEpoch: '1001'
})
expect(fixture.journal.frames).toContainEqual(
expect.objectContaining({
bindingId: 'binding-1',
channelEpoch: '1000',
sequence: '1'
})
)
})
it('does not adopt an idle binding without exact takeover proof', async () => {
const fixture = harness()
await open(fixture)
fixture.context.abort.abort()
await new Promise<void>((resolve) => setImmediate(resolve))
await expect(
fixture.backend.methods['runtime/openAcpChannel']!(
fixture.openRequest,
protocolContext({
generation: 2,
connectionId: 'connection-2'
})
)
).rejects.toMatchObject({ code: 'stale-controller' })
})
it('reopens a definitively closed stable binding with a fresh epoch and generation', async () => {
const fixture = harness()
await open(fixture)
@@ -799,6 +916,13 @@ describe('RuntimeAcpBackend', () => {
generation: 2,
connectionId: 'connection-2'
})
resumedContext.controllerTakeoverProven = true
await expect(
fixture.backend.methods['runtime/openAcpChannel']!(
fixture.openRequest,
resumedContext
)
).rejects.toMatchObject({ code: 'stale-controller' })
await expect(
fixture.backend.methods['runtime/resumeAcpChannel']!(
{
@@ -1019,6 +1143,43 @@ describe('RuntimeAcpBackend', () => {
).rejects.toMatchObject({ code: 'identity' })
expect(fixture.process.stops).toEqual(['identity-conflict'])
})
it('runs independent conversations for two workspaces on one controller', async () => {
const fixture = harness({ uniqueProcesses: true })
await open(fixture)
const secondOpen = {
...fixture.openRequest,
bindingId: 'binding-2',
workspaceIdentity: 'workspace-identity-2'
}
await invoke(
fixture,
'runtime/openAcpChannel',
secondOpen
)
await Promise.all([
invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation()
),
invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation({
bindingId: 'binding-2',
operationId: 'request-2',
requestId: 'request-2',
channelEpoch: '1001',
workspaceIdentity: 'workspace-identity-2'
})
)
])
expect(fixture.launches).toHaveLength(2)
expect(fixture.process.stops).toEqual([])
})
})
async function open(fixture: Harness): Promise<Record<string, unknown>> {
+24 -2
View File
@@ -621,8 +621,30 @@ export class RuntimeAcpBackend {
)
}
if (existing.state !== 'closed' || existing.process !== undefined) {
this.#assertBindingOwner(existing, context)
return acpOpenChannelResultSchema.parse(existing.openResult)
const ownedByCurrentConnection =
existing.controllerGeneration ===
context.controller.generation &&
existing.connectionId ===
context.controller.connectionId
if (ownedByCurrentConnection) {
return acpOpenChannelResultSchema.parse(existing.openResult)
}
const canReplaceDetachedIdleBinding =
context.controllerTakeoverProven === true &&
context.controller.generation >
existing.controllerGeneration &&
existing.transportState === 'detached' &&
existing.activeOperationId === undefined &&
(existing.state === 'open' ||
existing.state === 'running')
if (!canReplaceDetachedIdleBinding) {
this.#assertBindingOwner(existing, context)
}
await Promise.all([
existing.inputTail,
existing.outputTail
])
await this.#stopAndReconcile(existing, 'binding-closed')
}
this.#assertOpenCapacity(context.controller.controllerId)
const channelEpoch = this.#allocateChannelEpoch()
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
View File
@@ -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
View File
@@ -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
View File
@@ -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?.()
}
+15 -5
View File
@@ -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
+1 -4
View File
@@ -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?.()
+13 -18
View File
@@ -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
)
+242 -4
View File
@@ -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'
+174 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+51
View File
@@ -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(),
+17 -47
View File
@@ -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 {
+273 -22
View File
@@ -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()
})
})
+202 -56
View File
@@ -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)}`
)
}
}
+10
View File
@@ -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)
+13 -2
View File
@@ -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(
+26
View File
@@ -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({
+31 -7
View File
@@ -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'
}))
+17 -80
View File
@@ -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 {
+9 -11
View File
@@ -117,7 +117,9 @@ import type {
SshDirectoryBrowseResult,
SshHostKeyInspection,
RemoteEnvironmentUpdateProgress,
RemoteEnvironmentUpdateRequest,
SshHostRemoteEnvironment,
SshHostRemovalResult,
SshHostsSnapshot,
SshHostValidationRequest,
SshHostValidationResult
@@ -375,11 +377,10 @@ const desktopApi: DesktopApi = {
handler
)
},
remove: async (hostId: string) => {
await ipcRenderer.invoke(ipcChannels.sshHostsRemove, {
remove: (hostId: string) =>
ipcRenderer.invoke(ipcChannels.sshHostsRemove, {
hostId
})
},
}) as Promise<SshHostRemovalResult>,
inspectDraftHostKey: (input: SshHostDraftInspectionRequest) =>
ipcRenderer.invoke(
ipcChannels.sshHostsInspectDraftKey,
@@ -401,10 +402,12 @@ const desktopApi: DesktopApi = {
ipcChannels.sshHostsRemoteEnvironment,
{ hostId }
) as Promise<SshHostRemoteEnvironment>,
updateRemoteEnvironment: async (hostId: string) => {
updateRemoteEnvironment: async (
input: RemoteEnvironmentUpdateRequest
) => {
await ipcRenderer.invoke(
ipcChannels.sshHostsUpdateRemoteEnvironment,
{ hostId }
input
)
},
cancelRemoteEnvironmentUpdate: async (hostId: string) => {
@@ -784,11 +787,6 @@ const desktopApi: DesktopApi = {
})
},
remote: {
activate: (projectId: string) =>
ipcRenderer.invoke(
ipcChannels.remoteProjectActivate,
projectId
) as Promise<AssistantProject>,
save: (input: RemoteProjectSaveRequest) =>
ipcRenderer.invoke(
ipcChannels.remoteProjectSave,
+1 -2
View File
@@ -168,7 +168,7 @@ describe('sandboxed preload', () => {
expect(source).toContain('cancelDirectoryBrowse: async ()')
expect(source).toContain('getRemoteEnvironment: (hostId: string)')
expect(source).toContain(
'updateRemoteEnvironment: async (hostId: string)'
'input: RemoteEnvironmentUpdateRequest'
)
expect(source).toContain(
'cancelRemoteEnvironmentUpdate: async (hostId: string)'
@@ -230,7 +230,6 @@ describe('sandboxed preload', () => {
'utf8'
)
expect(source).toContain('remote: {')
expect(source).toContain('remoteProjectActivate')
expect(source).toContain('remoteProjectSave')
expect(source).toContain('remoteProjectCancelCurrent')
expect(source).toContain('remoteProjectSaveProgress')
+83 -549
View File
@@ -395,8 +395,6 @@ const api: DesktopApi = {
setArchived: vi.fn(async () => {}),
delete: vi.fn(async () => {}),
remote: {
activate:
vi.fn<DesktopApi['projects']['remote']['activate']>(),
save: vi.fn<DesktopApi['projects']['remote']['save']>(),
cancelCurrent:
vi.fn<DesktopApi['projects']['remote']['cancelCurrent']>(),
@@ -983,7 +981,6 @@ describe('App', () => {
vi.mocked(api.conversations.onChanged)
.mockReset()
.mockReturnValue(() => undefined)
vi.mocked(api.projects.remote.activate).mockReset()
vi.mocked(api.projects.remote.cancelCurrent)
.mockReset()
.mockResolvedValue()
@@ -5809,7 +5806,6 @@ describe('App', () => {
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
expect(api.projects.remote.activate).not.toHaveBeenCalled()
await waitFor(() =>
expect(
localStorage.getItem('goodbuddy.active-project.v1')
@@ -5817,7 +5813,7 @@ describe('App', () => {
)
})
it('shows only supported OpenCode choices in a managed SSH conversation', async () => {
it('switches immediately to a managed SSH project and shows only supported OpenCode choices', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
@@ -5836,9 +5832,6 @@ describe('App', () => {
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockResolvedValueOnce(
remoteProject
)
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000319',
@@ -5872,6 +5865,9 @@ describe('App', () => {
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(remoteProject.name)
)
expect(
screen.queryByRole('progressbar')
).not.toBeInTheDocument()
const runtimeButton = await screen.findByRole('button', {
name: /OpenCode · 默认模型/u
@@ -5914,6 +5910,57 @@ describe('App', () => {
).toBeInTheDocument()
})
it('switches between projects on one Host without a connection phase', async () => {
installRemoteProjectsSetting(true)
const first = {
...project,
id: '00000000-0000-4000-8000-000000000126',
name: 'Host 项目一',
rootPath: '/srv/one',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000226',
remoteRootPath: '/srv/one'
},
builtInDefault: false
}
const second = {
...first,
id: '00000000-0000-4000-8000-000000000127',
name: 'Host 项目二',
rootPath: '/srv/two',
executionSpace: {
...first.executionSpace,
remoteRootPath: '/srv/two'
}
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
first,
second
])
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
vi.mocked(
api.runtimeCustomization.getNativeSnapshot
).mockClear()
for (const remoteProject of [first, second, first]) {
selectProjectOption(remoteProject.name)
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(remoteProject.name)
expect(
screen.queryByRole('progressbar')
).not.toBeInTheDocument()
}
expect(
api.runtimeCustomization.getNativeSnapshot
).not.toHaveBeenCalled()
})
it('keeps saved remote projects hidden while the preview is disabled', async () => {
installRemoteProjectsSetting(false)
const remoteProject = {
@@ -5944,7 +5991,6 @@ describe('App', () => {
expect(
within(menu).queryByText(remoteProject.name)
).not.toBeInTheDocument()
expect(api.projects.remote.activate).not.toHaveBeenCalled()
})
it('falls back to a local project when the remote preview is disabled', async () => {
@@ -5966,9 +6012,6 @@ describe('App', () => {
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockResolvedValueOnce(
remoteProject
)
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
@@ -6014,79 +6057,18 @@ describe('App', () => {
expect(api.projects.delete).not.toHaveBeenCalled()
})
it('cancels pending remote activation when the preview is disabled', async () => {
const { updateSettings } = installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000118',
name: '连接中的远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000218',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
const activation = deferred<typeof remoteProject>()
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockReturnValueOnce(
activation.promise
)
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(remoteProject.name)
await screen.findByRole('progressbar', {
name: '远程项目连接进度'
})
fireEvent.click(screen.getByText('本地工作区'))
fireEvent.click(
await screen.findByRole('tab', { name: '平台功能' })
)
fireEvent.click(
await screen.findByRole('tab', {
name: '远程项目(技术预览)'
})
)
fireEvent.click(
await screen.findByRole('switch', {
name: '远程项目(技术预览)'
})
)
await waitFor(() =>
expect(updateSettings).toHaveBeenCalledWith({
remoteProjectsEnabled: false
})
)
expect(api.projects.remote.cancelCurrent).toHaveBeenCalledOnce()
await act(async () => {
activation.resolve(remoteProject)
await activation.promise
})
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
})
it('keeps the current project active when remote activation fails', async () => {
it('deletes an unreachable non-active remote project without connecting', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000104',
name: '失效远程项目',
rootPath: '/srv/project',
id: '00000000-0000-4000-8000-000000000125',
name: '目录已丢失的远程项目',
rootPath: '/srv/missing',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000204',
remoteRootPath: '/srv/project'
hostId: '00000000-0000-4000-8000-000000000225',
remoteRootPath: '/srv/missing'
},
builtInDefault: false
}
@@ -6094,489 +6076,40 @@ describe('App', () => {
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockRejectedValueOnce(
new Error(
"Error invoking remote method 'projects:remote:activate': AgentInstallationError: Agent upload failed"
)
)
render(<App />)
await waitFor(() =>
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
)
selectProjectOption(remoteProject.name)
expect(
await screen.findByText(
'远程项目更新失败:Agent upload failed。请检查 Host 后重试'
)
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
})
it('revalidates the current SSH project when it is selected again', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000114',
name: '可重新连接的远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000214',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockResolvedValue(
remoteProject
)
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(remoteProject.name)
await waitFor(() =>
expect(api.projects.remote.activate).toHaveBeenCalledTimes(1)
)
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(remoteProject.name)
selectProjectOption(remoteProject.name)
await waitFor(() =>
expect(api.projects.remote.activate).toHaveBeenCalledTimes(2)
)
expect(api.projects.remote.activate).toHaveBeenLastCalledWith(
remoteProject.id
)
})
it('keeps Host-stale projects blocked and preserves queued dispatches until reactivation succeeds', async () => {
installRemoteProjectsSetting(true)
const hostId = '00000000-0000-4000-8000-000000000215'
const candidateId =
'00000000-0000-4000-8000-000000000315'
const remoteConversationId =
'00000000-0000-4000-8000-000000000415'
const fingerprint = `SHA256:${'A'.repeat(43)}`
const host = {
id: hostId,
name: 'Build host',
hostname: '192.168.0.23',
port: 22,
username: 'root',
authentication: 'system-agent' as const,
credentialConfigured: true,
credentialSource: 'system-agent' as const,
hostKey: {
state: 'verified' as const,
algorithm: 'ecdsa-sha2-nistp256',
fingerprintSha256: fingerprint,
generation: 1
},
lastValidatedAt: '2026-08-24T00:00:00.000Z',
createdAt: '2026-08-24T00:00:00.000Z',
updatedAt: '2026-08-24T00:00:00.000Z'
}
const updatedHost = {
...host,
hostname: '10.7.0.23',
hostKey: { ...host.hostKey, generation: 2 },
updatedAt: '2026-08-24T00:01:00.000Z'
}
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000115',
name: 'Host 一致性项目',
rootPath: '/root',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId,
remoteRootPath: '/root'
},
builtInDefault: false
}
const sshHosts: NonNullable<DesktopApi['sshHosts']> = {
getSnapshot: vi.fn(async () => ({
hosts: [host],
secureStorageAvailable: true
})),
getAgentPackageInventory: vi.fn(async () => ({
checkedAt: '2026-08-24T00:00:00.000Z',
catalog: {
state: 'available' as const,
checkedAt: '2026-08-24T00:00:00.000Z',
error: null
},
entries: [
{
platform: 'linux' as const,
architecture: 'x64' as const,
state: 'verified' as const,
version: '0.11.2',
latestVersion: '0.11.4',
updateAvailable: true,
agentProtocol: { major: 2, minor: 0 },
remoteRuntimeVersion: '1.18.9'
},
{
platform: 'linux' as const,
architecture: 'arm64' as const,
state: 'not-downloaded' as const,
version: null,
latestVersion: '0.11.4',
updateAvailable: false,
agentProtocol: null,
remoteRuntimeVersion: null
}
]
})),
downloadAgentPackage: vi.fn(),
importAgentPackage: vi.fn(),
exportAgentPackage: vi.fn(async () => {}),
onAgentPackageProgress: vi.fn(() => () => {}),
remove: vi.fn(async () => undefined),
inspectDraftHostKey: vi.fn(async () => ({
candidateId,
hostId,
state: 'verified' as const,
algorithm: 'ecdsa-sha2-nistp256',
fingerprintSha256: fingerprint
})),
discardCandidate: vi.fn(async () => undefined),
validateAndSave: vi.fn(async () => ({
host: updatedHost,
connection: {
hostId,
connected: true as const,
latencyMs: 12,
platform: 'linux' as const,
architecture: 'x64' as const,
shell: '/bin/bash',
homeDirectory: '/root',
detail: 'SSH 已连接,远端系统为 linux/x64'
}
})),
getRemoteEnvironment: vi.fn(async () => ({
hostId,
checkedAt: '2026-08-24T00:00:00.000Z',
architecture: 'x64' as const,
agent: {
state: 'current' as const,
expected: {
version: '0.11.2-e2e.8',
architecture: 'x64' as const
},
installed: {
version: '0.11.2-e2e.8',
architecture: 'x64' as const
}
},
runtimes: [{
runtimeId: 'opencode' as const,
provider: 'opencode' as const,
state: 'current' as const,
expected: {
version: '1.18.9',
architecture: 'x64' as const
},
installed: {
version: '1.18.9',
architecture: 'x64' as const
}
}]
})),
updateRemoteEnvironment: vi.fn(async () => undefined),
cancelRemoteEnvironmentUpdate: vi.fn(async () => undefined),
onRemoteEnvironmentUpdateProgress: vi.fn(() => () => undefined),
browseDirectories: vi.fn(async () => ({
path: '/root',
homeDirectory: '/root',
parentPath: '/',
entries: [],
truncated: false
})),
cancelDirectoryBrowse: vi.fn(async () => undefined)
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: remoteConversationId,
projectId: remoteProject.id,
title: 'Host 一致性对话',
updatedAt: Date.now(),
messages: []
}
])
const hostReactivation = deferred<typeof remoteProject>()
vi.mocked(api.projects.remote.activate)
.mockResolvedValueOnce(remoteProject)
.mockReturnValueOnce(hostReactivation.promise)
.mockResolvedValueOnce(remoteProject)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: { ...api, sshHosts }
})
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(remoteProject.name)
await waitFor(() =>
expect(api.projects.remote.activate).toHaveBeenCalledTimes(1)
)
fireEvent.click(await screen.findByText('本地工作区'))
await screen.findByRole('heading', { name: '设置中心' })
fireEvent.click(screen.getByLabelText('当前项目'))
fireEvent.click(
screen.getByRole('tab', { name: '主机与远程执行' })
)
fireEvent.click(
await screen.findByRole('button', {
name: '编辑 Build host'
screen.getByRole('menuitem', {
name: `管理项目 ${remoteProject.name}`
})
)
fireEvent.change(screen.getByLabelText('主机地址'), {
target: { value: '10.7.0.23' }
})
const dialog = screen.getByRole('dialog', { name: '项目设置' })
fireEvent.click(
screen.getByRole('button', { name: '检查 Host Key' })
within(dialog).getByRole('button', { name: '删除项目' })
)
fireEvent.change(
within(dialog).getByLabelText(
`输入“${remoteProject.name}”确认删除`
),
{ target: { value: remoteProject.name } }
)
fireEvent.click(
await screen.findByRole('button', {
name: '确认身份并继续'
within(dialog).getByRole('button', {
name: '永久删除项目'
})
)
fireEvent.click(
screen.getByRole('button', { name: '验证并保存' })
)
await waitFor(() =>
expect(sshHosts.validateAndSave).toHaveBeenCalledOnce()
)
await waitFor(() =>
expect(api.projects.remote.activate).toHaveBeenCalledTimes(2)
)
expect(api.projects.remote.activate).toHaveBeenLastCalledWith(
remoteProject.id
)
const queuedDispatch: ConversationQueueDispatch = {
item: {
id: '00000000-0000-4000-8000-000000000515',
conversationId: remoteConversationId,
source: 'user',
label: '等待 Host 恢复的消息',
createdAt: '2026-08-24T00:02:00.000Z'
},
input: {
conversationId: remoteConversationId,
projectId: remoteProject.id,
runtimeSelection: { provider: 'opencode' },
workMode: 'ask',
includeMemoryContext: true,
prompt: '等待 Host 恢复的消息',
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto'
}
}
act(() => {
conversationQueueDispatchListener?.(queuedDispatch)
})
await waitFor(() =>
expect(api.conversationQueue.releaseUser).toHaveBeenCalledWith(
queuedDispatch.item.id
expect(api.projects.delete).toHaveBeenCalledWith(
remoteProject.id,
remoteProject.name
)
)
expect(run).not.toHaveBeenCalled()
await act(async () => {
hostReactivation.reject(new Error('Host reconnect failed'))
await hostReactivation.promise.catch(() => undefined)
})
expect(
await screen.findByText(
'远程项目更新失败:Host reconnect failed。请检查 Host 后重试'
)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '对话' }))
const composer = await screen.findByLabelText('向 GoodBuddy 提问')
fireEvent.change(composer, {
target: { value: '前台消息也必须等待重新连接' }
})
const send = screen.getByLabelText('发送')
expect(send).toBeDisabled()
expect(send).toHaveAttribute(
'title',
'远程项目需要重新连接,请重新选择当前项目后再发送'
)
selectProjectOption(remoteProject.name)
await waitFor(() =>
expect(api.projects.remote.activate).toHaveBeenCalledTimes(3)
)
await waitFor(() => expect(send).toBeEnabled())
await waitFor(() =>
expect(api.conversationQueue.ready).toHaveBeenCalledWith(
remoteConversationId
)
)
act(() => {
conversationQueueDispatchListener?.(queuedDispatch)
})
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(run.mock.calls[0]![0]).toMatchObject({
queueItemId: queuedDispatch.item.id,
projectId: remoteProject.id,
prompt: queuedDispatch.input.prompt
})
expect(api.conversationQueue.releaseUser).toHaveBeenCalledOnce()
})
it('shows every direct remote activation phase and blocks conflicting project actions', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000105',
name: '阶段远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000205',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
const activation = deferred<typeof remoteProject>()
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockReturnValueOnce(
activation.promise
)
render(<App />)
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
selectProjectOption(remoteProject.name)
const progress = await screen.findByRole('progressbar', {
name: '远程项目连接进度'
})
expect(progress).toHaveAttribute('max', '5')
expect(progress).toHaveAttribute('value', '1')
expect(
screen.getByText('正在连接远程项目“阶段远程项目”')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '当前项目' })
).toBeDisabled()
expect(screen.getByLabelText('新建项目')).toBeDisabled()
expect(screen.getByLabelText('项目设置')).toBeDisabled()
act(() => {
remoteProjectSaveProgressListener?.({ phase: 'workspace' })
})
expect(progress).toHaveAttribute('value', '3')
expect(
screen.getByText('当前阶段:远端工作区')
).toBeInTheDocument()
const stages = screen.getByRole('list', {
name: '远程项目连接阶段'
})
expect(within(stages).getAllByRole('listitem')).toHaveLength(5)
expect(
within(stages).getByText('远端工作区').closest('li')
).toHaveAttribute('aria-current', 'step')
await act(async () => {
activation.resolve(remoteProject)
await activation.promise
})
await waitFor(() =>
expect(
screen.queryByRole('progressbar', {
name: '远程项目连接进度'
})
).not.toBeInTheDocument()
)
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(remoteProject.name)
})
it('cancels direct remote activation without switching projects', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000106',
name: '可取消远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000206',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
const activation = deferred<typeof remoteProject>()
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockReturnValueOnce(
activation.promise
)
render(<App />)
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
selectProjectOption(remoteProject.name)
fireEvent.click(
await screen.findByRole('button', { name: '取消连接' })
)
expect(api.projects.remote.cancelCurrent).toHaveBeenCalledOnce()
expect(
screen.getByRole('button', { name: '取消中…' })
).toBeDisabled()
await act(async () => {
activation.reject(
new DOMException('Remote project save cancelled', 'AbortError')
)
await activation.promise.catch(() => undefined)
})
expect(
await screen.findByText('已取消远程项目连接')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
expect(
screen.queryByRole('progressbar', {
name: '远程项目连接进度'
})
).not.toBeInTheDocument()
})
it('shows grouped project details and keeps the rich menu keyboard accessible', async () => {
@@ -6610,15 +6143,16 @@ describe('App', () => {
const selectedProject = within(menu)
.getByText(project.name, { selector: 'b' })
.closest<HTMLButtonElement>('[role="menuitemradio"]')
const remoteProject = within(menu)
.getByText(channelProject.name, { selector: 'b' })
.closest<HTMLButtonElement>('[role="menuitemradio"]')
const remoteProjectSettings = within(menu).getByRole(
'menuitem',
{ name: `管理项目 ${channelProject.name}` }
)
expect(selectedProject).toHaveAttribute('aria-checked', 'true')
await waitFor(() => expect(selectedProject).toHaveFocus())
fireEvent.keyDown(selectedProject!, { key: 'End' })
expect(remoteProject).toHaveFocus()
fireEvent.keyDown(remoteProject!, { key: 'Escape' })
expect(remoteProjectSettings).toHaveFocus()
fireEvent.keyDown(remoteProjectSettings, { key: 'Escape' })
expect(trigger).toHaveFocus()
expect(
screen.queryByRole('menu', { name: '当前项目' })
+94 -390
View File
@@ -89,9 +89,6 @@ import {
agentRuntimeSelectionSchema,
type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import type {
RemoteProjectSavePhase
} from '../../shared/remote-project-candidate-contracts'
import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
@@ -1994,25 +1991,6 @@ function App(): React.JSX.Element {
const [selectedExpertId, setSelectedExpertId] = useState('')
const [activeProjectId, setActiveProjectId] = useState('')
const activeProjectIdRef = useRef(activeProjectId)
const remoteProjectActivationRequestRef = useRef(0)
const remoteProjectActivationControlRef = useRef<{
requestId: number
projectId: string
cancelRequested: boolean
} | undefined>(undefined)
const [remoteProjectActivation, setRemoteProjectActivation] =
useState<{
projectId: string
phase: RemoteProjectSavePhase
cancelling: boolean
}>()
const remoteProjectsRequiringActivationRef = useRef(
new Set<string>()
)
const [
remoteProjectsRequiringActivation,
setRemoteProjectsRequiringActivation
] = useState<Set<string>>(() => new Set())
const workspaceChangesRequestRef = useRef(0)
const runtimeStatusRequestRef = useRef(0)
const runtimeSetupPromptedRef = useRef(false)
@@ -3020,6 +2998,7 @@ function App(): React.JSX.Element {
if (
!activeRuntimeSelection ||
activeConversation?.remote ||
activeProjectUsesManagedSsh ||
(activeRuntimeSelection.provider !== 'opencode' &&
activeRuntimeSelection.provider !== 'continue')
) {
@@ -3053,6 +3032,7 @@ function App(): React.JSX.Element {
})
}, [
activeConversation?.remote,
activeProjectUsesManagedSsh,
activeProjectId,
activeRuntimeSelection,
activeRuntimeSelectionKey
@@ -3396,8 +3376,6 @@ function App(): React.JSX.Element {
},
[notify, projects, runtimeSettings, setActiveId, setView]
)
const activeRemoteProjectRequiresActivation =
remoteProjectsRequiringActivation.has(activeProjectId)
const activeProjectDisplayName = activeProject
? getProjectDisplayText(activeProject, tWorkspace).name
: undefined
@@ -4880,191 +4858,6 @@ function App(): React.JSX.Element {
[]
)
const activateRemoteProject = useCallback(
async (
project: AssistantProject
): Promise<{
project: AssistantProject
current: boolean
}> => {
if (
!remoteProjectsEnabled ||
project.executionSpace.kind !== 'ssh'
) {
return { project, current: false }
}
const requestId = remoteProjectActivationRequestRef.current + 1
remoteProjectActivationRequestRef.current = requestId
const control = {
requestId,
projectId: project.id,
cancelRequested: false
}
remoteProjectActivationControlRef.current = control
setRemoteProjectActivation({
projectId: project.id,
phase: 'host',
cancelling: false
})
const removeProgress =
window.goodbuddy.projects.remote.onSaveProgress(
(progress) => {
if (
remoteProjectActivationControlRef.current !==
control
) {
return
}
setRemoteProjectActivation({
projectId: project.id,
phase: progress.phase,
cancelling: control.cancelRequested
})
}
)
try {
const activated =
await window.goodbuddy.projects.remote.activate(project.id)
setProjects((current) =>
current.map((candidate) =>
candidate.id === activated.id ? activated : candidate
)
)
const current =
remoteProjectActivationRequestRef.current === requestId &&
!control.cancelRequested
if (current) {
notify({
tone: 'success',
message: tRef.current('notices.remoteProjectActivated'),
dedupeKey: `remote-project-activated:${project.id}`
})
} else if (
remoteProjectActivationRequestRef.current === requestId &&
control.cancelRequested
) {
notify({
tone: 'info',
message: tRef.current(
'notices.remoteProjectActivationCancelled'
),
dedupeKey: `remote-project-cancelled:${project.id}`
})
}
return { project: activated, current }
} catch (reason) {
if (
remoteProjectActivationRequestRef.current !== requestId
) {
return { project, current: false }
}
if (control.cancelRequested) {
notify({
tone: 'info',
message: tRef.current(
'notices.remoteProjectActivationCancelled'
),
dedupeKey: `remote-project-cancelled:${project.id}`
})
return { project, current: false }
}
const message = displayErrorMessage(
reason,
tRef.current('notices.remoteProjectActivationUnknownError')
)
throw new Error(
tRef.current('notices.remoteProjectActivationFailed', {
message
}),
{ cause: reason }
)
} finally {
removeProgress()
if (
remoteProjectActivationControlRef.current === control
) {
remoteProjectActivationControlRef.current = undefined
setRemoteProjectActivation(undefined)
}
}
},
[notify, remoteProjectsEnabled]
)
const cancelRemoteProjectActivation = useCallback((): void => {
const control = remoteProjectActivationControlRef.current
if (!control || control.cancelRequested) {
return
}
control.cancelRequested = true
setRemoteProjectActivation((current) =>
current && current.projectId === control.projectId
? { ...current, cancelling: true }
: current
)
void window.goodbuddy.projects.remote.cancelCurrent().catch(
(reason: unknown) => {
if (
remoteProjectActivationControlRef.current !== control
) {
return
}
control.cancelRequested = false
setRemoteProjectActivation((current) =>
current && current.projectId === control.projectId
? { ...current, cancelling: false }
: current
)
notify({
tone: 'error',
message: tRef.current(
'notices.remoteProjectActivationCancelFailed',
{
message: displayErrorMessage(
reason,
tRef.current(
'notices.remoteProjectActivationCancelUnknownError'
)
)
}
)
})
}
)
}, [notify])
const markRemoteProjectsRequiringActivation = useCallback(
(projectIds: string[]): void => {
const next = new Set(remoteProjectsRequiringActivationRef.current)
let changed = false
for (const projectId of projectIds) {
if (!next.has(projectId)) {
next.add(projectId)
changed = true
}
}
if (!changed) {
return
}
remoteProjectsRequiringActivationRef.current = next
setRemoteProjectsRequiringActivation(next)
},
[]
)
const clearRemoteProjectActivationRequirement = useCallback(
(projectId: string): void => {
if (!remoteProjectsRequiringActivationRef.current.has(projectId)) {
return
}
const next = new Set(remoteProjectsRequiringActivationRef.current)
next.delete(projectId)
remoteProjectsRequiringActivationRef.current = next
setRemoteProjectsRequiringActivation(next)
},
[]
)
const resumeProjectConversationQueues = useCallback(
(projectId: string): void => {
const conversationIds = conversationsRef.current
@@ -5094,57 +4887,6 @@ function App(): React.JSX.Element {
[notify]
)
const handleSshHostUpdated = useCallback(
(hostId: string): void => {
if (!remoteProjectsEnabled) {
return
}
const affectedProjects = projects.filter(
(candidate) =>
candidate.executionSpace.kind === 'ssh' &&
candidate.executionSpace.hostId === hostId
)
markRemoteProjectsRequiringActivation(
affectedProjects.map((project) => project.id)
)
const project = affectedProjects.find(
(candidate) => candidate.id === activeProjectIdRef.current
)
if (!project) {
return
}
void activateRemoteProject(project).then(
(activated) => {
if (!activated.current) {
return
}
clearRemoteProjectActivationRequirement(activated.project.id)
resumeProjectConversationQueues(activated.project.id)
},
(reason: unknown) => {
notify({
tone: 'error',
message:
reason instanceof Error
? reason.message
: tRef.current(
'notices.remoteProjectActivationUnknownError'
)
})
}
)
},
[
activateRemoteProject,
clearRemoteProjectActivationRequirement,
markRemoteProjectsRequiringActivation,
notify,
projects,
remoteProjectsEnabled,
resumeProjectConversationQueues
]
)
useEffect(() => {
let active = true
const initialization = Promise.all([
@@ -5160,7 +4902,6 @@ function App(): React.JSX.Element {
if (!project) {
throw new Error('没有可用的本地项目')
}
remoteProjectActivationRequestRef.current += 1
setActiveProjectId(project.id)
setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode)
@@ -5814,12 +5555,15 @@ function App(): React.JSX.Element {
)
}, [startNewConversation])
const commitProjectSelection = (selected: AssistantProject): void => {
const commitProjectSelection = (
selected: AssistantProject,
candidateConversations = conversationsRef.current
): void => {
setActiveProjectId(selected.id)
setWorkMode(
normalizeInteractiveWorkMode(selected.defaultWorkMode)
)
const conversation = conversationsRef.current.find(
const conversation = candidateConversations.find(
(candidate) =>
candidate.projectId === selected.id &&
(
@@ -5873,39 +5617,20 @@ function App(): React.JSX.Element {
if (!project) {
return
}
if (project.executionSpace.kind !== 'ssh') {
remoteProjectActivationRequestRef.current += 1
commitProjectSelection(project)
if (
project.executionSpace.kind === 'ssh' &&
!remoteProjectsEnabled
) {
return
}
if (!remoteProjectsEnabled) {
return
}
void activateRemoteProject(project).then(
(activated) => {
if (activated.current) {
clearRemoteProjectActivationRequirement(activated.project.id)
commitProjectSelection(activated.project)
resumeProjectConversationQueues(activated.project.id)
}
},
(reason: unknown) => {
notify({
tone: 'error',
message:
reason instanceof Error
? reason.message
: t('notices.remoteProjectActivationUnknownError')
})
}
)
commitProjectSelection(project)
resumeProjectConversationQueues(project.id)
}
const createProject = async (
input: ProjectCreateInput
): Promise<AssistantProject> => {
const project = await window.goodbuddy.projects.create(input)
remoteProjectActivationRequestRef.current += 1
setProjects((current) => [project, ...current])
setActiveProjectId(project.id)
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode))
@@ -5959,9 +5684,7 @@ function App(): React.JSX.Element {
)
: [project, ...current]
)
remoteProjectActivationRequestRef.current += 1
commitProjectSelection(project)
clearRemoteProjectActivationRequirement(project.id)
resumeProjectConversationQueues(project.id)
}
@@ -5972,10 +5695,6 @@ function App(): React.JSX.Element {
if (enabled) {
return
}
remoteProjectActivationRequestRef.current += 1
if (remoteProjectActivationControlRef.current) {
cancelRemoteProjectActivation()
}
if (activeProject?.executionSpace.kind !== 'ssh') {
return
}
@@ -5987,48 +5706,85 @@ function App(): React.JSX.Element {
const archiveProject = async (projectId: string): Promise<void> => {
await window.goodbuddy.projects.setArchived(projectId, true)
clearRemoteProjectActivationRequirement(projectId)
const remaining = projects.filter((project) => project.id !== projectId)
setProjects(remaining)
const next = remoteProjectsEnabled
? remaining[0]
: remaining.find(isOrdinaryLocalProject)
if (next) {
selectProject(next.id)
if (projectId === activeProjectIdRef.current) {
const next =
remaining.find(isOrdinaryLocalProject) ??
(remoteProjectsEnabled ? remaining[0] : undefined)
if (next) {
selectProject(next.id)
}
}
}
const deleteProject = async (
projectId: string,
confirmation: string
): Promise<void> => {
await window.goodbuddy.projects.delete(projectId, confirmation)
clearRemoteProjectActivationRequirement(projectId)
const remainingProjects = projects.filter(
(project) => project.id !== projectId
const removeProjectsFromUi = (
projectIds: readonly string[]
): void => {
if (projectIds.length === 0) {
return
}
const deletedProjectIds = new Set(projectIds)
const referencesDeletedProject = (
projectId: string | undefined
): boolean =>
projectId !== undefined && deletedProjectIds.has(projectId)
const remainingProjects = projectsRef.current.filter(
(project) => !deletedProjectIds.has(project.id)
)
const remainingConversations = conversations.filter(
(conversation) => conversation.projectId !== projectId
const currentConversations = conversationsRef.current
const deletedConversationIds = new Set(
currentConversations
.filter((conversation) =>
referencesDeletedProject(conversation.projectId)
)
.map((conversation) => conversation.id)
)
const remainingConversations = currentConversations.filter(
(conversation) =>
!referencesDeletedProject(conversation.projectId)
)
setProjects((current) =>
current.filter(
(project) => !deletedProjectIds.has(project.id)
)
)
setConversations((current) =>
current.filter(
(conversation) =>
!referencesDeletedProject(conversation.projectId)
)
)
setProjects(remainingProjects)
setConversations(remainingConversations)
setAssistantTasks((current) =>
current.filter((task) => task.projectId !== projectId)
current.filter(
(task) => !referencesDeletedProject(task.projectId)
)
)
setAssistantArtifacts((current) =>
current.filter((artifact) => artifact.projectId !== projectId)
current.filter(
(artifact) =>
!referencesDeletedProject(artifact.projectId)
)
)
setAssistantMemories((current) =>
current.filter(
(memory) =>
!(
memory.scope === 'project' &&
memory.scopeId === projectId
referencesDeletedProject(memory.scopeId)
) &&
!(
memory.scope === 'conversation' &&
memory.scopeId !== undefined &&
deletedConversationIds.has(memory.scopeId)
)
)
)
setAssistantSchedules((current) =>
current.filter((schedule) => schedule.projectId !== projectId)
current.filter(
(schedule) =>
!referencesDeletedProject(schedule.projectId)
)
)
setAssistantHeartbeats((current) =>
current.flatMap((heartbeat) => {
@@ -6036,46 +5792,34 @@ function App(): React.JSX.Element {
return heartbeat
}
const projectIds = heartbeat.scope.projectIds.filter(
(id) => id !== projectId
(id) => !deletedProjectIds.has(id)
)
return projectIds.length > 0
? [{ ...heartbeat, scope: { kind: 'projects', projectIds } }]
: []
})
)
const next = remoteProjectsEnabled
? remainingProjects[0]
: remainingProjects.find(isOrdinaryLocalProject)
if (next) {
setActiveProjectId(next.id)
setWorkMode(
normalizeInteractiveWorkMode(next.defaultWorkMode)
)
const nextConversation = remainingConversations.find(
(conversation) =>
conversation.projectId === next.id &&
(next.kind !== 'channel' ||
conversation.remote !== undefined)
)
if (nextConversation) {
setActiveId(nextConversation.id)
} else if (next.kind === 'channel') {
setActiveId('')
} else {
const created = createConversation(
next.id,
runtimeSettings
? getProjectDefaultRuntimeSelection(next, runtimeSettings)
: undefined,
t('conversation.greeting')
)
setConversations((current) => [created, ...current])
setActiveId(created.id)
}
if (!deletedProjectIds.has(activeProjectIdRef.current)) {
return
}
const next = remainingProjects.find(isOrdinaryLocalProject)
if (next) {
commitProjectSelection(next, remainingConversations)
return
}
setActiveProjectId('')
setActiveId('')
setView('chat')
}
const deleteProject = async (
projectId: string,
confirmation: string
): Promise<void> => {
await window.goodbuddy.projects.delete(projectId, confirmation)
removeProjectsFromUi([projectId])
}
const newConversation = (): boolean => {
return startNewConversation(activeProjectId || undefined)
}
@@ -6590,39 +6334,6 @@ function App(): React.JSX.Element {
await releaseQueuedItem()
return
}
const dispatchProjectId =
queuedInput?.projectId ?? conversationSnapshot.projectId
const dispatchProjectReactivating =
Boolean(
dispatchProjectId &&
remoteProjectActivationControlRef.current?.projectId ===
dispatchProjectId
)
const dispatchProjectRequiresActivation =
Boolean(
dispatchProjectId &&
remoteProjectsRequiringActivationRef.current.has(
dispatchProjectId
)
)
if (
dispatchProjectReactivating ||
dispatchProjectRequiresActivation
) {
if (queuedInput) {
await releaseQueuedItem()
} else {
notify({
tone: 'info',
message: t(
dispatchProjectRequiresActivation
? 'notices.remoteProjectReconnectRequired'
: 'notices.remoteProjectReactivating'
)
})
}
return
}
if (!queuedInput && selectingContextFilesRef.current) {
notify({
tone: 'info',
@@ -7930,7 +7641,6 @@ function App(): React.JSX.Element {
<ProjectSwitcher
activeProjectId={activeProjectId}
onCancelRemoteActivation={cancelRemoteProjectActivation}
runtimeSettings={runtimeSettings}
onArchive={archiveProject}
onCreate={createProject}
@@ -7942,7 +7652,6 @@ function App(): React.JSX.Element {
}
onUpdate={updateProject}
projects={projects}
remoteActivation={remoteProjectActivation}
remoteProjectsEnabled={remoteProjectsEnabled}
/>
@@ -9482,20 +9191,15 @@ function App(): React.JSX.Element {
)
)) ||
selectingContextFiles ||
remoteProjectActivation?.projectId ===
activeProjectId ||
activeRemoteProjectRequiresActivation ||
!runtime?.available ||
runtimeSwitching ||
runtimeStatusKey !== activeRuntimeSelectionKey
}
onClick={() => void submit()}
title={
activeRemoteProjectRequiresActivation
? t('notices.remoteProjectReconnectRequired')
: conversationExecutionRunning
? t('composer.queueMessageTitle')
: t('composer.sendTitle')
conversationExecutionRunning
? t('composer.queueMessageTitle')
: t('composer.sendTitle')
}
>
<Send aria-hidden="true" size={17} />
@@ -10195,7 +9899,7 @@ function App(): React.JSX.Element {
}
onNotify={notify}
onLeaveRequestReady={registerSettingsLeaveRequester}
onSshHostUpdated={handleSshHostUpdated}
onProjectsDeleted={removeProjectsFromUi}
onSaved={(settings) => {
setRuntimeSettings(settings)
}}
+234 -20
View File
@@ -13,7 +13,11 @@ import {
type RuntimeSettings
} from '../../shared/contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
import type { SshDirectoryBrowseResult } from '../../shared/ssh-host-contracts'
import type {
SshDirectoryBrowseResult,
SshHost,
SshHostRemoteEnvironment
} from '../../shared/ssh-host-contracts'
import i18n from './i18n'
import { ProjectSwitcher } from './ProjectSwitcher'
@@ -79,7 +83,9 @@ function renderSwitcher(
} = {}
): {
onCreate: ReturnType<typeof vi.fn>
onDelete: ReturnType<typeof vi.fn>
onRemoteCommitted: ReturnType<typeof vi.fn>
onSelect: ReturnType<typeof vi.fn>
onSelectRoot: ReturnType<typeof vi.fn>
onUpdate: ReturnType<typeof vi.fn>
} {
@@ -93,14 +99,16 @@ function renderSwitcher(
}))
const onRemoteCommitted = vi.fn(async () => undefined)
const onSelectRoot = vi.fn(async () => undefined)
const onDelete = vi.fn(async () => undefined)
const onSelect = vi.fn()
render(
<ProjectSwitcher
activeProjectId={currentProject.id}
onArchive={vi.fn(async () => undefined)}
onCreate={onCreate}
onDelete={vi.fn(async () => undefined)}
onDelete={onDelete}
onRemoteCommitted={onRemoteCommitted}
onSelect={vi.fn()}
onSelect={onSelect}
onSelectRoot={onSelectRoot}
onUpdate={onUpdate}
projects={projects}
@@ -110,7 +118,9 @@ function renderSwitcher(
)
return {
onCreate,
onDelete,
onRemoteCommitted,
onSelect,
onSelectRoot,
onUpdate
}
@@ -134,7 +144,9 @@ function directoryResult(
}
}
function installRemoteApi() {
function installRemoteApi(
options: { hostValidated?: boolean } = {}
) {
let progress: ((value: { phase: 'host' | 'agent' }) => void) | undefined
const savedProject: AssistantProject = {
...project,
@@ -162,24 +174,61 @@ function installRemoteApi() {
])
)
const cancelDirectoryBrowse = vi.fn(async () => undefined)
const hostValidated = options.hostValidated !== false
const host: SshHost = {
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
credentialConfigured: true,
credentialSource: 'system-agent',
hostKey: {
state: hostValidated ? 'verified' : 'unverified',
generation: 1
},
...(hostValidated
? { lastValidatedAt: '2026-08-04T00:00:00.000Z' }
: {}),
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
const getSnapshot = vi.fn(async () => ({
hosts: [
{
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent' as const,
credentialConfigured: true,
credentialSource: 'system-agent' as const,
hostKey: { state: 'verified' as const, generation: 1 },
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
],
hosts: [host],
secureStorageAvailable: true
}))
const currentVersion = {
version: '1.0.0',
architecture: 'x64' as const
}
const getRemoteEnvironment = vi.fn(
async (requestedHostId: string): Promise<SshHostRemoteEnvironment> => ({
hostId: requestedHostId,
checkedAt: '2026-08-24T00:00:00.000Z',
architecture: 'x64',
agent: {
state: 'current',
expected: currentVersion,
installed: currentVersion
},
runtimes: [
{
runtimeId: 'opencode',
provider: 'opencode',
state: 'current',
expected: currentVersion,
installed: currentVersion
}
],
remoteDownload: {
available: true,
source: 'github',
packageSize: 1024
}
})
)
const updateRemoteEnvironment = vi.fn(async () => undefined)
const onSaveProgress = vi.fn(
(listener: (value: { phase: 'host' | 'agent' }) => void) => {
progress = listener
@@ -192,7 +241,9 @@ function installRemoteApi() {
sshHosts: {
browseDirectories,
cancelDirectoryBrowse,
getSnapshot
getSnapshot,
getRemoteEnvironment,
updateRemoteEnvironment
},
projects: {
remote: {
@@ -209,6 +260,8 @@ function installRemoteApi() {
browseDirectories,
cancelDirectoryBrowse,
getSnapshot,
getRemoteEnvironment,
updateRemoteEnvironment,
onSaveProgress,
emit: (phase: 'host' | 'agent') => progress?.({ phase })
}
@@ -386,6 +439,60 @@ describe('ProjectSwitcher managed SSH projects', () => {
expect(api.save).not.toHaveBeenCalled()
})
it('deletes an unreachable remote project from the list without activating it', async () => {
const api = installRemoteApi()
const remoteProject: AssistantProject = {
...project,
id: remoteProjectId,
name: 'Remote project',
rootPath: '/srv/missing',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/missing'
},
runtimeSelection: { provider: 'opencode' }
}
const { onDelete, onSelect } = renderSwitcher(project, {
projects: [project, remoteProject]
})
fireEvent.click(screen.getByLabelText('当前项目'))
fireEvent.click(
screen.getByRole('menuitem', {
name: '管理项目 Remote project'
})
)
const dialog = screen.getByRole('dialog', { name: '项目设置' })
expect(onSelect).not.toHaveBeenCalled()
expect(api.getRemoteEnvironment).not.toHaveBeenCalled()
expect(api.browseDirectories).not.toHaveBeenCalled()
fireEvent.click(
within(dialog).getByRole('button', { name: '删除项目' })
)
fireEvent.change(
within(dialog).getByLabelText(
'输入“Remote project”确认删除'
),
{ target: { value: 'Remote project' } }
)
fireEvent.click(
within(dialog).getByRole('button', {
name: '永久删除项目'
})
)
await waitFor(() =>
expect(onDelete).toHaveBeenCalledWith(
remoteProjectId,
'Remote project'
)
)
expect(onSelect).not.toHaveBeenCalled()
expect(api.save).not.toHaveBeenCalled()
})
it('uses remote work directory copy and opens from a valid typed path', async () => {
await i18n.changeLanguage('en-US')
const api = installRemoteApi()
@@ -855,4 +962,111 @@ describe('ProjectSwitcher managed SSH projects', () => {
)
expect(onUpdate).not.toHaveBeenCalled()
})
it('uses the local validated Host record and defers remote checks to the requested action', async () => {
const api = installRemoteApi()
renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
const dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
expect(
await within(dialog).findByText(
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。'
)
).toBeInTheDocument()
expect(api.getRemoteEnvironment).not.toHaveBeenCalled()
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: 'Ready project' }
})
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: '/srv/project' }
})
expect(
within(dialog).getByRole('button', {
name: '浏览远端工作目录'
})
).toBeEnabled()
fireEvent.click(
within(dialog).getByRole('button', {
name: '保存远程项目'
})
)
await waitFor(() => expect(api.save).toHaveBeenCalledOnce())
expect(api.updateRemoteEnvironment).not.toHaveBeenCalled()
})
it('disables a Host that has not completed local validation', async () => {
const api = installRemoteApi({ hostValidated: false })
const remoteProject: AssistantProject = {
...project,
id: remoteProjectId,
name: 'Remote project',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project'
},
runtimeSelection: { provider: 'opencode' }
}
renderSwitcher(remoteProject)
fireEvent.click(screen.getByLabelText('项目设置'))
const dialog = screen.getByRole('dialog', { name: '项目设置' })
expect(
await within(dialog).findByText(
/此主机尚未完成 Host Key 和连接验证/u
)
).toBeInTheDocument()
expect(within(dialog).getByLabelText('SSH 主机')).toHaveValue(hostId)
expect(
within(dialog).getByRole('option', {
name: /Build host.*需要验证/u
})
).toBeDisabled()
const save = within(dialog).getByRole('button', {
name: '保存远程项目'
})
expect(save).toBeDisabled()
fireEvent.click(save)
expect(api.save).not.toHaveBeenCalled()
expect(api.updateRemoteEnvironment).not.toHaveBeenCalled()
})
it('never probes remote environments when the project form opens or reopens', async () => {
const api = installRemoteApi()
renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
let dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
expect(
await within(dialog).findByText(
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。'
)
).toBeInTheDocument()
expect(api.getRemoteEnvironment).not.toHaveBeenCalled()
fireEvent.click(
within(dialog).getByRole('button', { name: '取消' })
)
fireEvent.click(screen.getByLabelText('新建项目'))
dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.click(
within(dialog).getByRole('button', { name: '托管 SSH' })
)
expect(
await within(dialog).findByText(
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。'
)
).toBeInTheDocument()
expect(api.getSnapshot).toHaveBeenCalledTimes(2)
expect(api.getRemoteEnvironment).not.toHaveBeenCalled()
expect(api.updateRemoteEnvironment).not.toHaveBeenCalled()
})
})
+214 -163
View File
@@ -50,11 +50,6 @@ type ProjectSwitcherProps = {
projects: AssistantProject[]
activeProjectId: string
remoteProjectsEnabled?: boolean
remoteActivation?: {
projectId: string
phase: RemoteProjectSavePhase
cancelling: boolean
}
runtimeSettings?: RuntimeSettings
onArchive: (projectId: string) => Promise<void>
onCreate: (input: ProjectCreateInput) => Promise<AssistantProject>
@@ -62,7 +57,6 @@ type ProjectSwitcherProps = {
onSelect: (projectId: string) => void
onSelectRoot: () => Promise<string | undefined>
onRemoteCommitted: (project: AssistantProject) => Promise<void>
onCancelRemoteActivation?: () => void
onUpdate: (
projectId: string,
input: ProjectCreateInput
@@ -76,20 +70,21 @@ const remoteProjectPhases: readonly RemoteProjectSavePhase[] = [
'runtime',
'saving'
]
type RemoteHostReadiness =
| { status: 'ready' }
| { status: 'unready' }
function RemoteProjectProgress({
cancelling = false,
onCancel,
phase,
phases = remoteProjectPhases,
title
}: {
cancelling?: boolean
onCancel?: () => void
phase: RemoteProjectSavePhase
phases?: readonly RemoteProjectSavePhase[]
title: string
}): React.JSX.Element {
const { t } = useTranslation('workspace')
const phaseIndex = remoteProjectPhases.indexOf(phase)
const phaseIndex = phases.indexOf(phase)
return (
<section
aria-live="polite"
@@ -99,28 +94,26 @@ function RemoteProjectProgress({
<div className="remote-project-progress__header">
<strong>{title}</strong>
<span>
{cancelling
? t('projectSwitcher.remote.activation.cancelling')
: t('projectSwitcher.remote.phaseStatus', {
phase: t(
`projectSwitcher.remote.phases.${phase}`
)
})}
{t('projectSwitcher.remote.phaseStatus', {
phase: t(
`projectSwitcher.remote.phases.${phase}`
)
})}
</span>
</div>
<progress
aria-label={t(
'projectSwitcher.remote.activation.progressLabel'
'projectSwitcher.remote.progress.progressLabel'
)}
max={remoteProjectPhases.length}
max={phases.length}
value={phaseIndex + 1}
/>
<ol
aria-label={t(
'projectSwitcher.remote.activation.stepsLabel'
'projectSwitcher.remote.progress.stepsLabel'
)}
>
{remoteProjectPhases.map((candidate, index) => {
{phases.map((candidate, index) => {
const state =
index < phaseIndex
? 'complete'
@@ -151,18 +144,6 @@ function RemoteProjectProgress({
)
})}
</ol>
{onCancel && (
<button
className="secondary-button"
disabled={cancelling}
onClick={onCancel}
type="button"
>
{cancelling
? t('projectSwitcher.remote.activation.cancellingAction')
: t('projectSwitcher.remote.activation.cancel')}
</button>
)}
</section>
)
}
@@ -175,12 +156,10 @@ export function ProjectSwitcher({
projects,
activeProjectId,
remoteProjectsEnabled = false,
remoteActivation,
runtimeSettings,
onArchive,
onCreate,
onDelete,
onCancelRemoteActivation,
onRemoteCommitted,
onSelect,
onSelectRoot,
@@ -190,6 +169,8 @@ export function ProjectSwitcher({
const [dialogMode, setDialogMode] = useState<
'create' | 'settings'
>()
const [settingsProjectId, setSettingsProjectId] =
useState<string>()
const [saving, setSaving] = useState(false)
const [archiving, setArchiving] = useState(false)
const [deleting, setDeleting] = useState(false)
@@ -201,6 +182,9 @@ export function ProjectSwitcher({
>('local')
const [sshHosts, setSshHosts] = useState<SshHostsSnapshot>()
const [loadingSshHosts, setLoadingSshHosts] = useState(false)
const [remoteHostReadiness, setRemoteHostReadiness] = useState<
Record<string, RemoteHostReadiness>
>({})
const [remoteHostId, setRemoteHostId] = useState('')
const [remoteRootPath, setRemoteRootPath] = useState('')
const [remoteSaving, setRemoteSaving] = useState(false)
@@ -223,7 +207,7 @@ export function ProjectSwitcher({
const projectPickerMenuRef = useRef<HTMLDivElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const restoreFocusTarget = useRef<
'create' | 'settings' | undefined
'create' | 'settings' | 'picker' | undefined
>(undefined)
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
const [draft, setDraft] = useState<ProjectCreateInput>({
@@ -238,6 +222,9 @@ export function ProjectSwitcher({
const activeProjectDisplay = activeProject
? getProjectDisplayText(activeProject, t)
: undefined
const settingsProject = projects.find(
(project) => project.id === settingsProjectId
)
const localProjects = projects.filter(
(project) =>
project.kind === 'user' &&
@@ -259,28 +246,23 @@ export function ProjectSwitcher({
saving ||
remoteSaving ||
archiving ||
deleting ||
(remoteProjectsEnabled && remoteActivation !== undefined)
deleting
const remoteFieldsDisabled = busy
const remoteDirectoryBrowseDisabled =
remoteFieldsDisabled ||
loadingSshHosts ||
!remoteHostId ||
remoteHostReadiness[remoteHostId]?.status !== 'ready' ||
dialogMode === 'settings'
const selectedRemoteHostReadiness =
remoteHostReadiness[remoteHostId]
const remoteDraftReady =
Boolean(
draft.name.trim() &&
remoteHostId &&
selectedRemoteHostReadiness?.status === 'ready' &&
isRemoteAbsolutePath(remoteRootPath)
)
const activatingProject = remoteActivation
? projects.find(
(project) => project.id === remoteActivation.projectId
)
: undefined
const activatingProjectName = activatingProject
? getProjectDisplayText(activatingProject, t).name
: t('projectSwitcher.selector.empty')
useEffect(() => {
if (
@@ -309,11 +291,27 @@ export function ProjectSwitcher({
return
}
setSshHosts(snapshot)
const readiness: Record<string, RemoteHostReadiness> =
Object.fromEntries(
snapshot.hosts.map((host) => [
host.id,
host.hostKey.state === 'verified' &&
host.lastValidatedAt !== undefined
? { status: 'ready' as const }
: { status: 'unready' as const }
])
)
setRemoteHostReadiness(readiness)
setRemoteHostId((current) =>
current &&
snapshot.hosts.some((host) => host.id === current)
? current
: snapshot.hosts[0]?.id ?? ''
: snapshot.hosts.find(
(host) =>
readiness[host.id]?.status === 'ready'
)?.id ??
snapshot.hosts[0]?.id ??
''
)
setLoadingSshHosts(false)
},
@@ -350,7 +348,7 @@ export function ProjectSwitcher({
if (
remoteProjectsEnabled ||
dialogMode !== 'settings' ||
activeProject?.executionSpace.kind !== 'ssh'
settingsProject?.executionSpace.kind !== 'ssh'
) {
return
}
@@ -365,12 +363,13 @@ export function ProjectSwitcher({
return () => {
active = false
}
}, [activeProject, dialogMode, remoteProjectsEnabled])
}, [dialogMode, remoteProjectsEnabled, settingsProject])
const resetRemoteDraft = (): void => {
directoryBrowseRequestRef.current += 1
setSshHosts(undefined)
setLoadingSshHosts(false)
setRemoteHostReadiness({})
setRemoteHostId('')
setRemoteRootPath('')
setRemoteSaving(false)
@@ -444,7 +443,7 @@ export function ProjectSwitcher({
const focusFrame = requestAnimationFrame(() => {
const items = Array.from(
projectPickerMenuRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitemradio"]'
'[role^="menuitem"]'
) ?? []
)
const initialItem =
@@ -489,6 +488,8 @@ export function ProjectSwitcher({
createButtonRef.current?.focus()
} else if (restoreFocusTarget.current === 'settings') {
settingsButtonRef.current?.focus()
} else if (restoreFocusTarget.current === 'picker') {
projectPickerButtonRef.current?.focus()
}
restoreFocusTarget.current = undefined
return
@@ -546,6 +547,53 @@ export function ProjectSwitcher({
setConfirmingDelete(false)
setDeleteConfirmation('')
setDialogMode(undefined)
setSettingsProjectId(undefined)
}
const openProjectSettings = (
project: AssistantProject,
restoreTarget: 'settings' | 'picker'
): void => {
if (
!remoteProjectsEnabled &&
project.executionSpace.kind === 'ssh'
) {
return
}
setProjectMenuOpen(false)
setError(undefined)
setConfirmingDelete(false)
setDeleteConfirmation('')
resetRemoteDraft()
setSettingsProjectId(project.id)
const nextExecutionSpaceKind =
project.executionSpace.kind === 'ssh' ? 'ssh' : 'local'
setExecutionSpaceKind(nextExecutionSpaceKind)
if (project.executionSpace.kind === 'ssh') {
setLoadingSshHosts(true)
setRemoteHostId(project.executionSpace.hostId)
setRemoteRootPath(project.executionSpace.remoteRootPath)
}
const nextDraft: ProjectCreateInput = {
name: project.name,
description: project.description,
rootPath: project.rootPath,
defaultWorkMode: normalizeInteractiveWorkMode(
project.defaultWorkMode
),
runtimeSelection:
project.runtimeSelection ??
(runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
: undefined)
}
setDraft(
project.kind === 'channel' && runtimeSettings
? channelProjectDraft(nextDraft, runtimeSettings)
: nextDraft
)
restoreFocusTarget.current = restoreTarget
setDialogMode('settings')
}
const validateRemoteDraft = (): boolean => {
@@ -553,6 +601,12 @@ export function ProjectSwitcher({
setError(t('projectSwitcher.remote.validation.host'))
return false
}
if (remoteHostReadiness[remoteHostId]?.status !== 'ready') {
setError(
t('projectSwitcher.remote.readiness.saveBlocked')
)
return false
}
if (!isRemoteAbsolutePath(remoteRootPath)) {
setError(t('projectSwitcher.remote.validation.root'))
return false
@@ -588,12 +642,12 @@ export function ProjectSwitcher({
}
const result =
await window.goodbuddy.projects.remote.save(
dialogMode === 'settings' && activeProject
dialogMode === 'settings' && settingsProject
? {
intent: 'update',
draft: {
...modeDraft,
projectId: activeProject.id
projectId: settingsProject.id
}
}
: {
@@ -627,8 +681,8 @@ export function ProjectSwitcher({
...draft,
runtimeSelection: getDefaultRuntimeSelection(runtimeSettings)
}
if (dialogMode === 'settings' && activeProject) {
await onUpdate(activeProject.id, input)
if (dialogMode === 'settings' && settingsProject) {
await onUpdate(settingsProject.id, input)
} else {
await onCreate(input)
}
@@ -668,10 +722,13 @@ export function ProjectSwitcher({
}
const archive = async (): Promise<void> => {
if (!settingsProject) {
return
}
setArchiving(true)
setError(undefined)
try {
await onArchive(activeProjectId)
await onArchive(settingsProject.id)
closeDialog()
} catch (reason) {
setError(
@@ -686,13 +743,13 @@ export function ProjectSwitcher({
}
const deleteProject = async (): Promise<void> => {
if (!activeProject) {
if (!settingsProject) {
return
}
setDeleting(true)
setError(undefined)
try {
await onDelete(activeProject.id, deleteConfirmation)
await onDelete(settingsProject.id, deleteConfirmation)
closeDialog()
} catch (reason) {
setError(
@@ -718,9 +775,6 @@ export function ProjectSwitcher({
aria-haspopup="menu"
aria-label={t('projectSwitcher.selector.ariaLabel')}
className="project-switcher__trigger"
disabled={
remoteProjectsEnabled && remoteActivation !== undefined
}
onClick={() => setProjectMenuOpen((open) => !open)}
onKeyDown={(event) => {
if (
@@ -750,7 +804,7 @@ export function ProjectSwitcher({
onKeyDown={(event) => {
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[role="menuitemradio"]'
'[role^="menuitem"]'
)
)
const currentIndex = items.indexOf(
@@ -775,9 +829,11 @@ export function ProjectSwitcher({
nextIndex === undefined ? undefined : items.at(nextIndex)
if (nextItem) {
event.preventDefault()
items.forEach((item) => {
item.tabIndex = item === nextItem ? 0 : -1
})
const currentItem = items.at(currentIndex)
if (currentItem) {
currentItem.tabIndex = -1
}
nextItem.tabIndex = 0
nextItem.focus()
}
}}
@@ -842,34 +898,53 @@ export function ProjectSwitcher({
{ path: project.rootPath }
)
return (
<button
aria-checked={selected}
<div
className="project-switcher__menu-item"
key={project.id}
onClick={() => {
if (
!selected ||
project.executionSpace.kind === 'ssh'
) {
onSelect(project.id)
}
setProjectMenuOpen(false)
requestAnimationFrame(() =>
projectPickerButtonRef.current?.focus()
)
}}
role="menuitemradio"
tabIndex={selected ? 0 : -1}
type="button"
>
<ProjectIcon aria-hidden="true" size={16} />
<span>
<b>{projectDisplay.name}</b>
<small>{detail}</small>
</span>
{selected && (
<Check aria-hidden="true" size={14} />
)}
</button>
<button
aria-checked={selected}
onClick={() => {
if (
!selected ||
project.executionSpace.kind === 'ssh'
) {
onSelect(project.id)
}
setProjectMenuOpen(false)
requestAnimationFrame(() =>
projectPickerButtonRef.current?.focus()
)
}}
role="menuitemradio"
tabIndex={selected ? 0 : -1}
type="button"
>
<ProjectIcon aria-hidden="true" size={16} />
<span>
<b>{projectDisplay.name}</b>
<small>{detail}</small>
</span>
{selected && (
<Check aria-hidden="true" size={14} />
)}
</button>
<button
aria-label={t(
'projectSwitcher.selector.settingsNamed',
{ name: projectDisplay.name }
)}
className="project-switcher__menu-settings"
onClick={() =>
openProjectSettings(project, 'picker')
}
role="menuitem"
tabIndex={-1}
type="button"
>
<Settings aria-hidden="true" size={14} />
</button>
</div>
)
})}
</div>
@@ -881,14 +956,12 @@ export function ProjectSwitcher({
<button
aria-label={t('projectSwitcher.selector.create')}
className="icon-button"
disabled={
remoteProjectsEnabled && remoteActivation !== undefined
}
onClick={() => {
setProjectMenuOpen(false)
setError(undefined)
setConfirmingDelete(false)
setDeleteConfirmation('')
setSettingsProjectId(undefined)
resetRemoteDraft()
setExecutionSpaceKind('local')
setDraft({
@@ -913,7 +986,6 @@ export function ProjectSwitcher({
className="icon-button"
disabled={
!activeProject ||
(remoteProjectsEnabled && remoteActivation !== undefined) ||
(!remoteProjectsEnabled &&
activeProject.executionSpace.kind === 'ssh')
}
@@ -921,49 +993,7 @@ export function ProjectSwitcher({
if (!activeProject) {
return
}
if (
!remoteProjectsEnabled &&
activeProject.executionSpace.kind === 'ssh'
) {
return
}
setProjectMenuOpen(false)
setError(undefined)
setConfirmingDelete(false)
setDeleteConfirmation('')
resetRemoteDraft()
const nextExecutionSpaceKind =
activeProject.executionSpace.kind === 'ssh'
? 'ssh'
: 'local'
setExecutionSpaceKind(nextExecutionSpaceKind)
if (activeProject.executionSpace.kind === 'ssh') {
setLoadingSshHosts(true)
setRemoteHostId(activeProject.executionSpace.hostId)
setRemoteRootPath(
activeProject.executionSpace.remoteRootPath
)
}
const nextDraft: ProjectCreateInput = {
name: activeProject.name,
description: activeProject.description,
rootPath: activeProject.rootPath,
defaultWorkMode: normalizeInteractiveWorkMode(
activeProject.defaultWorkMode
),
runtimeSelection:
activeProject.runtimeSelection ??
(runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
: undefined)
}
setDraft(
activeProject.kind === 'channel' && runtimeSettings
? channelProjectDraft(nextDraft, runtimeSettings)
: nextDraft
)
restoreFocusTarget.current = 'settings'
setDialogMode('settings')
openProjectSettings(activeProject, 'settings')
}}
ref={settingsButtonRef}
type="button"
@@ -971,17 +1001,6 @@ export function ProjectSwitcher({
<Settings size={15} />
</button>
</div>
{remoteProjectsEnabled && remoteActivation && (
<RemoteProjectProgress
cancelling={remoteActivation.cancelling}
onCancel={onCancelRemoteActivation}
phase={remoteActivation.phase}
title={t(
'projectSwitcher.remote.activation.title',
{ project: activatingProjectName }
)}
/>
)}
{dialogMode && (
<div
className="project-create-backdrop"
@@ -1019,7 +1038,7 @@ export function ProjectSwitcher({
</button>
</header>
{dialogMode === 'settings' &&
activeProject?.kind === 'channel' &&
settingsProject?.kind === 'channel' &&
runtimeSettings ? (
<ChannelProjectSettingsFields
autoFocus
@@ -1210,9 +1229,10 @@ export function ProjectSwitcher({
loadingSshHosts ||
dialogMode === 'settings'
}
onChange={(event) =>
onChange={(event) => {
setError(undefined)
setRemoteHostId(event.target.value)
}
}}
value={remoteHostId}
>
{loadingSshHosts && (
@@ -1230,16 +1250,47 @@ export function ProjectSwitcher({
)}
</option>
)}
{sshHosts?.hosts.map((host) => (
<option key={host.id} value={host.id}>
{host.name} · {host.username}@
{host.hostname}
</option>
))}
{sshHosts?.hosts.map((host) => {
const readiness =
remoteHostReadiness[host.id]
const readinessLabel = readiness
? t(
`projectSwitcher.remote.readiness.options.${readiness.status}`
)
: t(
'projectSwitcher.remote.readiness.options.loading'
)
return (
<option
disabled={readiness?.status !== 'ready'}
key={host.id}
value={host.id}
>
{host.name} · {host.username}@
{host.hostname} · {readinessLabel}
</option>
)
})}
</select>
<small>
{t('projectSwitcher.remote.hostHelp')}
</small>
{remoteHostId &&
selectedRemoteHostReadiness && (
<small
aria-live="polite"
role={
selectedRemoteHostReadiness.status ===
'unready'
? 'alert'
: 'status'
}
>
{t(
`projectSwitcher.remote.readiness.${selectedRemoteHostReadiness.status}`
)}
</small>
)}
</label>
<label>
<span>
@@ -1528,7 +1579,7 @@ export function ProjectSwitcher({
</p>
)}
{dialogMode === 'settings' &&
activeProject?.kind !== 'channel' && (
settingsProject?.kind !== 'channel' && (
<section
aria-labelledby="project-danger-title"
className="project-danger-zone"
@@ -1561,7 +1612,7 @@ export function ProjectSwitcher({
<span>
{t(
'projectSwitcher.dialog.danger.confirmation',
{ projectName: activeProject?.name }
{ projectName: settingsProject?.name }
)}
</span>
<input
@@ -1590,7 +1641,7 @@ export function ProjectSwitcher({
className="danger-button"
disabled={
busy ||
deleteConfirmation !== activeProject?.name
deleteConfirmation !== settingsProject?.name
}
onClick={() => void deleteProject()}
type="button"
@@ -1616,9 +1667,9 @@ export function ProjectSwitcher({
)}
<div className="project-create-card__actions">
{dialogMode === 'settings' &&
activeProject?.kind !== 'channel' &&
settingsProject?.kind !== 'channel' &&
userProjects.length > 1 &&
activeProjectId && (
settingsProject && (
<button
className="secondary-button"
disabled={busy}
+4 -1
View File
@@ -862,7 +862,10 @@ describe('SettingsPanel runtime files', () => {
onAgentPackageProgress,
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(async () => undefined),
remove: vi.fn(async (hostId: string) => ({
hostId,
deletedProjects: []
})),
inspectHostKey: vi.fn(),
acceptHostKey: vi.fn(),
testConnection: vi.fn()
+3 -3
View File
@@ -211,7 +211,7 @@ type SettingsPanelProps = {
initialCategory?: SettingsCategoryId
initialChannel?: ProjectChannel
onClose: () => void
onSshHostUpdated?: (hostId: string) => void
onProjectsDeleted?: (projectIds: string[]) => void
onSaved: (settings: RuntimeSettings) => void
onNotify?: (notification: AppNotificationInput) => void
onUpdateProject: (
@@ -585,7 +585,7 @@ export function SettingsPanel({
initialCategory,
initialChannel,
onClose,
onSshHostUpdated = () => {},
onProjectsDeleted = () => {},
onSaved,
onNotify = () => {},
onUpdateProject,
@@ -3759,8 +3759,8 @@ export function SettingsPanel({
{remoteProjectsEnabled && activeTab === 'ssh-hosts' && (
<SshHostsSettingsSection
onDirtyChange={setSshHostsDirty}
onHostUpdated={onSshHostUpdated}
onNotify={onNotify}
onProjectsDeleted={onProjectsDeleted}
/>
)}
+458 -63
View File
@@ -8,9 +8,12 @@ import {
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantProject } from '../../shared/assistant-contracts'
import type {
RemoteEnvironmentPreparationMethod,
RemoteEnvironmentUpdateProgress,
SshHost,
SshHostRemovalResult,
SshHostRemoteEnvironment,
SshHostValidationResult,
SshHostsSnapshot
@@ -67,14 +70,20 @@ const validationResult: SshHostValidationResult = {
}
const getSnapshot = vi.fn<() => Promise<SshHostsSnapshot>>()
const remove = vi.fn()
const remove =
vi.fn<(hostId: string) => Promise<SshHostRemovalResult>>()
const inspectDraftHostKey = vi.fn()
const discardCandidate = vi.fn()
const validateAndSave = vi.fn()
const getRemoteEnvironment =
vi.fn<(hostId: string) => Promise<SshHostRemoteEnvironment>>()
const updateRemoteEnvironment =
vi.fn<(hostId: string) => Promise<void>>()
vi.fn<
(input: {
hostId: string
method: RemoteEnvironmentPreparationMethod
}) => Promise<void>
>()
const cancelRemoteEnvironmentUpdate =
vi.fn<(hostId: string) => Promise<void>>()
let remoteEnvironmentUpdateProgressListener:
@@ -117,7 +126,12 @@ const remoteEnvironment: SshHostRemoteEnvironment = {
version: '1.18.9',
architecture: 'x64'
}
}]
}],
remoteDownload: {
available: true,
source: 'mirror',
packageSize: 64 * 1024 * 1024
}
}
const currentRemoteEnvironment: SshHostRemoteEnvironment = {
@@ -129,6 +143,15 @@ const currentRemoteEnvironment: SshHostRemoteEnvironment = {
}
}
const notInstalledRemoteEnvironment: SshHostRemoteEnvironment = {
...remoteEnvironment,
agent: {
...remoteEnvironment.agent,
state: 'not-installed',
installed: null
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
@@ -181,12 +204,28 @@ async function inspectAndConfirmFirstKey(): Promise<void> {
fireEvent.click(continueButton)
}
async function refreshHostEnvironment(
name = 'Build host'
): Promise<HTMLElement> {
const host = await screen.findByRole('region', { name })
fireEvent.click(
within(host).getByRole('button', {
name: `刷新 ${name} 的远程运行环境版本`
})
)
await within(host).findByText('GoodBuddy Agent')
return host
}
describe('SshHostsSettingsSection', () => {
beforeEach(async () => {
await changeUiLocale('zh-CN')
vi.clearAllMocks()
getSnapshot.mockResolvedValue(emptySnapshot)
remove.mockResolvedValue(undefined)
remove.mockResolvedValue({
hostId,
deletedProjects: []
})
inspectDraftHostKey.mockResolvedValue({
candidateId,
state: 'unverified',
@@ -484,12 +523,12 @@ describe('SshHostsSettingsSection', () => {
<SshHostsSettingsSection onHostUpdated={onHostUpdated} />
)
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
)
getRemoteEnvironment.mockClear()
await screen.findByRole('button', {
name: '编辑 Build host'
})
expect(getRemoteEnvironment).not.toHaveBeenCalled()
fireEvent.click(
await screen.findByRole('button', {
screen.getByRole('button', {
name: '编辑 Build host'
})
)
@@ -572,6 +611,79 @@ describe('SshHostsSettingsSection', () => {
expect(inspectButton).toHaveFocus()
})
it('lists and locally removes related projects with a deleted Host', async () => {
const remoteProject: AssistantProject = {
id: '00000000-0000-4000-8000-000000000106',
name: '无法连接的远程项目',
description: '',
rootPath: '/srv/missing',
executionSpace: {
kind: 'ssh',
hostId,
remoteRootPath: '/srv/missing'
},
defaultWorkMode: 'ask',
runtimeSelection: { provider: 'opencode' },
kind: 'user',
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
getSnapshot.mockResolvedValue({
...verifiedSnapshot,
projectReferences: {
[hostId]: [
{ id: remoteProject.id, name: remoteProject.name }
]
}
})
remove.mockResolvedValue({
hostId,
deletedProjects: [
{ id: remoteProject.id, name: remoteProject.name }
]
})
const onProjectsDeleted = vi.fn()
render(
<SshHostsSettingsSection
onProjectsDeleted={onProjectsDeleted}
/>
)
const host = await screen.findByRole('region', {
name: 'Build host'
})
fireEvent.click(
within(host).getByRole('button', { name: '删除' })
)
const confirmation = screen.getByRole('alertdialog')
expect(
within(confirmation).getByText('同时删除以下关联项目记录:')
).toBeInTheDocument()
expect(
within(confirmation).getByText(remoteProject.name)
).toBeInTheDocument()
expect(confirmation).toHaveTextContent(
'不会连接主机,也不会删除远端目录或内容'
)
fireEvent.click(
within(confirmation).getByRole('button', {
name: '确认删除'
})
)
await waitFor(() =>
expect(remove).toHaveBeenCalledWith(hostId)
)
expect(onProjectsDeleted).toHaveBeenCalledWith([
remoteProject.id
])
expect(
screen.queryByRole('region', { name: 'Build host' })
).not.toBeInTheDocument()
expect(getRemoteEnvironment).not.toHaveBeenCalled()
})
it('offers existing unverified hosts a validate action', async () => {
getSnapshot.mockResolvedValue({
hosts: [legacyUnverifiedHost],
@@ -595,17 +707,74 @@ describe('SshHostsSettingsSection', () => {
expect(
await screen.findByText('远程运行环境')
).toBeInTheDocument()
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledWith(hostId)
)
const host = screen.getByRole('region', { name: 'Build host' })
expect(within(host).getByText('GoodBuddy Agent')).toBeInTheDocument()
expect(within(host).getByText('OpenCode Runtime')).toBeInTheDocument()
expect(getRemoteEnvironment).not.toHaveBeenCalled()
expect(
screen.getByText(
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
)
).toBeInTheDocument()
const host = await refreshHostEnvironment()
expect(getRemoteEnvironment).toHaveBeenCalledWith(hostId)
expect(getRemoteEnvironment).toHaveBeenCalledTimes(1)
expect(
within(host).queryByText(
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
)
).not.toBeInTheDocument()
expect(
within(host).getByText('GoodBuddy Agent')
).toBeInTheDocument()
expect(
within(host).getByText('OpenCode Runtime')
).toBeInTheDocument()
expect(within(host).getByText('待更新')).toBeInTheDocument()
expect(within(host).getByText('当前版本')).toBeInTheDocument()
expect(within(host).getByText('版本匹配')).toBeInTheDocument()
expect(within(host).getByText('0.10.4')).toBeInTheDocument()
expect(within(host).getByText('0.11.1')).toBeInTheDocument()
expect(within(host).getAllByText('1.18.9')).toHaveLength(2)
expect(within(host).getByText('安装方式')).toBeInTheDocument()
expect(
within(host)
.getByRole('button', {
name: '刷新 Build host 的远程运行环境版本'
})
.closest('.ssh-host-environment__header')
).not.toBeNull()
expect(
within(host)
.getByRole('button', {
name: '为 Build host 更新远程环境'
})
.closest('.ssh-host-environment__toolbar')
).not.toBeNull()
})
it('does not contact any verified Host when the section opens', async () => {
getSnapshot.mockResolvedValue({
hosts: [
verifiedHost,
{
...verifiedHost,
id: candidateId,
name: 'Deploy host',
hostname: 'deploy.example.com'
}
],
secureStorageAvailable: true
})
render(<SshHostsSettingsSection />)
await screen.findByRole('region', { name: 'Deploy host' })
expect(getRemoteEnvironment).not.toHaveBeenCalled()
expect(
screen.getAllByText(
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
)
).toHaveLength(2)
await refreshHostEnvironment()
expect(getRemoteEnvironment).toHaveBeenCalledTimes(1)
expect(getRemoteEnvironment).toHaveBeenCalledWith(hostId)
})
it('keeps Host details visible and retries a failed version refresh', async () => {
@@ -615,6 +784,14 @@ describe('SshHostsSettingsSection', () => {
.mockResolvedValueOnce(remoteEnvironment)
render(<SshHostsSettingsSection />)
await screen.findByText(
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
)
fireEvent.click(
screen.getByRole('button', {
name: '刷新 Build host 的远程运行环境版本'
})
)
expect(
await screen.findByText('Host unreachable')
).toBeInTheDocument()
@@ -630,22 +807,196 @@ describe('SshHostsSettingsSection', () => {
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
})
it('places Update immediately before Refresh and disables it when versions are current', async () => {
it.each([
[notInstalledRemoteEnvironment, '为 Build host 安装远程环境'],
[remoteEnvironment, '为 Build host 更新远程环境'],
[currentRemoteEnvironment, '为 Build host 重新安装远程环境']
])('names the single main environment action from version facts', async (
environment,
actionName
) => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
getRemoteEnvironment.mockResolvedValue(currentRemoteEnvironment)
getRemoteEnvironment.mockResolvedValue(environment)
render(<SshHostsSettingsSection />)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
const host = screen.getByRole('region', { name: 'Build host' })
const updateButton = within(host).getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
const host = await refreshHostEnvironment()
expect(
within(host).getByRole('button', { name: actionName })
).toBeEnabled()
expect(
within(host).getAllByRole('button', {
name: /为 Build host (?:安装|更新|重新安装)远程环境/u
})
).toHaveLength(1)
})
it('defaults to auto, supports arrow-key selection, and requests all three methods', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
render(<SshHostsSettingsSection />)
const host = await refreshHostEnvironment()
let automatic = within(host).getByRole('button', {
name: '自动'
})
const refreshButton = within(host).getByRole('button', {
name: '刷新 Build host 的远程运行环境版本'
let hostDownload: HTMLElement
let mainAction = within(host).getByRole('button', {
name: '为 Build host 更新远程环境'
})
expect(updateButton.nextElementSibling).toBe(refreshButton)
expect(updateButton).toBeDisabled()
expect(updateRemoteEnvironment).not.toHaveBeenCalled()
expect(automatic).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(mainAction)
await waitFor(() =>
expect(updateRemoteEnvironment).toHaveBeenLastCalledWith({
hostId,
method: 'auto'
})
)
await waitFor(() =>
expect(
within(host).getByRole('button', { name: '自动' })
).toBeEnabled()
)
automatic = within(host).getByRole('button', { name: '自动' })
hostDownload = within(host).getByRole('button', {
name: 'Host 下载'
})
mainAction = within(host).getByRole('button', {
name: '为 Build host 更新远程环境'
})
automatic.focus()
fireEvent.keyDown(automatic, { key: 'ArrowRight' })
expect(hostDownload).toHaveFocus()
expect(hostDownload).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(mainAction)
await waitFor(() =>
expect(updateRemoteEnvironment).toHaveBeenLastCalledWith({
hostId,
method: 'remote-download'
})
)
await waitFor(() =>
expect(
within(host).getByRole('button', { name: 'Host 下载' })
).toBeEnabled()
)
hostDownload = within(host).getByRole('button', {
name: 'Host 下载'
})
const goodbuddyTransfer = within(host).getByRole('button', {
name: 'GoodBuddy 传输'
})
mainAction = within(host).getByRole('button', {
name: '为 Build host 更新远程环境'
})
fireEvent.keyDown(hostDownload, { key: 'ArrowRight' })
expect(goodbuddyTransfer).toHaveFocus()
expect(goodbuddyTransfer).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(mainAction)
await waitFor(() =>
expect(updateRemoteEnvironment).toHaveBeenLastCalledWith({
hostId,
method: 'goodbuddy-transfer'
})
)
expect(updateRemoteEnvironment).toHaveBeenCalledTimes(3)
})
it('disables only an explicitly unavailable Host download method', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
getRemoteEnvironment.mockResolvedValue({
...remoteEnvironment,
remoteDownload: {
available: false,
source: 'mirror',
packageSize: 64 * 1024 * 1024,
reason: 'source-unreachable'
}
})
render(<SshHostsSettingsSection />)
const host = await refreshHostEnvironment()
const mainAction = within(host).getByRole('button', {
name: '为 Build host 更新远程环境'
})
expect(mainAction).toBeEnabled()
fireEvent.click(
within(host).getByRole('button', { name: 'Host 下载' })
)
expect(mainAction).toBeDisabled()
fireEvent.click(
within(host).getByRole('button', {
name: 'GoodBuddy 传输'
})
)
expect(mainAction).toBeEnabled()
expect(host).toHaveTextContent(
'远程主机无法连接所选下载源(镜像节点)'
)
})
it('allows a direct retry when its capability check fails', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
getRemoteEnvironment.mockResolvedValue({
...currentRemoteEnvironment,
remoteDownload: {
available: false,
source: 'mirror',
packageSize: 64 * 1024 * 1024,
reason: 'probe-failed'
}
})
render(<SshHostsSettingsSection />)
const host = await refreshHostEnvironment()
fireEvent.click(
within(host).getByRole('button', { name: 'Host 下载' })
)
const directRetry = within(host).getByRole('button', {
name: '为 Build host 重新安装远程环境'
})
expect(directRetry).toBeEnabled()
expect(host).toHaveTextContent(
'可直接重试由远程主机安装'
)
fireEvent.click(directRetry)
await waitFor(() =>
expect(updateRemoteEnvironment).toHaveBeenCalledWith({
hostId,
method: 'remote-download'
})
)
})
it('does not fall back after an explicit Host-download failure', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
updateRemoteEnvironment.mockRejectedValueOnce(
new Error('Remote source timed out')
)
render(<SshHostsSettingsSection />)
const host = await refreshHostEnvironment()
fireEvent.click(
within(host).getByRole('button', { name: 'Host 下载' })
)
const directButton = within(host).getByRole('button', {
name: '为 Build host 更新远程环境'
})
fireEvent.click(directButton)
expect(updateRemoteEnvironment).toHaveBeenCalledOnce()
expect(updateRemoteEnvironment).toHaveBeenCalledWith({
hostId,
method: 'remote-download'
})
expect(await screen.findByRole('alert')).toHaveTextContent(
'Remote source timed out'
)
expect(updateRemoteEnvironment).toHaveBeenCalledTimes(1)
})
it('updates with phase progress, preserves cards, refreshes, and reports success once', async () => {
@@ -661,16 +1012,18 @@ describe('SshHostsSettingsSection', () => {
/>
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
const host = screen.getByRole('region', { name: 'Build host' })
const host = await refreshHostEnvironment()
fireEvent.click(
within(host).getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
name: '为 Build host 更新远程环境'
})
)
expect(updateRemoteEnvironment).toHaveBeenCalledOnce()
expect(updateRemoteEnvironment).toHaveBeenCalledWith(hostId)
expect(updateRemoteEnvironment).toHaveBeenCalledWith({
hostId,
method: 'auto'
})
expect(within(host).getByText('GoodBuddy Agent')).toBeInTheDocument()
expect(within(host).getByText('OpenCode Runtime')).toBeInTheDocument()
expect(
@@ -678,40 +1031,61 @@ describe('SshHostsSettingsSection', () => {
name: '刷新 Build host 的远程运行环境版本'
})
).toBeDisabled()
expect(
within(host).queryByRole('button', {
name: '为 Build host 更新远程环境'
})
).not.toBeInTheDocument()
expect(
within(host).queryByRole('button', { name: '自动' })
).not.toBeInTheDocument()
expect(within(host).getByRole('status')).toHaveTextContent('自动')
expect(within(host).getByRole('status')).toHaveTextContent(
'正在准备更新'
'正在准备安装'
)
act(() => {
remoteEnvironmentUpdateProgressListener?.({
hostId: candidateId,
phase: 'agent'
method: 'goodbuddy-transfer',
phase: 'installing-agent'
})
})
expect(within(host).getByRole('status')).toHaveTextContent('自动')
act(() => {
remoteEnvironmentUpdateProgressListener?.({
hostId,
method: 'remote-download',
phase: 'downloading'
})
})
expect(within(host).getByRole('status')).toHaveTextContent(
'正在准备更新'
'Host 下载'
)
act(() => {
remoteEnvironmentUpdateProgressListener?.({
hostId,
phase: 'agent'
method: 'remote-download',
phase: 'installing-agent'
})
})
expect(within(host).getByRole('status')).toHaveTextContent(
'正在更新 GoodBuddy Agent'
'正在安装 GoodBuddy Agent'
)
act(() => {
remoteEnvironmentUpdateProgressListener?.({
hostId,
phase: 'runtime'
method: 'remote-download',
phase: 'installing-runtime'
})
})
expect(within(host).getByRole('status')).toHaveTextContent(
'正在更新 Runtime'
'正在安装 Runtime'
)
act(() => {
remoteEnvironmentUpdateProgressListener?.({
hostId,
method: 'remote-download',
phase: 'finalizing'
})
})
@@ -759,13 +1133,16 @@ describe('SshHostsSettingsSection', () => {
/>
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
await refreshHostEnvironment()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
name: '为 Build host 更新远程环境'
})
)
expect(updateRemoteEnvironment).toHaveBeenCalledWith(hostId)
expect(updateRemoteEnvironment).toHaveBeenCalledWith({
hostId,
method: 'auto'
})
unmount()
await act(async () => {
@@ -791,10 +1168,10 @@ describe('SshHostsSettingsSection', () => {
/>
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
await refreshHostEnvironment()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
name: '为 Build host 更新远程环境'
})
)
unmount()
@@ -830,18 +1207,13 @@ describe('SshHostsSettingsSection', () => {
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
render(<SshHostsSettingsSection />)
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
)
const buildHost = screen.getByRole('region', {
name: 'Build host'
})
const deployHost = screen.getByRole('region', {
name: 'Deploy host'
})
const buildHost = await refreshHostEnvironment()
const deployHost =
await refreshHostEnvironment('Deploy host')
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
fireEvent.click(
within(buildHost).getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
name: '为 Build host 更新远程环境'
})
)
@@ -855,7 +1227,7 @@ describe('SshHostsSettingsSection', () => {
).toBeDisabled()
expect(
within(deployHost).getByRole('button', {
name: '更新 Deploy host 的远程运行环境版本'
name: '为 Deploy host 更新远程环境'
})
).toBeDisabled()
@@ -884,10 +1256,10 @@ describe('SshHostsSettingsSection', () => {
/>
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
await refreshHostEnvironment()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
name: '为 Build host 更新远程环境'
})
)
@@ -900,13 +1272,36 @@ describe('SshHostsSettingsSection', () => {
)
expect(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
name: '为 Build host 重新安装远程环境'
})
).toBeEnabled()
expect(onHostUpdated).not.toHaveBeenCalled()
expect(onNotify).not.toHaveBeenCalled()
})
it('combines a matched-version reinstall failure summary with its detail in one alert', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
getRemoteEnvironment.mockResolvedValue(currentRemoteEnvironment)
updateRemoteEnvironment.mockRejectedValue(
new Error('Runtime finalization failed')
)
render(<SshHostsSettingsSection />)
await refreshHostEnvironment()
fireEvent.click(
screen.getByRole('button', {
name: '为 Build host 重新安装远程环境'
})
)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(
'本次重新安装未完成;正在重新检查当前版本。'
)
expect(alert).toHaveTextContent('Runtime finalization failed')
expect(screen.getAllByRole('alert')).toHaveLength(1)
})
it('cancels an update, stays disabled while cancelling, and refreshes after settlement', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
const updateOperation = deferred<void>()
@@ -920,10 +1315,10 @@ describe('SshHostsSettingsSection', () => {
/>
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
await refreshHostEnvironment()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
name: '为 Build host 更新远程环境'
})
)
const cancelButton = screen.getByRole('button', {
@@ -963,10 +1358,10 @@ describe('SshHostsSettingsSection', () => {
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
render(<SshHostsSettingsSection />)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
await refreshHostEnvironment()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
name: '为 Build host 更新远程环境'
})
)
fireEvent.click(
@@ -1002,10 +1397,10 @@ describe('SshHostsSettingsSection', () => {
)
render(<SshHostsSettingsSection />)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
await refreshHostEnvironment()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
name: '为 Build host 更新远程环境'
})
)
const cancelButton = screen.getByRole('button', {
+314 -139
View File
@@ -19,6 +19,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
RemoteEnvironmentPreparationMethod,
RemoteEnvironmentUpdatePhase,
SshHost,
SshHostConnectionTestResult,
@@ -32,13 +33,15 @@ import { displayErrorMessage } from './error-message'
import { SshHostDialog } from './SshHostDialog'
import {
DestructiveConfirmActions,
EmptyState
EmptyState,
SegmentedControl
} from './WorkspacePrimitives'
type SshHostsSettingsSectionProps = {
onDirtyChange?: (dirty: boolean) => void
onHostUpdated?: (hostId: string) => void
onNotify?: (notification: AppNotificationInput) => void
onProjectsDeleted?: (projectIds: string[]) => void
}
type RemoteEnvironmentLoadState = {
@@ -49,6 +52,8 @@ type RemoteEnvironmentLoadState = {
type RemoteEnvironmentUpdateState = {
hostId: string
requestedMethod: RemoteEnvironmentPreparationMethod
resolvedMethod?: RemoteEnvironmentPreparationMethod
requestId: number
phase?: RemoteEnvironmentUpdatePhase
cancelling: boolean
@@ -56,11 +61,17 @@ type RemoteEnvironmentUpdateState = {
type ActiveRemoteEnvironmentUpdate = {
hostId: string
requestedMethod: RemoteEnvironmentPreparationMethod
requestId: number
cancelRequested: boolean
cancelError?: string
}
type RemoteEnvironmentUpdateError = {
detail: string
reinstallAttempt: boolean
}
function credentialDescription(
host: SshHost,
t: ReturnType<typeof useTranslation<'settingsSections'>>['t']
@@ -81,12 +92,15 @@ function upsertHost(hosts: SshHost[], host: SshHost): SshHost[] {
export function SshHostsSettingsSection({
onDirtyChange,
onHostUpdated,
onNotify
onNotify,
onProjectsDeleted
}: SshHostsSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const [hosts, setHosts] = useState<SshHost[]>([])
const [secureStorageAvailable, setSecureStorageAvailable] =
useState(false)
const [projectReferences, setProjectReferences] =
useState<SshHostsSnapshot['projectReferences']>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string>()
const [editingId, setEditingId] = useState<string | 'new'>()
@@ -99,12 +113,16 @@ export function SshHostsSettingsSection({
const [remoteEnvironments, setRemoteEnvironments] = useState<
Record<string, RemoteEnvironmentLoadState>
>({})
const [
remoteEnvironmentPreparationMethods,
setRemoteEnvironmentPreparationMethods
] = useState<Record<string, RemoteEnvironmentPreparationMethod>>({})
const [remoteEnvironmentUpdate, setRemoteEnvironmentUpdate] =
useState<RemoteEnvironmentUpdateState>()
const [
remoteEnvironmentUpdateErrors,
setRemoteEnvironmentUpdateErrors
] = useState<Record<string, string>>({})
] = useState<Record<string, RemoteEnvironmentUpdateError>>({})
const remoteEnvironmentRequests = useRef(new Map<string, number>())
const activeRemoteEnvironmentUpdate =
useRef<ActiveRemoteEnvironmentUpdate | undefined>(undefined)
@@ -126,24 +144,29 @@ export function SshHostsSettingsSection({
return
}
return api.onRemoteEnvironmentUpdateProgress(
({ hostId, phase }) => {
({ hostId, method, phase }) => {
const activeUpdate = activeRemoteEnvironmentUpdate.current
if (
!mounted.current ||
!activeUpdate ||
activeUpdate.hostId !== hostId
activeUpdate.hostId !== hostId ||
(activeUpdate.requestedMethod !== 'auto' &&
activeUpdate.requestedMethod !== method)
) {
return
}
setRemoteEnvironmentUpdate((current) => {
if (
!current ||
current.requestId !== activeUpdate.requestId
current.requestId !== activeUpdate.requestId ||
(current.phase === phase &&
current.resolvedMethod === method)
) {
return current
}
return {
...current,
resolvedMethod: method,
phase
}
})
@@ -164,6 +187,7 @@ export function SshHostsSettingsSection({
(snapshot: SshHostsSnapshot): void => {
setHosts(snapshot.hosts)
setSecureStorageAvailable(snapshot.secureStorageAvailable)
setProjectReferences(snapshot.projectReferences)
setError(undefined)
},
[]
@@ -252,7 +276,8 @@ export function SshHostsSettingsSection({
)
const updateRemoteEnvironment = async (
host: SshHost
host: SshHost,
method: RemoteEnvironmentPreparationMethod
): Promise<void> => {
if (
typeof api?.updateRemoteEnvironment !== 'function' ||
@@ -264,6 +289,7 @@ export function SshHostsSettingsSection({
remoteEnvironmentUpdateRequestId.current = requestId
const activeUpdate: ActiveRemoteEnvironmentUpdate = {
hostId: host.id,
requestedMethod: method,
requestId,
cancelRequested: false
}
@@ -278,13 +304,24 @@ export function SshHostsSettingsSection({
})
setRemoteEnvironmentUpdate({
hostId: host.id,
requestedMethod: method,
requestId,
cancelling: false
})
let succeeded = false
const currentEnvironment = remoteEnvironments[host.id]?.value
const reinstallingMatchedEnvironment =
currentEnvironment !== undefined &&
[
currentEnvironment.agent,
...currentEnvironment.runtimes
].every(({ state }) => state === 'current')
try {
await api.updateRemoteEnvironment(host.id)
await api.updateRemoteEnvironment({
hostId: host.id,
method
})
succeeded = true
} catch (reason) {
const cancelled =
@@ -300,7 +337,11 @@ export function SshHostsSettingsSection({
if (mounted.current) {
setRemoteEnvironmentUpdateErrors((current) => ({
...current,
[host.id]: message
[host.id]: {
detail: message,
reinstallAttempt:
reinstallingMatchedEnvironment && !cancelled
}
}))
} else if (!cancelled) {
onNotify?.({
@@ -404,7 +445,10 @@ export function SshHostsSettingsSection({
})
setRemoteEnvironmentUpdateErrors((current) => ({
...current,
[hostId]: activeUpdate.cancelError!
[hostId]: {
detail: activeUpdate.cancelError!,
reinstallAttempt: false
}
}))
}
}
@@ -420,43 +464,6 @@ export function SshHostsSettingsSection({
[]
)
useEffect(() => {
if (loading) {
return
}
const hostIds = hosts
.filter(
(host) =>
host.lastValidatedAt &&
!remoteEnvironmentRequests.current.has(host.id)
)
.map((host) => host.id)
if (hostIds.length === 0) {
return
}
let active = true
let nextIndex = 0
const worker = async (): Promise<void> => {
while (active) {
const hostId = hostIds[nextIndex]
nextIndex += 1
if (!hostId) {
return
}
await requestRemoteEnvironment(hostId)
}
}
void Promise.all(
Array.from(
{ length: Math.min(3, hostIds.length) },
() => worker()
)
)
return () => {
active = false
}
}, [hosts, loading, requestRemoteEnvironment])
const notify = (
dedupeKey: string,
message: string,
@@ -488,7 +495,10 @@ export function SshHostsSettingsSection({
setBusyHostId(host.id)
setError(undefined)
try {
await api.remove(host.id)
const result = await api.remove(host.id)
onProjectsDeleted?.(
result.deletedProjects.map((project) => project.id)
)
setConfirmingDeleteId(undefined)
setHosts((current) =>
current.filter((candidate) => candidate.id !== host.id)
@@ -509,6 +519,14 @@ export function SshHostsSettingsSection({
delete next[host.id]
return next
})
setRemoteEnvironmentPreparationMethods((current) => {
if (!(host.id in current)) {
return current
}
const next = { ...current }
delete next[host.id]
return next
})
setRemoteEnvironmentUpdateErrors((current) => {
if (!(host.id in current)) {
return current
@@ -517,6 +535,14 @@ export function SshHostsSettingsSection({
delete next[host.id]
return next
})
setProjectReferences((current) => {
if (!current || !(host.id in current)) {
return current
}
const next = { ...current }
delete next[host.id]
return next
})
remoteEnvironmentRequests.current.delete(host.id)
notify(
`ssh-host-removed:${host.id}`,
@@ -599,6 +625,8 @@ export function SshHostsSettingsSection({
className="ssh-hosts-list"
>
{hosts.map((host) => {
const relatedProjects =
projectReferences?.[host.id] ?? []
const result = testResults[host.id]
const remoteEnvironment = remoteEnvironments[host.id]
const hostEnvironmentUpdate =
@@ -608,17 +636,29 @@ export function SshHostsSettingsSection({
const remoteEnvironmentUpdateError =
remoteEnvironmentUpdateErrors[host.id]
const deleting = busyHostId === host.id
const environmentNeedsUpdate = Boolean(
remoteEnvironment?.value &&
[
const environmentComponents = remoteEnvironment?.value
? [
remoteEnvironment.value.agent,
...remoteEnvironment.value.runtimes
].some(
({ state }) =>
state === 'update-available' ||
state === 'not-installed'
)
)
]
: []
const environmentAction =
environmentComponents.some(
({ state }) => state === 'not-installed'
)
? 'install'
: environmentComponents.some(
({ state }) => state === 'update-available'
)
? 'update'
: 'reinstall'
const remoteDownload =
remoteEnvironment?.value?.remoteDownload
const directDownloadActionAvailable =
remoteDownload?.available === true ||
remoteDownload?.reason === 'probe-failed'
const preparationMethod =
remoteEnvironmentPreparationMethods[host.id] ?? 'auto'
const updateApiAvailable =
typeof api?.updateRemoteEnvironment === 'function' &&
typeof api.cancelRemoteEnvironmentUpdate ===
@@ -730,92 +770,149 @@ export function SshHostsSettingsSection({
{t('sshHosts.environment.description')}
</small>
</div>
<div className="ssh-host-environment__actions">
<button
aria-label={t(
'sshHosts.environment.refreshNamed',
{ name: host.name }
)}
className="secondary-button"
disabled={
remoteEnvironment?.loading ||
Boolean(hostEnvironmentUpdate)
}
onClick={() =>
void requestRemoteEnvironment(host.id)
}
type="button"
>
{remoteEnvironment?.loading ? (
<LoaderCircle
aria-hidden="true"
className="ssh-host-environment__spinner"
size={13}
/>
) : (
<RefreshCw aria-hidden="true" size={13} />
)}
{t('sshHosts.environment.refresh')}
</button>
</div>
{!hostEnvironmentUpdate && (
<div className="ssh-host-environment__toolbar">
<div className="ssh-host-environment__method">
<span>
{t('sshHosts.environment.methodLabel')}
</span>
<SegmentedControl
ariaLabel={t(
'sshHosts.environment.methodSelectorNamed',
{ name: host.name }
)}
disabled={
!host.lastValidatedAt ||
!remoteEnvironment?.value ||
!updateApiAvailable ||
Boolean(remoteEnvironment?.loading) ||
Boolean(busyHostId) ||
Boolean(editingId) ||
hasActiveRemoteEnvironmentUpdate
}
onChange={(method) =>
setRemoteEnvironmentPreparationMethods(
(current) => ({
...current,
[host.id]: method
})
)
}
options={[
{
value: 'auto',
label: t(
'sshHosts.environment.methods.auto'
)
},
{
value: 'remote-download',
label: t(
'sshHosts.environment.methods.remote-download'
)
},
{
value: 'goodbuddy-transfer',
label: t(
'sshHosts.environment.methods.goodbuddy-transfer'
)
}
]}
value={preparationMethod}
/>
</div>
<button
aria-label={t(
hostEnvironmentUpdate
? 'sshHosts.environment.cancelUpdateNamed'
: 'sshHosts.environment.updateNamed',
`sshHosts.environment.actions.${environmentAction}Named`,
{ name: host.name }
)}
className="secondary-button"
className="primary-button"
disabled={
hostEnvironmentUpdate
? hostEnvironmentUpdate.cancelling ||
hostEnvironmentUpdate.phase ===
'finalizing'
: !host.lastValidatedAt ||
!remoteEnvironment?.value ||
!(
environmentNeedsUpdate ||
remoteEnvironmentUpdateError
) ||
!updateApiAvailable ||
Boolean(remoteEnvironment?.loading) ||
Boolean(busyHostId) ||
Boolean(editingId) ||
hasActiveRemoteEnvironmentUpdate
!host.lastValidatedAt ||
!remoteEnvironment?.value ||
!updateApiAvailable ||
(preparationMethod === 'remote-download' &&
!directDownloadActionAvailable) ||
Boolean(remoteEnvironment?.loading) ||
Boolean(busyHostId) ||
Boolean(editingId) ||
hasActiveRemoteEnvironmentUpdate
}
onClick={() => {
if (hostEnvironmentUpdate) {
void cancelRemoteEnvironmentUpdate(
host.id
)
} else {
void updateRemoteEnvironment(host)
}
void updateRemoteEnvironment(
host,
preparationMethod
)
}}
type="button"
>
{hostEnvironmentUpdate?.cancelling ? (
<LoaderCircle
aria-hidden="true"
className="ssh-host-environment__spinner"
size={13}
/>
) : hostEnvironmentUpdate ? (
<X aria-hidden="true" size={13} />
) : (
<Download aria-hidden="true" size={13} />
<Download aria-hidden="true" size={13} />
{t(
`sshHosts.environment.actions.${environmentAction}`
)}
{hostEnvironmentUpdate?.cancelling
? t('sshHosts.environment.cancelling')
: hostEnvironmentUpdate
? t('sshHosts.environment.cancelUpdate')
: t('sshHosts.environment.update')}
</button>
<button
aria-label={t(
'sshHosts.environment.refreshNamed',
{ name: host.name }
)}
className="secondary-button"
disabled={
remoteEnvironment?.loading ||
Boolean(hostEnvironmentUpdate)
}
onClick={() =>
void requestRemoteEnvironment(host.id)
}
type="button"
>
{remoteEnvironment?.loading ? (
<LoaderCircle
aria-hidden="true"
className="ssh-host-environment__spinner"
size={13}
/>
) : (
<RefreshCw aria-hidden="true" size={13} />
)}
{t('sshHosts.environment.refresh')}
</button>
</div>
</div>
)}
{remoteDownload &&
!remoteDownload.available && (
<div className="ssh-host-environment__availability">
{t(
`sshHosts.environment.remoteDownloadUnavailable.${remoteDownload.reason}`,
{
source: remoteDownload.source
? t(
`sshHosts.environment.sources.${remoteDownload.source}`
)
: undefined
}
)}
{remoteDownload.packageSize && (
<small>
{t(
'sshHosts.environment.remoteDownloadPackageSize',
{
size: formatPackageSize(
remoteDownload.packageSize
)
}
)}
</small>
)}
</div>
)}
{hostEnvironmentUpdate && (
<div
className="ssh-host-environment__loading"
className="ssh-host-environment__loading ssh-host-environment__loading--update"
role="status"
>
<LoaderCircle
@@ -823,13 +920,53 @@ export function SshHostsSettingsSection({
className="ssh-host-environment__spinner"
size={16}
/>
{t(
hostEnvironmentUpdate.cancelling
? 'sshHosts.environment.progress.cancelling'
: hostEnvironmentUpdate.phase
? `sshHosts.environment.progress.${hostEnvironmentUpdate.phase}`
: 'sshHosts.environment.progress.preparing'
)}
<span>
{!hostEnvironmentUpdate.cancelling && (
<strong>
{t(
`sshHosts.environment.methods.${hostEnvironmentUpdate.resolvedMethod ?? hostEnvironmentUpdate.requestedMethod}`
)}
</strong>
)}
<span>
{t(
hostEnvironmentUpdate.cancelling
? 'sshHosts.environment.progress.cancelling'
: hostEnvironmentUpdate.phase
? `sshHosts.environment.progress.${hostEnvironmentUpdate.phase}`
: 'sshHosts.environment.progress.preparing'
)}
</span>
</span>
<button
aria-label={t(
'sshHosts.environment.cancelUpdateNamed',
{ name: host.name }
)}
className="secondary-button"
disabled={
hostEnvironmentUpdate.cancelling ||
hostEnvironmentUpdate.phase === 'finalizing' ||
hostEnvironmentUpdate.phase === 'complete'
}
onClick={() =>
void cancelRemoteEnvironmentUpdate(host.id)
}
type="button"
>
{hostEnvironmentUpdate.cancelling ? (
<LoaderCircle
aria-hidden="true"
className="ssh-host-environment__spinner"
size={13}
/>
) : (
<X aria-hidden="true" size={13} />
)}
{hostEnvironmentUpdate.cancelling
? t('sshHosts.environment.cancelling')
: t('sshHosts.environment.cancelUpdate')}
</button>
</div>
)}
@@ -838,7 +975,14 @@ export function SshHostsSettingsSection({
className="ssh-host-environment__error"
role="alert"
>
{remoteEnvironmentUpdateError}
{remoteEnvironmentUpdateError.reinstallAttempt && (
<strong>
{t(
'sshHosts.environment.errors.reinstallFailedSummary'
)}
</strong>
)}
<span>{remoteEnvironmentUpdateError.detail}</span>
</div>
)}
@@ -897,7 +1041,11 @@ export function SshHostsSettingsSection({
/>
{t('sshHosts.environment.loading')}
</div>
) : null}
) : (
<div className="ssh-host-environment__availability">
{t('sshHosts.environment.notChecked')}
</div>
)}
</div>
)}
@@ -910,9 +1058,31 @@ export function SshHostsSettingsSection({
disabled={
deleting || Boolean(hostEnvironmentUpdate)
}
message={t('sshHosts.removeMessage', {
name: host.name
})}
message={
confirmingDeleteId === host.id ? (
<div className="ssh-host-remove-confirmation">
<p>
{t('sshHosts.removeMessage', {
name: host.name
})}
</p>
{relatedProjects.length > 0 && (
<>
<strong>
{t('sshHosts.removeProjectsHeading')}
</strong>
<ul>
{relatedProjects.map((project) => (
<li key={project.id}>
{project.name}
</li>
))}
</ul>
</>
)}
</div>
) : null
}
onCancel={() =>
setConfirmingDeleteId(undefined)
}
@@ -966,6 +1136,11 @@ function isCancellationError(reason: unknown): boolean {
)
}
function formatPackageSize(bytes: number): string {
const mebibytes = bytes / (1024 * 1024)
return `${mebibytes >= 10 ? Math.round(mebibytes) : mebibytes.toFixed(1)} MiB`
}
function EnvironmentVersionCard({
expected,
icon,
+1 -1
View File
@@ -372,7 +372,7 @@ export function DestructiveConfirmActions({
confirming: boolean
disabled?: boolean
icon?: ReactNode
message?: string
message?: ReactNode
onCancel: () => void
onConfirm: () => void
onRequestConfirm: () => void
@@ -567,21 +567,6 @@ export const app = {
brandingSaved: 'Branding saved',
updateAvailable:
'GoodBuddy {{version}} is available. View it in About & Updates.',
remoteProjectActivated:
'The remote project Agent and Runtime are ready',
remoteProjectActivationFailed:
'Remote project update failed: {{message}}. Check the Host and try again.',
remoteProjectActivationUnknownError: 'Unknown error',
remoteProjectReactivating:
'The remote project is reconnecting. Try again shortly.',
remoteProjectReconnectRequired:
'The remote project must reconnect. Select the current project again before sending.',
remoteProjectActivationCancelled:
'Remote project connection cancelled',
remoteProjectActivationCancelFailed:
'Could not cancel the remote project connection: {{message}}',
remoteProjectActivationCancelUnknownError:
'Could not cancel the remote project connection. Try again.',
channelConversationAutomatic:
'Channel conversations are created automatically when the client receives a new message',
deleteConversationCancelFailed:
@@ -73,31 +73,74 @@ export const settingsSections = {
description:
'Updating this Host may replace the Agent and Runtime and interrupt active remote work. Host configuration and project files are preserved.',
loading: 'Loading Agent and Runtime versions…',
update: 'Update versions',
updateNamed: 'Update remote environment versions for {{name}}',
notChecked:
'Versions have not been checked. This page does not connect automatically; select Refresh versions to check.',
methodLabel: 'Installation method',
methodSelectorNamed:
'Remote environment installation method for {{name}}',
actions: {
install: 'Install remote environment',
installNamed: 'Install the remote environment for {{name}}',
update: 'Update remote environment',
updateNamed: 'Update the remote environment for {{name}}',
reinstall: 'Reinstall',
reinstallNamed: 'Reinstall the remote environment for {{name}}'
},
cancelUpdate: 'Cancel update',
cancelUpdateNamed: 'Cancel the remote environment update for {{name}}',
cancelling: 'Cancelling…',
refresh: 'Refresh versions',
refreshNamed: 'Refresh remote environment versions for {{name}}',
progress: {
preparing: 'Preparing the update…',
agent: 'Updating GoodBuddy Agent…',
runtime: 'Updating Runtime…',
preparing: 'Preparing installation…',
probing: 'Checking the remote Host environment…',
downloading: 'Transferring the environment package…',
verifying: 'Verifying the environment package…',
applying: 'Applying the remote environment…',
'installing-agent': 'Installing GoodBuddy Agent…',
'installing-runtime': 'Installing Runtime…',
'checking-health': 'Checking the remote environment…',
finalizing: 'Finalizing the remote environment update…',
complete: 'Remote environment installation complete',
cancelling: 'Cancelling the remote environment update…'
},
methods: {
auto: 'Automatic',
'remote-download': 'Host download',
'goodbuddy-transfer': 'GoodBuddy transfer'
},
sources: {
github: 'GitHub',
mirror: 'mirror node'
},
remoteDownloadUnavailable: {
'package-unavailable':
'The current source has no package for this Host. You can still install through GoodBuddy.',
'missing-tools':
'The remote Host lacks a system tool required to download or unpack the package. You can still install through GoodBuddy.',
'home-unwritable':
'The remote user directory is not writable. Fix its permissions and refresh, or install through GoodBuddy.',
'insufficient-disk-space':
'The remote Host does not have enough free space for the package. Free space and refresh, or install through GoodBuddy.',
'source-unreachable':
'The remote Host cannot reach the selected source ({{source}}). Check its network and refresh, or install through GoodBuddy.',
'probe-failed':
'Could not complete the remote download capability check. Retry the remote-host installation directly, refresh the check, or reinstall through GoodBuddy.'
},
remoteDownloadPackageSize: 'Package size: {{size}}',
errors: {
updateFailed: 'Could not update the remote environment. Try again.',
cancelled: 'The remote environment update was cancelled. You can retry.',
cancelFailed: 'Could not cancel the remote environment update.'
cancelFailed: 'Could not cancel the remote environment update.',
reinstallFailedSummary:
'This reinstall did not complete. Checking the current versions again.'
},
installed: 'Installed on Host',
expected: 'Required by GoodBuddy',
notInstalled: 'Not installed',
versionDetail: 'Linux · {{architecture}}',
states: {
current: 'Current',
current: 'Version matched',
'update-available': 'Update available',
'not-installed': 'Not installed'
}
@@ -144,7 +187,8 @@ export const settingsSections = {
}
},
removeMessage:
'Deleting “{{name}}” clears the locally saved connection information and encrypted credential. Remote files are not deleted.',
'Deleting “{{name}}” clears its locally saved connection information and encrypted credential. Related projects are removed locally without connecting to the Host or deleting remote directories or content.',
removeProjectsHeading: 'Also remove these related project records:',
actions: {
add: 'Add host',
edit: 'Edit and revalidate',
@@ -22,7 +22,8 @@ export const workspace = {
remoteDetail: '{{channel}} · Remote channel · {{path}}',
remoteChannel: 'Remote channel',
create: 'New project',
settings: 'Project settings'
settings: 'Project settings',
settingsNamed: 'Manage project {{name}}'
},
dialog: {
createTitle: 'New project',
@@ -85,6 +86,23 @@ export const workspace = {
noHosts: 'No saved SSH hosts',
hostHelp:
'Host credentials remain centrally managed in Settings and are not copied into the project.',
readiness: {
loading: 'Reading the Host validation record…',
ready:
'This Host is validated. Saving connects and checks the Agent, workspace, and Runtime.',
unready:
'This Host has not completed Host Key and connection validation. Validate it under Settings > SSH Hosts first.',
error:
'Could not read this Host’s local validation record. Check it under Settings > SSH Hosts.',
saveBlocked:
'The selected Host has not been validated. Validate its connection under Settings > SSH Hosts first.',
options: {
loading: 'Reading',
ready: 'Validated',
unready: 'Validation required',
error: 'Record unavailable'
}
},
rootHelp:
'Enter or choose a canonical absolute path beginning with /.',
directoryPicker: {
@@ -108,13 +126,9 @@ export const workspace = {
save: 'Save remote project',
saving: 'Saving remote project…'
},
activation: {
title: 'Connecting to remote project “{{project}}”',
progressLabel: 'Remote project connection progress',
stepsLabel: 'Remote project connection stages',
cancel: 'Cancel connection',
cancelling: 'Cancelling the remote connection…',
cancellingAction: 'Cancelling…'
progress: {
progressLabel: 'Remote project save progress',
stepsLabel: 'Remote project save stages'
},
phaseStatus: 'Current phase: {{phase}}',
phases: {
@@ -532,18 +532,6 @@ export const app = {
notices: {
brandingSaved: '品牌设置已保存',
updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看',
remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪',
remoteProjectActivationFailed:
'远程项目更新失败:{{message}}。请检查 Host 后重试',
remoteProjectActivationUnknownError: '未知错误',
remoteProjectReactivating: '远程项目正在重新连接,请稍候',
remoteProjectReconnectRequired:
'远程项目需要重新连接,请重新选择当前项目后再发送',
remoteProjectActivationCancelled: '已取消远程项目连接',
remoteProjectActivationCancelFailed:
'取消远程项目连接失败:{{message}}',
remoteProjectActivationCancelUnknownError:
'取消远程项目连接失败,请重试',
channelConversationAutomatic:
'通道项目的会话由客户端收到新消息后自动创建',
deleteConversationCancelFailed:
@@ -66,31 +66,73 @@ export const settingsSections = {
description:
'更新 Host 可能替换 Agent 和 Runtime,并中断正在进行的远程工作;Host 配置和项目文件会保留。',
loading: '正在读取 Agent 和 Runtime 版本…',
update: '更新版本',
updateNamed: '更新 {{name}} 的远程运行环境版本',
notChecked:
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。',
methodLabel: '安装方式',
methodSelectorNamed: '{{name}} 的远程环境安装方式',
actions: {
install: '安装远程环境',
installNamed: '为 {{name}} 安装远程环境',
update: '更新远程环境',
updateNamed: '为 {{name}} 更新远程环境',
reinstall: '重新安装',
reinstallNamed: '为 {{name}} 重新安装远程环境'
},
cancelUpdate: '取消更新',
cancelUpdateNamed: '取消 {{name}} 的远程运行环境更新',
cancelling: '正在取消…',
refresh: '刷新版本',
refreshNamed: '刷新 {{name}} 的远程运行环境版本',
progress: {
preparing: '正在准备更新…',
agent: '正在更新 GoodBuddy Agent…',
runtime: '正在更新 Runtime…',
preparing: '正在准备安装…',
probing: '正在检查远程主机环境…',
downloading: '正在传输运行环境包…',
verifying: '正在校验运行环境包…',
applying: '正在应用远程运行环境…',
'installing-agent': '正在安装 GoodBuddy Agent…',
'installing-runtime': '正在安装 Runtime…',
'checking-health': '正在检查远程运行环境…',
finalizing: '正在完成远程运行环境更新…',
complete: '远程运行环境安装完成',
cancelling: '正在取消远程运行环境更新…'
},
methods: {
auto: '自动',
'remote-download': 'Host 下载',
'goodbuddy-transfer': 'GoodBuddy 传输'
},
sources: {
github: 'GitHub',
mirror: '镜像节点'
},
remoteDownloadUnavailable: {
'package-unavailable':
'当前下载源没有适用于此主机的安装包。仍可通过 GoodBuddy 安装。',
'missing-tools':
'远程主机缺少直接下载或解包所需的系统工具。仍可通过 GoodBuddy 安装。',
'home-unwritable':
'远程主机的用户目录不可写,无法直接下载安装。请修复权限后刷新,或通过 GoodBuddy 安装。',
'insufficient-disk-space':
'远程主机没有足够的可用空间下载安装包。请释放空间后刷新,或通过 GoodBuddy 安装。',
'source-unreachable':
'远程主机无法连接所选下载源({{source}})。请检查网络后刷新,或通过 GoodBuddy 安装。',
'probe-failed':
'无法完成远程主机直连能力检查。可直接重试由远程主机安装、刷新检查,或通过 GoodBuddy 重新安装。'
},
remoteDownloadPackageSize: '安装包大小:{{size}}',
errors: {
updateFailed: '更新远程运行环境失败,请重试',
cancelled: '远程运行环境更新已取消,可重试',
cancelFailed: '取消远程运行环境更新失败'
cancelFailed: '取消远程运行环境更新失败',
reinstallFailedSummary:
'本次重新安装未完成;正在重新检查当前版本。'
},
installed: 'Host 已安装',
expected: 'GoodBuddy 所需',
notInstalled: '尚未安装',
versionDetail: 'Linux · {{architecture}}',
states: {
current: '当前版本',
current: '版本匹配',
'update-available': '待更新',
'not-installed': '未安装'
}
@@ -137,7 +179,8 @@ export const settingsSections = {
}
},
removeMessage:
'删除“{{name}}”会清除本机保存的连接信息和加密凭据,不会删除远端文件。',
'删除“{{name}}”会清除本机保存的连接信息和加密凭据。关联项目只删除本地记录,不会连接主机,也不会删除远端目录或内容。',
removeProjectsHeading: '同时删除以下关联项目记录:',
actions: {
add: '添加主机',
edit: '编辑',
@@ -19,7 +19,8 @@ export const workspace = {
remoteDetail: '{{channel}} · 远程通道 · {{path}}',
remoteChannel: '远程通道',
create: '新建项目',
settings: '项目设置'
settings: '项目设置',
settingsNamed: '管理项目 {{name}}'
},
dialog: {
createTitle: '新建项目',
@@ -81,6 +82,23 @@ export const workspace = {
loadingHosts: '正在读取已保存主机…',
noHosts: '没有已保存的 SSH 主机',
hostHelp: '主机凭据由设置中心统一管理,不会复制到项目中。',
readiness: {
loading: '正在读取主机验证记录…',
ready:
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。',
unready:
'此主机尚未完成 Host Key 和连接验证。请先前往“设置 > SSH 主机”完成验证。',
error:
'无法读取此主机的本地验证记录。请前往“设置 > SSH 主机”检查配置。',
saveBlocked:
'所选主机尚未完成验证。请先在“设置 > SSH 主机”验证连接。',
options: {
loading: '读取中',
ready: '已验证',
unready: '需要验证',
error: '记录不可用'
}
},
rootHelp: '请输入或选择以 / 开头的规范绝对路径。',
directoryPicker: {
browse: '浏览远端工作目录',
@@ -103,13 +121,9 @@ export const workspace = {
save: '保存远程项目',
saving: '正在保存远程项目…'
},
activation: {
title: '正在连接远程项目“{{project}}”',
progressLabel: '远程项目连接进度',
stepsLabel: '远程项目连接阶段',
cancel: '取消连接',
cancelling: '正在取消远程连接…',
cancellingAction: '取消中…'
progress: {
progressLabel: '远程项目保存进度',
stepsLabel: '远程项目保存阶段'
},
phaseStatus: '当前阶段:{{phase}}',
phases: {
+165 -18
View File
@@ -2468,13 +2468,18 @@ textarea:focus-visible {
font-weight: 650;
}
.project-switcher__group > button {
.project-switcher__menu-item {
position: relative;
min-width: 0;
}
.project-switcher__menu-item > [role="menuitemradio"] {
display: grid;
width: 100%;
min-width: 0;
min-height: 52px;
align-items: center;
padding: var(--space-2);
padding: var(--space-2) 44px var(--space-2) var(--space-2);
border-radius: var(--radius-control);
background: transparent;
color: var(--text-primary);
@@ -2484,35 +2489,78 @@ textarea:focus-visible {
text-align: left;
}
.project-switcher__group > button:hover,
.project-switcher__group > button[aria-checked="true"] {
.project-switcher__menu-item > [role="menuitemradio"]:hover,
.project-switcher__menu-item > [role="menuitemradio"][aria-checked="true"] {
background: var(--accent-subtle);
color: var(--accent);
}
.project-switcher__group > button > span {
.project-switcher__menu-item > [role="menuitemradio"] > span {
display: grid;
min-width: 0;
gap: 2px;
}
.project-switcher__group > button b,
.project-switcher__group > button small {
.project-switcher__menu-item > [role="menuitemradio"] b,
.project-switcher__menu-item > [role="menuitemradio"] small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-switcher__group > button b {
.project-switcher__menu-item > [role="menuitemradio"] b {
font-size: var(--font-body);
font-weight: 650;
}
.project-switcher__group > button small {
.project-switcher__menu-item > [role="menuitemradio"] small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.project-switcher__menu-settings {
position: absolute;
top: 50%;
right: var(--space-1);
display: grid;
width: 32px;
height: 32px;
padding: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-muted);
opacity: 0;
place-items: center;
pointer-events: none;
transform: translateY(-50%);
transition:
opacity var(--motion-fast) ease-out,
background var(--motion-fast) ease-out,
color var(--motion-fast) ease-out;
}
.project-switcher__menu-item:hover
.project-switcher__menu-settings,
.project-switcher__menu-item:focus-within
.project-switcher__menu-settings,
.project-switcher__menu-settings:focus-visible {
opacity: 1;
pointer-events: auto;
}
.project-switcher__menu-settings:hover,
.project-switcher__menu-settings:focus-visible {
background: var(--surface-subtle);
color: var(--text-primary);
}
@media (hover: none) {
.project-switcher__menu-settings {
opacity: 1;
pointer-events: auto;
}
}
.project-create-backdrop {
position: fixed;
z-index: var(--z-dialog);
@@ -6982,6 +7030,25 @@ button > svg {
margin-left: auto;
}
.ssh-host-remove-confirmation {
display: grid;
max-width: 420px;
gap: var(--space-2);
}
.ssh-host-remove-confirmation p {
margin: 0;
}
.ssh-host-remove-confirmation ul {
display: grid;
max-height: 140px;
margin: 0;
padding-left: var(--space-5);
gap: var(--space-1);
overflow: auto;
}
.ssh-host-test-result {
display: flex;
align-items: center;
@@ -6999,7 +7066,7 @@ button > svg {
.ssh-host-environment__header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
@@ -7024,16 +7091,38 @@ button > svg {
}
.ssh-host-environment__summary small {
max-width: 680px;
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.ssh-host-environment__actions {
.ssh-host-environment__header > .secondary-button {
flex: 0 0 auto;
}
.ssh-host-environment__toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
.ssh-host-environment__method {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.ssh-host-environment__method > span {
color: var(--text-muted);
font-size: var(--font-caption);
font-weight: 600;
}
.ssh-host-environment__toolbar > .primary-button {
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
}
.ssh-host-environment__grid {
@@ -7045,10 +7134,10 @@ button > svg {
.ssh-host-environment__runtime {
display: grid;
min-width: 0;
padding: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
background: var(--surface-raised);
gap: var(--space-3);
}
@@ -7066,7 +7155,7 @@ button > svg {
height: 30px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-raised);
background: var(--surface-subtle);
color: var(--accent);
place-items: center;
}
@@ -7156,10 +7245,49 @@ button > svg {
color: var(--text-secondary);
}
.ssh-host-environment__loading--update {
border: 1px solid var(--border-subtle);
background: var(--surface-raised);
}
.ssh-host-environment__loading > span {
display: grid;
min-width: 0;
margin-right: auto;
gap: var(--space-1);
}
.ssh-host-environment__loading > span > strong {
color: var(--text-primary);
font-size: var(--font-caption);
}
.ssh-host-environment__availability {
display: grid;
padding: var(--space-2) var(--space-3);
border-left: 3px solid var(--warning);
background: var(--surface-subtle);
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.5;
gap: var(--space-1);
}
.ssh-host-environment__availability small {
color: var(--text-muted);
font-size: inherit;
}
.ssh-host-environment__error {
display: grid;
border: 1px solid var(--danger-border);
background: var(--danger-subtle);
color: var(--danger);
gap: var(--space-1);
}
.ssh-host-environment__error > strong {
color: var(--danger-strong);
}
.ssh-host-environment__spinner {
@@ -7483,9 +7611,28 @@ button > svg {
flex-direction: column;
}
.ssh-host-environment__actions {
.ssh-host-environment__header > .secondary-button {
align-self: flex-start;
}
.ssh-host-environment__toolbar {
align-items: stretch;
flex-direction: column;
}
.ssh-host-environment__method > .segmented-control {
width: 100%;
}
.ssh-host-environment__method
> .segmented-control
.segmented-control__option {
min-width: 0;
flex: 1;
}
.ssh-host-environment__toolbar > .primary-button {
align-self: flex-start;
flex-wrap: wrap;
}
.ssh-host-card__actions > .danger-confirm {
@@ -62,6 +62,17 @@ describe('Agent package contracts', () => {
).toThrow()
})
it('rejects unknown catalog entry metadata', () => {
expect(() =>
agentPackageCatalogEntrySchema.parse({
...catalogEntry(),
bootstrapCapability: {
version: 1
}
})
).toThrow()
})
it('requires one local inventory entry per architecture', () => {
const entry = {
platform: 'linux',
+4 -100
View File
@@ -9,8 +9,6 @@ import {
persistedProjectExecutionSpaceSchema,
projectCreateSchema,
projectExecutionSpaceSchema,
projectRuntimeValidationSchema,
sshExecutionValidationSchema,
projectUpdateSchema
} from './assistant-contracts'
@@ -132,120 +130,26 @@ describe('project execution space contracts', () => {
).toBe(false)
})
it('keeps SSH and Runtime validation identities separate and strict', () => {
const validatedAt = '2026-08-21T00:00:00.000Z'
const validation = {
hostRevision: 2,
hostKeyGeneration: 3,
remoteUsername: 'builder',
workspaceIdentity: 'workspace-identity',
agentProtocolMajor: 1,
agentInstallationIdAtValidation: 'agent-installation',
agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`,
agentVersionAtValidation: '0.11.0',
agentArchitectureAtValidation: 'x64',
validatedAt
}
const runtimeValidation = {
runtimeSelectionKey: 'opencode:profile',
runtimeBundleDigest: `sha256:${'b'.repeat(64)}`,
runtimeAdapterDigest: `sha256:${'c'.repeat(64)}`,
agentInstallationIdAtValidation: 'agent-installation',
validatedAt,
workMode: 'execute'
}
expect(
sshExecutionValidationSchema.safeParse(validation).success
).toBe(true)
expect(
projectRuntimeValidationSchema.safeParse(runtimeValidation)
.success
).toBe(true)
it('persists only the SSH Host and remote path', () => {
expect(
persistedProjectExecutionSpaceSchema.parse({
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000301',
remoteRootPath: '/srv/goodbuddy',
validation
remoteRootPath: '/srv/goodbuddy'
})
).toEqual({
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000301',
remoteRootPath: '/srv/goodbuddy',
validation
remoteRootPath: '/srv/goodbuddy'
})
expect(
sshExecutionValidationSchema.safeParse({
hostRevision: 2,
hostKeyGeneration: 3,
remoteUsername: 'builder',
workspaceIdentity: 'workspace-identity',
agentProtocolMajor: 1,
agentInstallationIdAtValidation: 'agent-installation',
agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`,
agentVersionAtValidation: '0.11.0',
agentArchitectureAtValidation: 'x64',
validatedAt,
runtimeBundleDigest: 'must-not-be-here'
}).success
).toBe(false)
expect(
persistedProjectExecutionSpaceSchema.safeParse({
kind: 'local',
rootPath: 'C:\\Workspace',
validation
}).success
).toBe(false)
expect(
persistedProjectExecutionSpaceSchema.safeParse({
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000301',
remoteRootPath: '/srv/goodbuddy',
hostRevision: 2
validation: { hostRevision: 2 }
}).success
).toBe(false)
for (const invalid of [
{ ...validation, hostRevision: 0 },
{ ...validation, trustAttestationRevision: 4 },
{
...validation,
agentBinaryDigestAtValidation: `sha256:${'A'.repeat(64)}`
},
{ ...validation, agentVersionAtValidation: ' version ' },
{ ...validation, agentArchitectureAtValidation: 'ia32' }
]) {
expect(
sshExecutionValidationSchema.safeParse(invalid).success
).toBe(false)
}
for (const invalid of [
{
...runtimeValidation,
runtimeBundleDigest: 'runtime-digest'
},
{ ...runtimeValidation, trustTier: 'T3' },
{
...runtimeValidation,
confinementPolicyDigest: `sha256:${'d'.repeat(64)}`
},
{ ...runtimeValidation, approvalBridgeVersion: '1' },
{
...runtimeValidation,
workMode: 'plan'
}
]) {
expect(
projectRuntimeValidationSchema.safeParse(invalid).success
).toBe(false)
}
expect(
projectRuntimeValidationSchema.safeParse({
...runtimeValidation,
workMode: 'ask'
}).success
).toBe(true)
})
it('keeps renderer project create and update inputs local-only', () => {
+1 -53
View File
@@ -1,6 +1,4 @@
import { z } from 'zod'
import { agentArchitectureSchema } from './agent-installation-contracts'
import { sha256DigestSchema } from './agent-protocol/contracts'
import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
import { sshHostIdSchema } from './ssh-host-contracts'
@@ -32,11 +30,6 @@ export type ProjectKind = z.infer<typeof projectKindSchema>
export type ProjectChannel = z.infer<typeof projectChannelSchema>
const projectExecutionSpacePathSchema = z.string().max(4_096)
const projectExecutionValidationTextSchema = z
.string()
.min(1)
.max(1_000)
.refine((value) => value.trim().length > 0)
export const projectExecutionSpaceSchema = z.discriminatedUnion(
'kind',
@@ -60,52 +53,8 @@ export const projectExecutionSpaceSchema = z.discriminatedUnion(
]
)
export const sshExecutionValidationSchema = z
.object({
hostRevision: z.number().int().positive(),
hostKeyGeneration: z.number().int().positive(),
remoteUsername: projectExecutionValidationTextSchema,
workspaceIdentity: projectExecutionValidationTextSchema,
agentProtocolMajor: z.number().int().positive(),
agentInstallationIdAtValidation:
projectExecutionValidationTextSchema,
agentBinaryDigestAtValidation: sha256DigestSchema,
agentVersionAtValidation: z
.string()
.regex(/^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$/u),
agentArchitectureAtValidation: agentArchitectureSchema,
validatedAt: z.string().datetime({ offset: true })
})
.strict()
export const projectRuntimeValidationSchema = z
.object({
runtimeSelectionKey: z.string().trim().min(1).max(1_000),
runtimeBundleDigest: sha256DigestSchema,
runtimeAdapterDigest: sha256DigestSchema,
agentInstallationIdAtValidation:
projectExecutionValidationTextSchema,
validatedAt: z.string().datetime({ offset: true }),
workMode: workModeSchema
})
.strict()
export type SshExecutionValidation = z.output<
typeof sshExecutionValidationSchema
>
export type ProjectRuntimeValidation = z.output<
typeof projectRuntimeValidationSchema
>
export const persistedProjectExecutionSpaceSchema =
z.discriminatedUnion('kind', [
projectExecutionSpaceSchema.options[0],
projectExecutionSpaceSchema.options[1]
.extend({
validation: sshExecutionValidationSchema.optional()
})
.strict()
])
projectExecutionSpaceSchema
export type ProjectExecutionSpace = z.output<
typeof persistedProjectExecutionSpaceSchema
@@ -513,7 +462,6 @@ export type AssistantProject = ProjectCreateInput & {
id: string
kind: ProjectKind
executionSpace: ProjectExecutionSpace
runtimeValidation?: ProjectRuntimeValidation
channel?: ProjectChannel
readonly builtInDefault?: boolean
status: 'active' | 'archived'
+6 -3
View File
@@ -173,7 +173,9 @@ import type {
SshHostDraftInspectionRequest,
SshHostKeyInspection,
RemoteEnvironmentUpdateProgress,
RemoteEnvironmentUpdateRequest,
SshHostRemoteEnvironment,
SshHostRemovalResult,
SshHostsSnapshot,
SshDirectoryBrowseResult,
SshHostValidationRequest,
@@ -1580,7 +1582,7 @@ export type DesktopApi = {
onAgentPackageProgress: (
listener: (progress: AgentPackageDownloadProgress) => void
) => () => void
remove: (hostId: string) => Promise<void>
remove: (hostId: string) => Promise<SshHostRemovalResult>
inspectDraftHostKey: (
input: SshHostDraftInspectionRequest
) => Promise<SshHostKeyInspection>
@@ -1591,7 +1593,9 @@ export type DesktopApi = {
getRemoteEnvironment: (
hostId: string
) => Promise<SshHostRemoteEnvironment>
updateRemoteEnvironment: (hostId: string) => Promise<void>
updateRemoteEnvironment: (
input: RemoteEnvironmentUpdateRequest
) => Promise<void>
cancelRemoteEnvironmentUpdate: (hostId: string) => Promise<void>
onRemoteEnvironmentUpdateProgress: (
listener: (progress: RemoteEnvironmentUpdateProgress) => void
@@ -1735,7 +1739,6 @@ export type DesktopApi = {
setArchived: (projectId: string, archived: boolean) => Promise<void>
delete: (projectId: string, confirmation: string) => Promise<void>
remote: {
activate: (projectId: string) => Promise<AssistantProject>
save: (
input: RemoteProjectSaveRequest
) => Promise<AssistantProject>

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