feat: add managed SSH execution spaces
Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI. Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts. Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import {
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
type KeyObject
|
||||
} from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
type Manifest = {
|
||||
formatVersion: 1
|
||||
product: 'GoodBuddy'
|
||||
agentVersion: string
|
||||
platform: 'linux'
|
||||
arch: 'x64' | 'arm64'
|
||||
protocol: { major: number; minor: number }
|
||||
signingKeyId: string
|
||||
entrypoint: {
|
||||
path: string
|
||||
runtimePath: string
|
||||
scriptPath: string
|
||||
}
|
||||
files: Array<{
|
||||
path: string
|
||||
size: number
|
||||
sha256: string
|
||||
mode: string
|
||||
}>
|
||||
licenses: Array<{
|
||||
package: string
|
||||
version: string
|
||||
spdx: string
|
||||
path: string
|
||||
}>
|
||||
}
|
||||
|
||||
type KeyRegistry = {
|
||||
formatVersion: 1
|
||||
keys: Array<{
|
||||
keyId: string
|
||||
publicKeySpkiBase64: string
|
||||
environment: 'production' | 'test'
|
||||
}>
|
||||
revocations: Array<{
|
||||
keyId: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface AgentBundleModule {
|
||||
assertSafeManifestPath: (path: string) => void
|
||||
canonicalManifestBytes: (manifest: Manifest) => Buffer
|
||||
createAgentArchive: (
|
||||
directory: string,
|
||||
archivePath: string
|
||||
) => void
|
||||
createManifest: (
|
||||
directory: string,
|
||||
metadata: Record<string, unknown>
|
||||
) => Manifest
|
||||
detectElfArchitecture: (
|
||||
contents: Buffer
|
||||
) => 'x64' | 'arm64' | undefined
|
||||
lockedRuntimeInput: (
|
||||
lock: Record<string, unknown>,
|
||||
arch: string,
|
||||
path: string
|
||||
) => unknown
|
||||
preflightProductionSigningKey: (options: {
|
||||
keyId?: string
|
||||
registry: KeyRegistry
|
||||
}) => KeyRegistry['keys'][number]
|
||||
productionSigningKey: (
|
||||
environment: NodeJS.ProcessEnv
|
||||
) => { keyId: string; privateKey: string }
|
||||
publicKeySpkiBase64: (key: KeyObject) => string
|
||||
redactSecrets: (
|
||||
value: string,
|
||||
environment: NodeJS.ProcessEnv
|
||||
) => string
|
||||
signManifestForTest: (
|
||||
manifest: Buffer,
|
||||
key: KeyObject
|
||||
) => Buffer
|
||||
verifyBundleDirectory: (
|
||||
directory: string,
|
||||
options: {
|
||||
registry: KeyRegistry
|
||||
verificationEnvironment?: string
|
||||
enforceFilesystemMode?: boolean
|
||||
}
|
||||
) => {
|
||||
manifest: Manifest
|
||||
manifestSha256: string
|
||||
}
|
||||
verifyManifestSignature: (
|
||||
manifest: Buffer,
|
||||
signature: Buffer,
|
||||
registry: KeyRegistry,
|
||||
options?: { verificationEnvironment?: string }
|
||||
) => Manifest
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const agentBundle = require(
|
||||
'../build/agent-bundle.cjs'
|
||||
) as AgentBundleModule
|
||||
|
||||
let directory: string
|
||||
let privateKey: KeyObject
|
||||
let registry: KeyRegistry
|
||||
|
||||
function elf(arch: 'x64' | 'arm64'): Buffer {
|
||||
const contents = Buffer.alloc(64)
|
||||
contents.set([0x7f, 0x45, 0x4c, 0x46])
|
||||
contents[5] = 1
|
||||
contents.writeUInt16LE(arch === 'x64' ? 62 : 183, 18)
|
||||
return contents
|
||||
}
|
||||
|
||||
function writeKoffiPayload(
|
||||
bundleDirectory: string,
|
||||
architecture: 'x64' | 'arm64'
|
||||
): void {
|
||||
const packageRoot = join(
|
||||
bundleDirectory,
|
||||
'lib',
|
||||
'node_modules',
|
||||
'koffi'
|
||||
)
|
||||
const nativePackage = `@koromix/koffi-linux-${architecture}`
|
||||
const nativeRoot = join(
|
||||
bundleDirectory,
|
||||
'lib',
|
||||
'node_modules',
|
||||
...nativePackage.split('/')
|
||||
)
|
||||
mkdirSync(join(packageRoot, 'src', 'koffi', 'src'), {
|
||||
recursive: true
|
||||
})
|
||||
mkdirSync(join(nativeRoot, `linux_${architecture}`), {
|
||||
recursive: true
|
||||
})
|
||||
mkdirSync(join(nativeRoot, `musl_${architecture}`), {
|
||||
recursive: true
|
||||
})
|
||||
const files = new Map<string, string | Buffer>([
|
||||
[
|
||||
join(packageRoot, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'koffi',
|
||||
version: '3.1.4',
|
||||
type: 'module',
|
||||
exports: { '.': { import: './index.js' } }
|
||||
})
|
||||
],
|
||||
[
|
||||
join(packageRoot, 'index.js'),
|
||||
'export { default } from "./src/koffi/index.js";\n'
|
||||
],
|
||||
[
|
||||
join(packageRoot, 'src', 'koffi', 'index.js'),
|
||||
'export default {};\n'
|
||||
],
|
||||
[
|
||||
join(packageRoot, 'src', 'koffi', 'src', 'static.js'),
|
||||
'export function loadStatic() { return null }\n'
|
||||
],
|
||||
[
|
||||
join(nativeRoot, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: nativePackage,
|
||||
version: '3.1.4',
|
||||
main: './index.js',
|
||||
os: ['linux'],
|
||||
cpu: [architecture]
|
||||
})
|
||||
],
|
||||
[
|
||||
join(nativeRoot, 'index.js'),
|
||||
`module.exports = require('./linux_${architecture}/koffi.node');\n`
|
||||
],
|
||||
[
|
||||
join(nativeRoot, `linux_${architecture}`, 'koffi.node'),
|
||||
elf(architecture)
|
||||
],
|
||||
[
|
||||
join(nativeRoot, `musl_${architecture}`, 'koffi.node'),
|
||||
elf(architecture)
|
||||
]
|
||||
])
|
||||
for (const [filePath, contents] of files) {
|
||||
writeFileSync(filePath, contents)
|
||||
chmodSync(filePath, 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
function writeFixtureBundle(): {
|
||||
manifest: Manifest
|
||||
manifestBytes: Buffer
|
||||
} {
|
||||
mkdirSync(join(directory, 'lib'), { recursive: true })
|
||||
mkdirSync(join(directory, 'licenses'), { recursive: true })
|
||||
writeFileSync(join(directory, 'node'), elf('x64'))
|
||||
writeFileSync(join(directory, 'goodbuddy-agent'), '#!/bin/sh\n')
|
||||
writeFileSync(join(directory, 'lib', 'agent.cjs'), 'module.exports={}\n')
|
||||
writeFileSync(join(directory, 'licenses', 'GoodBuddy-0BSD.txt'), '0BSD')
|
||||
writeFileSync(join(directory, 'licenses', 'Node.js-MIT.txt'), 'MIT')
|
||||
writeFileSync(join(directory, 'licenses', 'zod-MIT.txt'), 'MIT')
|
||||
writeFileSync(join(directory, 'licenses', 'koffi-MIT.txt'), 'MIT')
|
||||
writeFileSync(
|
||||
join(directory, 'licenses', 'koffi-native-MIT.txt'),
|
||||
'MIT'
|
||||
)
|
||||
writeKoffiPayload(directory, 'x64')
|
||||
for (const path of [
|
||||
'node',
|
||||
'goodbuddy-agent'
|
||||
]) {
|
||||
chmodSync(join(directory, path), 0o755)
|
||||
}
|
||||
for (const path of [
|
||||
'lib/agent.cjs',
|
||||
'licenses/GoodBuddy-0BSD.txt',
|
||||
'licenses/Node.js-MIT.txt',
|
||||
'licenses/zod-MIT.txt',
|
||||
'licenses/koffi-MIT.txt',
|
||||
'licenses/koffi-native-MIT.txt'
|
||||
]) {
|
||||
chmodSync(join(directory, ...path.split('/')), 0o644)
|
||||
}
|
||||
const manifest = agentBundle.createManifest(directory, {
|
||||
agentVersion: '0.11.0',
|
||||
nodeVersion: '24.19.0',
|
||||
zodVersion: '4.4.3',
|
||||
koffiVersion: '3.1.4',
|
||||
koffiNativePackage: '@koromix/koffi-linux-x64',
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
signingKeyId: 'fixture-test-key'
|
||||
})
|
||||
const manifestBytes =
|
||||
agentBundle.canonicalManifestBytes(manifest)
|
||||
writeFileSync(join(directory, 'manifest.json'), manifestBytes)
|
||||
writeFileSync(
|
||||
join(directory, 'manifest.sig'),
|
||||
`${agentBundle
|
||||
.signManifestForTest(manifestBytes, privateKey)
|
||||
.toString('base64')}\n`
|
||||
)
|
||||
chmodSync(join(directory, 'manifest.json'), 0o644)
|
||||
chmodSync(join(directory, 'manifest.sig'), 0o644)
|
||||
return { manifest, manifestBytes }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'goodbuddy-agent-bundle-'))
|
||||
// Deterministic repository-visible fixture seed; never a production key.
|
||||
privateKey = createPrivateKey({
|
||||
key: Buffer.from(
|
||||
'302e020100300506032b657004220420000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
|
||||
'hex'
|
||||
),
|
||||
format: 'der',
|
||||
type: 'pkcs8'
|
||||
})
|
||||
const publicKey = createPublicKey(privateKey)
|
||||
registry = {
|
||||
formatVersion: 1,
|
||||
keys: [
|
||||
{
|
||||
keyId: 'fixture-test-key',
|
||||
publicKeySpkiBase64:
|
||||
agentBundle.publicKeySpkiBase64(publicKey),
|
||||
environment: 'test'
|
||||
}
|
||||
],
|
||||
revocations: []
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('deterministic signed Agent manifests', () => {
|
||||
it('produces byte-identical manifests and detached signatures', () => {
|
||||
const first = writeFixtureBundle()
|
||||
const firstSignature = readFileSync(
|
||||
join(directory, 'manifest.sig')
|
||||
)
|
||||
rmSync(join(directory, 'manifest.json'))
|
||||
rmSync(join(directory, 'manifest.sig'))
|
||||
const secondManifest = agentBundle.createManifest(directory, {
|
||||
agentVersion: '0.11.0',
|
||||
nodeVersion: '24.19.0',
|
||||
zodVersion: '4.4.3',
|
||||
koffiVersion: '3.1.4',
|
||||
koffiNativePackage: '@koromix/koffi-linux-x64',
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
signingKeyId: 'fixture-test-key'
|
||||
})
|
||||
const secondBytes =
|
||||
agentBundle.canonicalManifestBytes(secondManifest)
|
||||
const secondSignature = agentBundle.signManifestForTest(
|
||||
secondBytes,
|
||||
privateKey
|
||||
)
|
||||
|
||||
expect(secondBytes).toEqual(first.manifestBytes)
|
||||
expect(secondSignature.toString('base64')).toBe(
|
||||
firstSignature.toString('utf8').trim()
|
||||
)
|
||||
})
|
||||
|
||||
it('produces byte-identical portable archives', () => {
|
||||
writeFixtureBundle()
|
||||
const firstArchive = `${directory}-first.tar`
|
||||
const secondArchive = `${directory}-second.tar`
|
||||
try {
|
||||
agentBundle.createAgentArchive(directory, firstArchive)
|
||||
agentBundle.createAgentArchive(directory, secondArchive)
|
||||
expect(readFileSync(secondArchive)).toEqual(
|
||||
readFileSync(firstArchive)
|
||||
)
|
||||
} finally {
|
||||
rmSync(firstArchive, { force: true })
|
||||
rmSync(secondArchive, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies signature, hashes, paths, modes, licenses, and ELF arch', () => {
|
||||
writeFixtureBundle()
|
||||
expect(
|
||||
agentBundle.verifyBundleDirectory(directory, {
|
||||
registry,
|
||||
verificationEnvironment: 'test'
|
||||
}).manifest
|
||||
).toMatchObject({
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 }
|
||||
})
|
||||
|
||||
writeFileSync(join(directory, 'lib', 'agent.cjs'), 'tampered')
|
||||
expect(() =>
|
||||
agentBundle.verifyBundleDirectory(directory, {
|
||||
registry,
|
||||
verificationEnvironment: 'test'
|
||||
})
|
||||
).toThrow(/size mismatch|hash mismatch/u)
|
||||
})
|
||||
|
||||
it('rejects a modified detached signature', () => {
|
||||
const { manifestBytes } = writeFixtureBundle()
|
||||
const signature = Buffer.from(
|
||||
readFileSync(join(directory, 'manifest.sig'), 'utf8').trim(),
|
||||
'base64'
|
||||
)
|
||||
signature[0] = (signature[0] ?? 0) ^ 1
|
||||
expect(() =>
|
||||
agentBundle.verifyManifestSignature(
|
||||
manifestBytes,
|
||||
signature,
|
||||
registry,
|
||||
{ verificationEnvironment: 'test' }
|
||||
)
|
||||
).toThrow('signature verification failed')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['../outside'],
|
||||
['/absolute'],
|
||||
['nested\\windows'],
|
||||
['nested//empty'],
|
||||
['nested/./dot']
|
||||
])('rejects unsafe manifest path %s', (path) => {
|
||||
expect(() =>
|
||||
agentBundle.assertSafeManifestPath(path)
|
||||
).toThrow('Unsafe Agent manifest path')
|
||||
})
|
||||
|
||||
it('rejects mode and architecture mismatches', () => {
|
||||
const { manifestBytes } = writeFixtureBundle()
|
||||
const modeManifest = JSON.parse(
|
||||
manifestBytes.toString('utf8')
|
||||
) as Manifest
|
||||
const launcherEntry = modeManifest.files.find(
|
||||
(file) => file.path === 'goodbuddy-agent'
|
||||
)
|
||||
expect(launcherEntry).toBeDefined()
|
||||
if (launcherEntry) {
|
||||
launcherEntry.mode = '0644'
|
||||
}
|
||||
const modeManifestBytes =
|
||||
agentBundle.canonicalManifestBytes(modeManifest)
|
||||
writeFileSync(
|
||||
join(directory, 'manifest.json'),
|
||||
modeManifestBytes
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, 'manifest.sig'),
|
||||
`${agentBundle
|
||||
.signManifestForTest(modeManifestBytes, privateKey)
|
||||
.toString('base64')}\n`
|
||||
)
|
||||
expect(() =>
|
||||
agentBundle.verifyBundleDirectory(directory, {
|
||||
registry,
|
||||
verificationEnvironment: 'test'
|
||||
})
|
||||
).toThrow('mode mismatch')
|
||||
|
||||
writeFileSync(join(directory, 'node'), elf('arm64'))
|
||||
const manifest = JSON.parse(
|
||||
manifestBytes.toString('utf8')
|
||||
) as Manifest
|
||||
const nodeEntry = manifest.files.find(
|
||||
(file) => file.path === 'node'
|
||||
)
|
||||
expect(nodeEntry).toBeDefined()
|
||||
if (nodeEntry) {
|
||||
nodeEntry.size = 64
|
||||
nodeEntry.sha256 = createHash('sha256')
|
||||
.update(elf('arm64'))
|
||||
.digest('hex')
|
||||
}
|
||||
const updatedBytes =
|
||||
agentBundle.canonicalManifestBytes(manifest)
|
||||
writeFileSync(join(directory, 'manifest.json'), updatedBytes)
|
||||
writeFileSync(
|
||||
join(directory, 'manifest.sig'),
|
||||
`${agentBundle
|
||||
.signManifestForTest(updatedBytes, privateKey)
|
||||
.toString('base64')}\n`
|
||||
)
|
||||
expect(() =>
|
||||
agentBundle.verifyBundleDirectory(directory, {
|
||||
registry,
|
||||
verificationEnvironment: 'test'
|
||||
})
|
||||
).toThrow(/hash mismatch|architecture mismatch/u)
|
||||
})
|
||||
|
||||
it('rejects test keys in production verification', () => {
|
||||
const { manifestBytes } = writeFixtureBundle()
|
||||
const signature = Buffer.from(
|
||||
readFileSync(join(directory, 'manifest.sig'), 'utf8').trim(),
|
||||
'base64'
|
||||
)
|
||||
expect(() =>
|
||||
agentBundle.verifyManifestSignature(
|
||||
manifestBytes,
|
||||
signature,
|
||||
registry
|
||||
)
|
||||
).toThrow('rejects non-production key')
|
||||
})
|
||||
|
||||
it('rejects revoked keys', () => {
|
||||
const { manifestBytes } = writeFixtureBundle()
|
||||
const signature = Buffer.from(
|
||||
readFileSync(join(directory, 'manifest.sig'), 'utf8').trim(),
|
||||
'base64'
|
||||
)
|
||||
registry.revocations.push({
|
||||
keyId: 'fixture-test-key'
|
||||
})
|
||||
expect(() =>
|
||||
agentBundle.verifyManifestSignature(
|
||||
manifestBytes,
|
||||
signature,
|
||||
registry,
|
||||
{ verificationEnvironment: 'test' }
|
||||
)
|
||||
).toThrow('is revoked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent production input handling', () => {
|
||||
it('fails closed when the locked official Node runtime input is absent', () => {
|
||||
expect(() =>
|
||||
agentBundle.lockedRuntimeInput(
|
||||
{
|
||||
node: {
|
||||
version: '24.19.0',
|
||||
targets: {
|
||||
'linux-x64': {
|
||||
archive: 'node-v24.19.0-linux-x64.tar.gz',
|
||||
sha256: 'a'.repeat(64),
|
||||
binaryPath: 'node/bin/node',
|
||||
licensePath: 'node/LICENSE'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'x64',
|
||||
join(directory, 'missing.tar.gz')
|
||||
)
|
||||
).toThrow('Verified official Node 24.19.0 runtime input is required')
|
||||
})
|
||||
|
||||
it('requires CI-only production key variables and redacts them', () => {
|
||||
expect(() =>
|
||||
agentBundle.productionSigningKey({})
|
||||
).toThrow('required for production Agent signing')
|
||||
|
||||
const secret = [
|
||||
'-----BEGIN PRIVATE KEY-----',
|
||||
'fixture-secret-material',
|
||||
'-----END PRIVATE KEY-----'
|
||||
].join('\n')
|
||||
const error = `signing_key=${secret} key id prod-secret-id`
|
||||
const redacted = agentBundle.redactSecrets(error, {
|
||||
GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY: secret,
|
||||
GOODBUDDY_AGENT_SIGNING_KEY_ID: 'prod-secret-id'
|
||||
})
|
||||
expect(redacted).not.toContain('fixture-secret-material')
|
||||
expect(redacted).not.toContain('prod-secret-id')
|
||||
expect(redacted).toContain('[redacted]')
|
||||
})
|
||||
|
||||
it('fails closed when the production public key ID is not registered', () => {
|
||||
expect(() =>
|
||||
agentBundle.preflightProductionSigningKey({
|
||||
keyId: 'production-release-key',
|
||||
registry
|
||||
})
|
||||
).toThrow(
|
||||
'Production Agent public signing key ID "production-release-key" is absent from resources/agent-release-keys.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts only an enabled production registry entry', () => {
|
||||
const productionRegistry: KeyRegistry = {
|
||||
...registry,
|
||||
keys: [
|
||||
{
|
||||
...registry.keys[0]!,
|
||||
keyId: 'production-release-key',
|
||||
environment: 'production'
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(
|
||||
agentBundle.preflightProductionSigningKey({
|
||||
keyId: 'production-release-key',
|
||||
registry: productionRegistry
|
||||
}).keyId
|
||||
).toBe('production-release-key')
|
||||
})
|
||||
|
||||
it('detects both ELF byte orders through the shared helper', () => {
|
||||
const littleEndian = elf('arm64')
|
||||
const bigEndian = elf('x64')
|
||||
bigEndian[5] = 2
|
||||
bigEndian.writeUInt16BE(62, 18)
|
||||
|
||||
expect(
|
||||
agentBundle.detectElfArchitecture(littleEndian)
|
||||
).toBe('arm64')
|
||||
expect(
|
||||
agentBundle.detectElfArchitecture(bigEndian)
|
||||
).toBe('x64')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Architecture = 'x64' | 'arm64'
|
||||
|
||||
interface PortableBuilderModule {
|
||||
assertPortableOutput: (
|
||||
directory: string,
|
||||
architectures?: Architecture[]
|
||||
) => void
|
||||
discoverLocalAgentResources: (
|
||||
projectRoot: string
|
||||
) => Architecture[]
|
||||
embedPortableAgentResources: (
|
||||
directory: string,
|
||||
options: {
|
||||
projectRoot: string
|
||||
requiredArchitectures?: Architecture[]
|
||||
verifyLockedBundle?: (
|
||||
directory: string,
|
||||
architecture: Architecture,
|
||||
options: { projectRoot: string }
|
||||
) => void
|
||||
}
|
||||
) => Architecture[]
|
||||
parseRequiredAgentArchitectures: (
|
||||
value: string | undefined
|
||||
) => Architecture[]
|
||||
portableRequiredPaths: (
|
||||
directory: string,
|
||||
architectures?: Architecture[]
|
||||
) => string[]
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const portableBuilder = require(
|
||||
'../build/build-portable.cjs'
|
||||
) as PortableBuilderModule
|
||||
|
||||
let directory: string
|
||||
let projectRoot: string
|
||||
let unpackedDirectory: string
|
||||
|
||||
function createAgentResource(
|
||||
architecture: Architecture,
|
||||
contents: string = architecture
|
||||
): string {
|
||||
const resource = join(
|
||||
projectRoot,
|
||||
'.agent-resources',
|
||||
`linux-${architecture}`
|
||||
)
|
||||
mkdirSync(join(resource, 'lib'), { recursive: true })
|
||||
writeFileSync(join(resource, 'manifest.json'), contents)
|
||||
writeFileSync(join(resource, 'manifest.sig'), 'signature')
|
||||
writeFileSync(join(resource, 'goodbuddy-agent'), 'launcher')
|
||||
writeFileSync(join(resource, 'node'), 'runtime')
|
||||
writeFileSync(join(resource, 'lib', 'agent.cjs'), 'agent')
|
||||
return resource
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'goodbuddy-portable-test-'))
|
||||
projectRoot = join(directory, 'project')
|
||||
unpackedDirectory = join(directory, 'win-unpacked')
|
||||
mkdirSync(projectRoot, { recursive: true })
|
||||
mkdirSync(unpackedDirectory, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Portable Agent resources', () => {
|
||||
it('parses and deduplicates required architectures', () => {
|
||||
expect(
|
||||
portableBuilder.parseRequiredAgentArchitectures(undefined)
|
||||
).toEqual([])
|
||||
expect(
|
||||
portableBuilder.parseRequiredAgentArchitectures(
|
||||
' x64,arm64,x64 '
|
||||
)
|
||||
).toEqual(['x64', 'arm64'])
|
||||
expect(() =>
|
||||
portableBuilder.parseRequiredAgentArchitectures('')
|
||||
).toThrow('仅支持逗号分隔的 x64 和 arm64')
|
||||
expect(() =>
|
||||
portableBuilder.parseRequiredAgentArchitectures('x64,ia32')
|
||||
).toThrow('仅支持逗号分隔的 x64 和 arm64')
|
||||
})
|
||||
|
||||
it('discovers only local Agent resource directories', () => {
|
||||
createAgentResource('arm64')
|
||||
const nonDirectory = join(
|
||||
projectRoot,
|
||||
'.agent-resources',
|
||||
'linux-x64'
|
||||
)
|
||||
writeFileSync(nonDirectory, 'not a directory')
|
||||
|
||||
expect(
|
||||
portableBuilder.discoverLocalAgentResources(projectRoot)
|
||||
).toEqual(['arm64'])
|
||||
})
|
||||
|
||||
it('verifies every discovered bundle before copying it', () => {
|
||||
const x64Resource = createAgentResource('x64', 'x64 manifest')
|
||||
const arm64Resource = createAgentResource(
|
||||
'arm64',
|
||||
'arm64 manifest'
|
||||
)
|
||||
const verifyLockedBundle = vi.fn()
|
||||
|
||||
expect(
|
||||
portableBuilder.embedPortableAgentResources(
|
||||
unpackedDirectory,
|
||||
{
|
||||
projectRoot,
|
||||
requiredArchitectures: ['x64', 'arm64'],
|
||||
verifyLockedBundle
|
||||
}
|
||||
)
|
||||
).toEqual(['x64', 'arm64'])
|
||||
expect(verifyLockedBundle.mock.calls).toEqual([
|
||||
[x64Resource, 'x64', { projectRoot }],
|
||||
[arm64Resource, 'arm64', { projectRoot }]
|
||||
])
|
||||
expect(
|
||||
readFileSync(
|
||||
join(
|
||||
unpackedDirectory,
|
||||
'resources',
|
||||
'agents',
|
||||
'linux-x64',
|
||||
'manifest.json'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('x64 manifest')
|
||||
expect(
|
||||
readFileSync(
|
||||
join(
|
||||
unpackedDirectory,
|
||||
'resources',
|
||||
'agents',
|
||||
'linux-arm64',
|
||||
'manifest.json'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('arm64 manifest')
|
||||
})
|
||||
|
||||
it('copies no Agent bytes when strict verification fails', () => {
|
||||
createAgentResource('x64')
|
||||
createAgentResource('arm64')
|
||||
const verifyLockedBundle = vi.fn(
|
||||
(_resource: string, architecture: Architecture) => {
|
||||
if (architecture === 'arm64') {
|
||||
throw new Error('verification failed')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
portableBuilder.embedPortableAgentResources(
|
||||
unpackedDirectory,
|
||||
{
|
||||
projectRoot,
|
||||
verifyLockedBundle
|
||||
}
|
||||
)
|
||||
).toThrow('verification failed')
|
||||
expect(
|
||||
existsSync(
|
||||
join(unpackedDirectory, 'resources', 'agents')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('fails when a required architecture is absent', () => {
|
||||
createAgentResource('x64')
|
||||
const verifyLockedBundle = vi.fn()
|
||||
|
||||
expect(() =>
|
||||
portableBuilder.embedPortableAgentResources(
|
||||
unpackedDirectory,
|
||||
{
|
||||
projectRoot,
|
||||
requiredArchitectures: ['x64', 'arm64'],
|
||||
verifyLockedBundle
|
||||
}
|
||||
)
|
||||
).toThrow('linux-arm64')
|
||||
expect(verifyLockedBundle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves metadata-only packaging when no resource exists', () => {
|
||||
const verifyLockedBundle = vi.fn()
|
||||
|
||||
expect(
|
||||
portableBuilder.embedPortableAgentResources(
|
||||
unpackedDirectory,
|
||||
{ projectRoot, verifyLockedBundle }
|
||||
)
|
||||
).toEqual([])
|
||||
expect(verifyLockedBundle).not.toHaveBeenCalled()
|
||||
expect(
|
||||
existsSync(
|
||||
join(unpackedDirectory, 'resources', 'agents')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
portableBuilder.portableRequiredPaths(unpackedDirectory)
|
||||
).toContain(
|
||||
join(
|
||||
unpackedDirectory,
|
||||
'resources',
|
||||
'agent-runtime-lock.json'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('includes embedded Agent payloads in output assertions', () => {
|
||||
const requiredPaths = portableBuilder.portableRequiredPaths(
|
||||
unpackedDirectory,
|
||||
['arm64']
|
||||
)
|
||||
const missingPath = join(
|
||||
unpackedDirectory,
|
||||
'resources',
|
||||
'agents',
|
||||
'linux-arm64',
|
||||
'lib',
|
||||
'agent.cjs'
|
||||
)
|
||||
for (const requiredPath of requiredPaths) {
|
||||
if (requiredPath === missingPath) {
|
||||
continue
|
||||
}
|
||||
mkdirSync(dirname(requiredPath), { recursive: true })
|
||||
writeFileSync(requiredPath, 'required')
|
||||
}
|
||||
|
||||
expect(requiredPaths).toContain(
|
||||
join(
|
||||
unpackedDirectory,
|
||||
'resources',
|
||||
'agents',
|
||||
'linux-arm64',
|
||||
'manifest.json'
|
||||
)
|
||||
)
|
||||
expect(() =>
|
||||
portableBuilder.assertPortableOutput(
|
||||
unpackedDirectory,
|
||||
['arm64']
|
||||
)
|
||||
).toThrow(missingPath)
|
||||
mkdirSync(dirname(missingPath), { recursive: true })
|
||||
writeFileSync(missingPath, 'agent')
|
||||
expect(() =>
|
||||
portableBuilder.assertPortableOutput(
|
||||
unpackedDirectory,
|
||||
['arm64']
|
||||
)
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
+549
-2
@@ -4,6 +4,7 @@ import {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
@@ -11,7 +12,7 @@ import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Writable } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface ReleaseOptions {
|
||||
platform: 'windows' | 'macos' | 'linux'
|
||||
@@ -23,6 +24,17 @@ interface ReleaseOptions {
|
||||
help: boolean
|
||||
}
|
||||
|
||||
type VerifyRemoteRuntimeBundle = (
|
||||
bundleDirectory: string,
|
||||
options: {
|
||||
projectRoot: string
|
||||
architecture: 'x64' | 'arm64'
|
||||
registry: Record<string, unknown>
|
||||
lock: Record<string, unknown>
|
||||
verificationEnvironment: 'production'
|
||||
}
|
||||
) => void
|
||||
|
||||
interface ReleaseBuilderModule {
|
||||
assertReplaceableOutput: (
|
||||
directory: string,
|
||||
@@ -150,6 +162,27 @@ interface ReleaseBuilderModule {
|
||||
options: ReleaseOptions
|
||||
) => void
|
||||
verifyPortableZip: (filePath: string) => void
|
||||
verifyReleaseAgentResources: (
|
||||
projectRoot?: string,
|
||||
verifyBundleMatrix?: (
|
||||
bundleRoot: string,
|
||||
options: { projectRoot: string }
|
||||
) => void
|
||||
) => void
|
||||
verifyReleaseRemoteRuntimeResources: (
|
||||
projectRoot?: string,
|
||||
verifyBundleMatrix?: (
|
||||
bundleRoot: string,
|
||||
options: { projectRoot: string }
|
||||
) => void
|
||||
) => void
|
||||
verifyRemoteRuntimeBundleMatrix: (
|
||||
resourcesRoot: string,
|
||||
options: {
|
||||
projectRoot: string
|
||||
verifyBundleDirectory: VerifyRemoteRuntimeBundle
|
||||
}
|
||||
) => void
|
||||
verifyArchiveIntegrity: (
|
||||
filePath: string,
|
||||
expectedIntegrity: string
|
||||
@@ -173,6 +206,22 @@ const packageVersion = (
|
||||
const releaseBuilder = require(
|
||||
'../build/build-release.cjs'
|
||||
) as ReleaseBuilderModule
|
||||
const releaseConfig = require(
|
||||
'../build/electron-builder.release.cjs'
|
||||
) as {
|
||||
extraResources: Array<{
|
||||
from: string
|
||||
to?: string
|
||||
filter?: string[]
|
||||
}>
|
||||
}
|
||||
const prepareBundledRuntimes = require(
|
||||
'../build/runtime-hooks.cjs'
|
||||
) as (context: {
|
||||
electronPlatformName: string
|
||||
arch: number
|
||||
packager: { projectDir: string }
|
||||
}) => Promise<void>
|
||||
const windowsOptions: ReleaseOptions = {
|
||||
platform: 'windows',
|
||||
arch: 'x64',
|
||||
@@ -183,6 +232,60 @@ const windowsOptions: ReleaseOptions = {
|
||||
help: false
|
||||
}
|
||||
|
||||
function createRemoteRuntimeMatrixFixture(): {
|
||||
projectRoot: string
|
||||
resourcesRoot: string
|
||||
options: {
|
||||
projectRoot: string
|
||||
verifyBundleDirectory: VerifyRemoteRuntimeBundle
|
||||
}
|
||||
cleanup: () => void
|
||||
} {
|
||||
const projectRoot = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-release-runtime-')
|
||||
)
|
||||
const resourcesRoot = join(
|
||||
projectRoot,
|
||||
'.remote-runtime-resources'
|
||||
)
|
||||
mkdirSync(join(projectRoot, 'resources'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(projectRoot, 'resources', 'agent-release-keys.json'),
|
||||
JSON.stringify({ formatVersion: 1 })
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectRoot, 'remote-runtime-lock.json'),
|
||||
JSON.stringify({
|
||||
formatVersion: 1,
|
||||
runtimes: {
|
||||
opencode: {
|
||||
targets: {
|
||||
x64: {
|
||||
package: 'opencode-linux-x64-baseline',
|
||||
integrity: 'sha512-x64'
|
||||
},
|
||||
arm64: {
|
||||
package: 'opencode-linux-arm64',
|
||||
integrity: 'sha512-arm64'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
return {
|
||||
projectRoot,
|
||||
resourcesRoot,
|
||||
options: {
|
||||
projectRoot,
|
||||
verifyBundleDirectory: vi.fn<VerifyRemoteRuntimeBundle>()
|
||||
},
|
||||
cleanup: () => {
|
||||
rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pe(machine: number): Buffer {
|
||||
const buffer = Buffer.alloc(256)
|
||||
buffer.write('MZ', 0, 'ascii')
|
||||
@@ -358,6 +461,10 @@ describe('release build arguments', () => {
|
||||
|
||||
expect(arguments_).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--config',
|
||||
expect.stringMatching(
|
||||
/build[\\/]electron-builder\.release\.cjs$/u
|
||||
),
|
||||
'--win',
|
||||
'nsis',
|
||||
'dir',
|
||||
@@ -376,6 +483,278 @@ describe('release build arguments', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('verifies both Agent bundles through the release preflight', () => {
|
||||
const projectRoot = join('workspace', 'goodbuddy')
|
||||
const verifyBundleMatrix = vi.fn()
|
||||
|
||||
releaseBuilder.verifyReleaseAgentResources(
|
||||
projectRoot,
|
||||
verifyBundleMatrix
|
||||
)
|
||||
|
||||
expect(verifyBundleMatrix).toHaveBeenCalledOnce()
|
||||
expect(verifyBundleMatrix).toHaveBeenCalledWith(
|
||||
join(projectRoot, '.agent-resources'),
|
||||
{ projectRoot }
|
||||
)
|
||||
expect(() =>
|
||||
releaseBuilder.verifyReleaseAgentResources(
|
||||
projectRoot,
|
||||
() => {
|
||||
throw new Error('invalid Agent bundle')
|
||||
}
|
||||
)
|
||||
).toThrow('invalid Agent bundle')
|
||||
})
|
||||
|
||||
it('verifies both remote Runtime bundles through the release preflight', () => {
|
||||
const projectRoot = join('workspace', 'goodbuddy')
|
||||
const verifyBundleMatrix = vi.fn()
|
||||
|
||||
releaseBuilder.verifyReleaseRemoteRuntimeResources(
|
||||
projectRoot,
|
||||
verifyBundleMatrix
|
||||
)
|
||||
|
||||
expect(verifyBundleMatrix).toHaveBeenCalledOnce()
|
||||
expect(verifyBundleMatrix).toHaveBeenCalledWith(
|
||||
join(projectRoot, '.remote-runtime-resources'),
|
||||
{ projectRoot }
|
||||
)
|
||||
expect(() =>
|
||||
releaseBuilder.verifyReleaseRemoteRuntimeResources(
|
||||
projectRoot,
|
||||
() => {
|
||||
throw new Error('invalid remote Runtime bundle')
|
||||
}
|
||||
)
|
||||
).toThrow('invalid remote Runtime bundle')
|
||||
})
|
||||
|
||||
it('discovers one canonical remote Runtime digest directory per architecture', () => {
|
||||
const projectRoot = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-release-runtime-')
|
||||
)
|
||||
const resourcesRoot = join(
|
||||
projectRoot,
|
||||
'.remote-runtime-resources'
|
||||
)
|
||||
const digests = {
|
||||
x64: '1'.repeat(64),
|
||||
arm64: '2'.repeat(64)
|
||||
}
|
||||
const registry = { formatVersion: 1 }
|
||||
const lock = {
|
||||
formatVersion: 1,
|
||||
runtimes: {
|
||||
opencode: {
|
||||
targets: {
|
||||
x64: {
|
||||
package: 'opencode-linux-x64-baseline',
|
||||
integrity: 'sha512-x64'
|
||||
},
|
||||
arm64: {
|
||||
package: 'opencode-linux-arm64',
|
||||
integrity: 'sha512-arm64'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const verifyBundleDirectory = vi.fn()
|
||||
|
||||
try {
|
||||
mkdirSync(join(projectRoot, 'resources'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(projectRoot, 'resources', 'agent-release-keys.json'),
|
||||
JSON.stringify(registry)
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectRoot, 'remote-runtime-lock.json'),
|
||||
JSON.stringify(lock)
|
||||
)
|
||||
for (const architecture of ['x64', 'arm64'] as const) {
|
||||
mkdirSync(
|
||||
join(
|
||||
resourcesRoot,
|
||||
`linux-${architecture}`,
|
||||
'opencode',
|
||||
digests[architecture]
|
||||
),
|
||||
{ recursive: true }
|
||||
)
|
||||
}
|
||||
|
||||
releaseBuilder.verifyRemoteRuntimeBundleMatrix(
|
||||
resourcesRoot,
|
||||
{ projectRoot, verifyBundleDirectory }
|
||||
)
|
||||
|
||||
expect(verifyBundleDirectory).toHaveBeenCalledTimes(2)
|
||||
for (const architecture of ['x64', 'arm64'] as const) {
|
||||
expect(verifyBundleDirectory).toHaveBeenCalledWith(
|
||||
join(
|
||||
resourcesRoot,
|
||||
`linux-${architecture}`,
|
||||
'opencode',
|
||||
digests[architecture]
|
||||
),
|
||||
{
|
||||
projectRoot,
|
||||
architecture,
|
||||
registry,
|
||||
lock,
|
||||
verificationEnvironment: 'production'
|
||||
}
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a missing remote Runtime digest directory', () => {
|
||||
const fixture = createRemoteRuntimeMatrixFixture()
|
||||
|
||||
try {
|
||||
mkdirSync(
|
||||
join(
|
||||
fixture.resourcesRoot,
|
||||
'linux-x64',
|
||||
'opencode'
|
||||
),
|
||||
{ recursive: true }
|
||||
)
|
||||
expect(() =>
|
||||
releaseBuilder.verifyRemoteRuntimeBundleMatrix(
|
||||
fixture.resourcesRoot,
|
||||
fixture.options
|
||||
)
|
||||
).toThrow(
|
||||
/must contain exactly one digest directory for x64; found 0/u
|
||||
)
|
||||
} finally {
|
||||
fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects multiple remote Runtime digest directories', () => {
|
||||
const fixture = createRemoteRuntimeMatrixFixture()
|
||||
const runtimeRoot = join(
|
||||
fixture.resourcesRoot,
|
||||
'linux-x64',
|
||||
'opencode'
|
||||
)
|
||||
|
||||
try {
|
||||
mkdirSync(join(runtimeRoot, '1'.repeat(64)), {
|
||||
recursive: true
|
||||
})
|
||||
mkdirSync(join(runtimeRoot, '2'.repeat(64)))
|
||||
|
||||
expect(() =>
|
||||
releaseBuilder.verifyRemoteRuntimeBundleMatrix(
|
||||
fixture.resourcesRoot,
|
||||
fixture.options
|
||||
)
|
||||
).toThrow(
|
||||
/must contain exactly one digest directory for x64; found 2/u
|
||||
)
|
||||
} finally {
|
||||
fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects malformed entries in a remote Runtime digest root', () => {
|
||||
const fixture = createRemoteRuntimeMatrixFixture()
|
||||
const runtimeRoot = join(
|
||||
fixture.resourcesRoot,
|
||||
'linux-x64',
|
||||
'opencode'
|
||||
)
|
||||
|
||||
try {
|
||||
mkdirSync(join(runtimeRoot, 'NOT-A-CANONICAL-DIGEST'), {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
releaseBuilder.verifyRemoteRuntimeBundleMatrix(
|
||||
fixture.resourcesRoot,
|
||||
fixture.options
|
||||
)
|
||||
).toThrow(
|
||||
/contains an unexpected entry for x64: NOT-A-CANONICAL-DIGEST/u
|
||||
)
|
||||
} finally {
|
||||
fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-directory remote Runtime digest entries', () => {
|
||||
const fixture = createRemoteRuntimeMatrixFixture()
|
||||
const runtimeRoot = join(
|
||||
fixture.resourcesRoot,
|
||||
'linux-x64',
|
||||
'opencode'
|
||||
)
|
||||
const digest = '1'.repeat(64)
|
||||
|
||||
try {
|
||||
mkdirSync(runtimeRoot, { recursive: true })
|
||||
writeFileSync(join(runtimeRoot, digest), 'not a bundle')
|
||||
|
||||
expect(() =>
|
||||
releaseBuilder.verifyRemoteRuntimeBundleMatrix(
|
||||
fixture.resourcesRoot,
|
||||
fixture.options
|
||||
)
|
||||
).toThrow(
|
||||
new RegExp(
|
||||
`entry must be a real directory for x64: ${digest}`,
|
||||
'u'
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects symlinked remote Runtime digest directories', () => {
|
||||
const fixture = createRemoteRuntimeMatrixFixture()
|
||||
const runtimeRoot = join(
|
||||
fixture.resourcesRoot,
|
||||
'linux-x64',
|
||||
'opencode'
|
||||
)
|
||||
const digest = '1'.repeat(64)
|
||||
const target = join(fixture.projectRoot, 'runtime-target')
|
||||
|
||||
try {
|
||||
mkdirSync(runtimeRoot, { recursive: true })
|
||||
mkdirSync(target)
|
||||
symlinkSync(
|
||||
target,
|
||||
join(runtimeRoot, digest),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
releaseBuilder.verifyRemoteRuntimeBundleMatrix(
|
||||
fixture.resourcesRoot,
|
||||
fixture.options
|
||||
)
|
||||
).toThrow(
|
||||
new RegExp(
|
||||
`entry must be a real directory for x64: ${digest}`,
|
||||
'u'
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('explicitly disables notarization for unsigned macOS packages', () => {
|
||||
const arguments_ =
|
||||
releaseBuilder.buildElectronBuilderArguments(
|
||||
@@ -731,7 +1110,7 @@ describe('release build arguments', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Web3D test fixtures out of release resources', () => {
|
||||
it('keeps installable Agent resources out of ordinary desktop packages', () => {
|
||||
const packageJson = require('../package.json') as {
|
||||
build: {
|
||||
files: string[]
|
||||
@@ -757,6 +1136,21 @@ describe('release build arguments', () => {
|
||||
to: 'runtimes',
|
||||
filter: ['npm{,/**/*}']
|
||||
})
|
||||
expect(resourceSources).toContain(
|
||||
'resources/agent-release-keys.json'
|
||||
)
|
||||
expect(resourceSources).toContain('agent-runtime-lock.json')
|
||||
expect(resourceSources).toContain('remote-runtime-lock.json')
|
||||
expect(
|
||||
resourceSources.some((source) =>
|
||||
source.startsWith('.agent-resources/')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
resourceSources.some((source) =>
|
||||
source.startsWith('.remote-runtime-resources/')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
resourceSources.some((source) => source.startsWith('tests/'))
|
||||
).toBe(false)
|
||||
@@ -777,6 +1171,159 @@ describe('release build arguments', () => {
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('adds the complete Agent matrix only to release packages', () => {
|
||||
const resourceEntries = releaseConfig.extraResources
|
||||
|
||||
expect(resourceEntries).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
from: 'resources/agent-release-keys.json',
|
||||
to: 'agent-release-keys.json'
|
||||
},
|
||||
{
|
||||
from: 'agent-runtime-lock.json',
|
||||
to: 'agent-runtime-lock.json'
|
||||
},
|
||||
{
|
||||
from: '.agent-resources/linux-x64',
|
||||
to: 'agents/linux-x64',
|
||||
filter: ['**/*']
|
||||
},
|
||||
{
|
||||
from: '.agent-resources/linux-arm64',
|
||||
to: 'agents/linux-arm64',
|
||||
filter: ['**/*']
|
||||
},
|
||||
{
|
||||
from: 'remote-runtime-lock.json',
|
||||
to: 'remote-runtime-lock.json'
|
||||
},
|
||||
{
|
||||
from: '.remote-runtime-resources/linux-x64',
|
||||
to: 'remote-runtimes/linux-x64',
|
||||
filter: ['**/*']
|
||||
},
|
||||
{
|
||||
from: '.remote-runtime-resources/linux-arm64',
|
||||
to: 'remote-runtimes/linux-arm64',
|
||||
filter: ['**/*']
|
||||
}
|
||||
])
|
||||
)
|
||||
expect(
|
||||
resourceEntries.filter((entry) =>
|
||||
entry.from.startsWith('.agent-resources/')
|
||||
)
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
resourceEntries.filter((entry) =>
|
||||
entry.from.startsWith('.remote-runtime-resources/')
|
||||
)
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
resourceEntries.filter(
|
||||
(entry) => entry.from === 'agent-runtime-lock.json'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
resourceEntries.filter(
|
||||
(entry) => entry.from === 'remote-runtime-lock.json'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
resourceEntries.filter(
|
||||
(entry) =>
|
||||
entry.from === 'resources/agent-release-keys.json'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prepares desktop Runtimes without Agent artifacts', async () => {
|
||||
const projectRoot = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-runtime-hook-')
|
||||
)
|
||||
const packageName = 'opencode-windows-x64-baseline'
|
||||
const integrity = 'sha512-test-integrity'
|
||||
const executable = Buffer.from('desktop runtime')
|
||||
try {
|
||||
mkdirSync(join(projectRoot, 'out', 'main'), {
|
||||
recursive: true
|
||||
})
|
||||
mkdirSync(
|
||||
join(projectRoot, '.runtime-resources', 'x64'),
|
||||
{ recursive: true }
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectRoot, 'package.json'),
|
||||
JSON.stringify({
|
||||
dependencies: {
|
||||
'@deepseek-ai/dsh-llm': '0.1.0-rc.6'
|
||||
}
|
||||
})
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectRoot, 'package-lock.json'),
|
||||
JSON.stringify({
|
||||
packages: {
|
||||
[`node_modules/${packageName}`]: {
|
||||
version: '1.18.9',
|
||||
integrity
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
writeFileSync(
|
||||
join(
|
||||
projectRoot,
|
||||
'.runtime-resources',
|
||||
'x64',
|
||||
'opencode.exe'
|
||||
),
|
||||
executable
|
||||
)
|
||||
writeFileSync(
|
||||
join(
|
||||
projectRoot,
|
||||
'.runtime-resources',
|
||||
'x64',
|
||||
'.ready.json'
|
||||
),
|
||||
JSON.stringify({
|
||||
packageName,
|
||||
version: '1.18.9',
|
||||
integrity,
|
||||
executableSha256: createHash('sha256')
|
||||
.update(executable)
|
||||
.digest('hex')
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
prepareBundledRuntimes({
|
||||
electronPlatformName: 'win32',
|
||||
arch: 1,
|
||||
packager: { projectDir: projectRoot }
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
expect(
|
||||
existsSync(join(projectRoot, '.agent-resources'))
|
||||
).toBe(false)
|
||||
expect(
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(projectRoot, 'out', 'main', 'package.json'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
).toMatchObject({
|
||||
name: '@deepseek-ai/dsh-llm',
|
||||
version: '0.1.0-rc.6'
|
||||
})
|
||||
} finally {
|
||||
rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('release binary architecture detection', () => {
|
||||
|
||||
@@ -7,6 +7,72 @@ const workflow = readFileSync(
|
||||
join(process.cwd(), '.github', 'workflows', 'packages.yml'),
|
||||
'utf8'
|
||||
)
|
||||
const runtimeLock = JSON.parse(
|
||||
readFileSync(
|
||||
join(process.cwd(), 'agent-runtime-lock.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as {
|
||||
node: {
|
||||
version: string
|
||||
source: string
|
||||
targets: Record<
|
||||
string,
|
||||
{
|
||||
archive: string
|
||||
sha256: string
|
||||
}
|
||||
>
|
||||
}
|
||||
}
|
||||
const parsedWorkflow = parse(workflow) as {
|
||||
jobs: Record<
|
||||
string,
|
||||
{
|
||||
needs?: string | string[]
|
||||
strategy?: {
|
||||
matrix?: {
|
||||
include?: Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
steps?: Array<{
|
||||
name?: string
|
||||
run?: string
|
||||
uses?: string
|
||||
with?: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
function serializedJob(name: string): string {
|
||||
return JSON.stringify(parsedWorkflow.jobs[name])
|
||||
}
|
||||
|
||||
function jobScripts(name: string): string {
|
||||
return (
|
||||
parsedWorkflow.jobs[name]?.steps
|
||||
?.map((step) => step.run ?? '')
|
||||
.join('\n') ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function jobSteps(
|
||||
name: string
|
||||
): NonNullable<
|
||||
(typeof parsedWorkflow.jobs)[string]['steps']
|
||||
> {
|
||||
return parsedWorkflow.jobs[name]?.steps ?? []
|
||||
}
|
||||
|
||||
function jobNeeds(name: string): string[] {
|
||||
const needs = parsedWorkflow.jobs[name]?.needs
|
||||
return needs === undefined
|
||||
? []
|
||||
: Array.isArray(needs)
|
||||
? needs
|
||||
: [needs]
|
||||
}
|
||||
|
||||
describe('packages workflow', () => {
|
||||
it('uses valid YAML and Node.js 24 GitHub Actions', () => {
|
||||
@@ -27,6 +93,229 @@ describe('packages workflow', () => {
|
||||
'actions/download-artifact@v8'
|
||||
])
|
||||
)
|
||||
const setupNodeVersions = Object.values(parsedWorkflow.jobs)
|
||||
.flatMap((job) => job.steps ?? [])
|
||||
.filter((step) => step.uses === 'actions/setup-node@v7')
|
||||
.map((step) => step.with?.['node-version'])
|
||||
|
||||
expect(setupNodeVersions.length).toBeGreaterThanOrEqual(4)
|
||||
expect(new Set(setupNodeVersions)).toEqual(
|
||||
new Set([runtimeLock.node.version])
|
||||
)
|
||||
})
|
||||
|
||||
it('builds signed Agent bundles natively before the release package matrix', () => {
|
||||
const runtimeJob = serializedJob('agent-runtime')
|
||||
const signingJob = serializedJob('agent-signing')
|
||||
const signingMatrix =
|
||||
parsedWorkflow.jobs['agent-signing']?.strategy?.matrix
|
||||
?.include ?? []
|
||||
const packageDownload = jobSteps('package').find(
|
||||
(step) =>
|
||||
step.uses === 'actions/download-artifact@v8' &&
|
||||
step.with?.pattern !== undefined
|
||||
)
|
||||
|
||||
expect(signingMatrix).toContainEqual(
|
||||
expect.objectContaining({
|
||||
arch: 'arm64',
|
||||
runner: 'ubuntu-24.04-arm'
|
||||
})
|
||||
)
|
||||
expect(signingJob).toContain(
|
||||
'GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY'
|
||||
)
|
||||
expect(signingJob).toContain(
|
||||
'Preflight production Agent signing registry'
|
||||
)
|
||||
expect(signingJob).toContain(
|
||||
'node build/agent-bundle.cjs preflight'
|
||||
)
|
||||
for (const target of Object.values(runtimeLock.node.targets)) {
|
||||
expect(runtimeJob).toContain(target.archive)
|
||||
}
|
||||
expect(packageDownload?.with?.pattern).toBe(
|
||||
'goodbuddy-agent-linux-*'
|
||||
)
|
||||
const packageScripts = jobScripts('package')
|
||||
for (const targetName of Object.keys(runtimeLock.node.targets)) {
|
||||
expect(packageScripts).toContain(
|
||||
`--archive dist/agent-downloads/goodbuddy-agent-${targetName}.tar`
|
||||
)
|
||||
}
|
||||
expect(jobNeeds('package')).toEqual(
|
||||
expect.arrayContaining(['validate', 'agent-signing'])
|
||||
)
|
||||
})
|
||||
|
||||
it('imports Agent artifacts before invoking the explicit release packager', () => {
|
||||
const scripts = jobScripts('package')
|
||||
const importIndex = scripts.indexOf(
|
||||
'node build/agent-bundle.cjs import'
|
||||
)
|
||||
const releaseIndex = scripts.indexOf(
|
||||
'npm run release:package'
|
||||
)
|
||||
|
||||
expect(importIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(releaseIndex).toBeGreaterThan(importIndex)
|
||||
expect(scripts).not.toContain('npm run dist')
|
||||
expect(scripts).not.toContain('npm run portable')
|
||||
})
|
||||
|
||||
it('authorizes signing only for protected-main commits and matching immutable version tags', () => {
|
||||
const authorization = serializedJob(
|
||||
'authorize-agent-signing'
|
||||
)
|
||||
const policy = jobScripts('authorize-agent-signing')
|
||||
|
||||
expect(authorization).toContain(
|
||||
"github.ref == 'refs/heads/main'"
|
||||
)
|
||||
expect(policy).toContain(
|
||||
'git merge-base --is-ancestor'
|
||||
)
|
||||
expect(policy).toContain(
|
||||
"require('./package.json').version"
|
||||
)
|
||||
expect(policy).toContain(
|
||||
'GITHUB_EVENT_PATH).created'
|
||||
)
|
||||
expect(policy).toContain(
|
||||
'GITHUB_EVENT_PATH).forced'
|
||||
)
|
||||
expect(policy).toContain(
|
||||
'GITHUB_EVENT_PATH).deleted'
|
||||
)
|
||||
expect(policy).toContain(
|
||||
'test "$GITHUB_REF" = "refs/tags/$expected"'
|
||||
)
|
||||
expect(policy).toContain(
|
||||
'git cat-file -t "refs/tags/$GITHUB_REF_NAME"'
|
||||
)
|
||||
expect(policy).toContain(
|
||||
'refs/tags/$GITHUB_REF_NAME^{commit}'
|
||||
)
|
||||
expect(policy).toContain(
|
||||
'test "$GITHUB_REF" = "refs/heads/main"'
|
||||
)
|
||||
expect(policy).toContain(
|
||||
'test "$GITHUB_SHA" = "$(git rev-parse refs/remotes/origin/main)"'
|
||||
)
|
||||
expect(policy).toContain(
|
||||
'echo "authorized=true" >> "$GITHUB_OUTPUT"'
|
||||
)
|
||||
})
|
||||
|
||||
it('gates the only Agent private-key step behind the protected signing environment', () => {
|
||||
const signing = serializedJob('agent-signing')
|
||||
|
||||
expect(signing).toContain('"name":"agent-signing"')
|
||||
expect(signing).toContain(
|
||||
'"authorize-agent-signing"'
|
||||
)
|
||||
expect(signing).toContain(
|
||||
"needs.authorize-agent-signing.outputs.authorized == 'true'"
|
||||
)
|
||||
expect(signing).toContain(
|
||||
'${{ secrets.GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY }}'
|
||||
)
|
||||
expect(signing.indexOf('Preflight production Agent signing registry'))
|
||||
.toBeLessThan(
|
||||
signing.indexOf(
|
||||
'${{ secrets.GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY }}'
|
||||
)
|
||||
)
|
||||
expect(signing).not.toContain('"cache"')
|
||||
expect(signing).not.toContain('actions/cache')
|
||||
expect(
|
||||
workflow.match(
|
||||
/\$\{\{\s*secrets\.GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY\s*\}\}/gu
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps production signing inputs out of arbitrary-ref jobs', () => {
|
||||
for (const name of [
|
||||
'validate',
|
||||
'authorize-agent-signing',
|
||||
'agent-runtime'
|
||||
]) {
|
||||
expect(serializedJob(name)).not.toContain('secrets.')
|
||||
expect(serializedJob(name)).not.toContain(
|
||||
'GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY'
|
||||
)
|
||||
}
|
||||
|
||||
expect(serializedJob('validate')).not.toContain(
|
||||
'GOODBUDDY_AGENT_SIGNING_KEY_ID'
|
||||
)
|
||||
expect(serializedJob('authorize-agent-signing')).not.toContain(
|
||||
'GOODBUDDY_AGENT_SIGNING_KEY_ID'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves unsigned validation while rejecting arbitrary refs from release jobs', () => {
|
||||
const validation = serializedJob('validate')
|
||||
const authorization = serializedJob(
|
||||
'authorize-agent-signing'
|
||||
)
|
||||
|
||||
expect(validation).toContain('npm test')
|
||||
expect(validation).toContain('npm run typecheck')
|
||||
expect(validation).toContain('npm run lint')
|
||||
expect(validation).toContain('npm run build:bundle')
|
||||
expect(authorization).toContain(
|
||||
"github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'"
|
||||
)
|
||||
expect(authorization).not.toContain(
|
||||
"github.event_name == 'workflow_dispatch' || github.ref_type == 'tag'"
|
||||
)
|
||||
expect(serializedJob('package')).toContain(
|
||||
"needs.authorize-agent-signing.outputs.authorized == 'true'"
|
||||
)
|
||||
})
|
||||
|
||||
it('acquires every locked Agent runtime securely on cold runners', () => {
|
||||
const runtimeJob = serializedJob('agent-runtime')
|
||||
const runtimeScripts = jobScripts('agent-runtime')
|
||||
const signingJob = serializedJob('agent-signing')
|
||||
const runtimeUses = jobSteps('agent-runtime')
|
||||
.map((step) => step.uses)
|
||||
.filter((value) => value !== undefined)
|
||||
const runtimeUpload = jobSteps('agent-runtime').find(
|
||||
(step) =>
|
||||
step.uses === 'actions/upload-artifact@v7'
|
||||
)
|
||||
|
||||
for (const target of Object.values(runtimeLock.node.targets)) {
|
||||
expect(runtimeJob).toContain(
|
||||
new URL(target.archive, runtimeLock.node.source).toString()
|
||||
)
|
||||
expect(runtimeJob).toContain(target.sha256)
|
||||
}
|
||||
expect(runtimeScripts).toContain(
|
||||
"curl --proto '=https' --tlsv1.2"
|
||||
)
|
||||
expect(runtimeScripts).toContain('--max-filesize 104857600')
|
||||
expect(runtimeScripts).toContain('sha256sum --check --strict -')
|
||||
expect(runtimeUses).toEqual(
|
||||
expect.arrayContaining([
|
||||
'actions/cache/restore@v6',
|
||||
'actions/cache/save@v6'
|
||||
])
|
||||
)
|
||||
expect(runtimeUpload?.with?.name).toBe(
|
||||
'goodbuddy-agent-node-${{ matrix.arch }}'
|
||||
)
|
||||
expect(runtimeJob).toContain(
|
||||
'steps.node-runtime-cache.outputs.cache-hit != \'true\''
|
||||
)
|
||||
expect(runtimeJob).not.toContain('fail-on-cache-miss: true')
|
||||
expect(jobNeeds('agent-signing')).toEqual(
|
||||
expect.arrayContaining(['validate', 'agent-runtime'])
|
||||
)
|
||||
expect(signingJob).toContain('agent-runtime')
|
||||
})
|
||||
|
||||
it('passes the endpoint-derived region to every OSS upload', () => {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import {
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
type KeyObject
|
||||
} from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { c as createTar } from 'tar'
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest'
|
||||
import type {
|
||||
AgentReleaseKeyRegistry
|
||||
} from '../src/shared/agent-installation-contracts'
|
||||
import {
|
||||
digestRemoteRuntimeBundleIdentity,
|
||||
type RemoteRuntimeBundleManifest,
|
||||
type RemoteRuntimeLock
|
||||
} from '../src/shared/remote-runtime-launch-contracts'
|
||||
|
||||
type VerifiedBundle = {
|
||||
bundleDirectory: string
|
||||
manifest: RemoteRuntimeBundleManifest
|
||||
manifestDigest: string
|
||||
}
|
||||
|
||||
type RemoteRuntimeBundleModule = {
|
||||
canonicalJson(value: unknown): string
|
||||
profileDigests: {
|
||||
adapter: string
|
||||
acp: string
|
||||
}
|
||||
buildRuntimeBundle(options: {
|
||||
projectRoot: string
|
||||
architecture: 'x64' | 'arm64'
|
||||
runtimeArchive: string
|
||||
outputRoot: string
|
||||
lock: RemoteRuntimeLock
|
||||
registry: AgentReleaseKeyRegistry
|
||||
testSigningIdentity: {
|
||||
keyId: string
|
||||
privateKey: KeyObject
|
||||
}
|
||||
enforceFilesystemMode?: boolean
|
||||
}): VerifiedBundle
|
||||
createRuntimeArchive(
|
||||
bundleDirectory: string,
|
||||
archivePath: string
|
||||
): void
|
||||
importRuntimeArchive(
|
||||
archivePath: string,
|
||||
options: {
|
||||
projectRoot: string
|
||||
architecture: 'x64' | 'arm64'
|
||||
outputRoot: string
|
||||
lock: RemoteRuntimeLock
|
||||
registry: AgentReleaseKeyRegistry
|
||||
verificationEnvironment: 'test'
|
||||
enforceFilesystemMode?: boolean
|
||||
}
|
||||
): VerifiedBundle
|
||||
preflightProductionSigningKey(options: {
|
||||
projectRoot: string
|
||||
environment: NodeJS.ProcessEnv
|
||||
lock: RemoteRuntimeLock
|
||||
registry: AgentReleaseKeyRegistry
|
||||
}): unknown
|
||||
verifyBundleDirectory(
|
||||
bundleDirectory: string,
|
||||
options: {
|
||||
projectRoot: string
|
||||
architecture: 'x64' | 'arm64'
|
||||
lock: RemoteRuntimeLock
|
||||
registry: AgentReleaseKeyRegistry
|
||||
verificationEnvironment: 'test'
|
||||
enforceFilesystemMode?: boolean
|
||||
}
|
||||
): VerifiedBundle
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const runtimeBundle = require(
|
||||
'../build/remote-runtime-bundle.cjs'
|
||||
) as RemoteRuntimeBundleModule
|
||||
|
||||
let temporaryRoot = ''
|
||||
let runtimeArchive = ''
|
||||
let lock: RemoteRuntimeLock
|
||||
let registry: AgentReleaseKeyRegistry
|
||||
let privateKey: KeyObject
|
||||
|
||||
beforeEach(() => {
|
||||
temporaryRoot = resolve(
|
||||
mkdtempSync(join(tmpdir(), 'goodbuddy-runtime-build-'))
|
||||
)
|
||||
privateKey = createPrivateKey({
|
||||
key: Buffer.from(
|
||||
'302e020100300506032b657004220420202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f',
|
||||
'hex'
|
||||
),
|
||||
format: 'der',
|
||||
type: 'pkcs8'
|
||||
})
|
||||
registry = {
|
||||
formatVersion: 1,
|
||||
keys: [
|
||||
{
|
||||
keyId: 'runtime-fixture',
|
||||
publicKeySpkiBase64: createPublicKey(privateKey)
|
||||
.export({ format: 'der', type: 'spki' })
|
||||
.toString('base64'),
|
||||
environment: 'test'
|
||||
}
|
||||
],
|
||||
revocations: []
|
||||
}
|
||||
runtimeArchive = createRuntimeInput()
|
||||
const integrity = `sha512-${createHash('sha512')
|
||||
.update(readFileSync(runtimeArchive))
|
||||
.digest('base64')}`
|
||||
lock = {
|
||||
formatVersion: 1,
|
||||
runtimes: {
|
||||
opencode: {
|
||||
version: '1.18.9',
|
||||
provider: 'opencode',
|
||||
entrypoint: 'bin/opencode',
|
||||
entrypointIdentity: 'opencode-acp',
|
||||
argvPrefix: ['acp'],
|
||||
allowedEnvironmentNames: [
|
||||
'HOME',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'XDG_STATE_HOME'
|
||||
],
|
||||
protocol: { major: 1, minor: 0 },
|
||||
targets: {
|
||||
x64: {
|
||||
package: 'opencode-linux-x64-baseline',
|
||||
integrity
|
||||
},
|
||||
arm64: {
|
||||
package: 'opencode-linux-arm64',
|
||||
integrity:
|
||||
'sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A=='
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(temporaryRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Remote Runtime bundle tooling', () => {
|
||||
it('pins the actual OpenCode 1.18.9 ACP capabilities', () => {
|
||||
const capabilities = {
|
||||
loadSession: true,
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true
|
||||
},
|
||||
promptCapabilities: {
|
||||
embeddedContext: true,
|
||||
image: true
|
||||
},
|
||||
sessionCapabilities: {
|
||||
close: {},
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {}
|
||||
}
|
||||
}
|
||||
const digest = `sha256:${createHash('sha256')
|
||||
.update(
|
||||
runtimeBundle.canonicalJson({
|
||||
protocolVersion: 1,
|
||||
capabilities
|
||||
})
|
||||
)
|
||||
.digest('hex')}`
|
||||
|
||||
expect(runtimeBundle.profileDigests.acp).toBe(digest)
|
||||
})
|
||||
|
||||
it('builds a deterministic signed digest-addressed bundle', async () => {
|
||||
const first = build(join(temporaryRoot, 'first'))
|
||||
const second = build(join(temporaryRoot, 'second'))
|
||||
|
||||
expect(first.manifest).toEqual(second.manifest)
|
||||
expect(first.manifest.bundleDigest).toBe(
|
||||
await digestRemoteRuntimeBundleIdentity(first.manifest)
|
||||
)
|
||||
expect(first.bundleDirectory).toMatch(
|
||||
new RegExp(
|
||||
`${first.manifest.bundleDigest.slice('sha256:'.length)}$`,
|
||||
'u'
|
||||
)
|
||||
)
|
||||
expect(first.manifest).toMatchObject({
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: '1.18.9',
|
||||
architecture: 'x64',
|
||||
sourcePackage: {
|
||||
name: 'opencode-linux-x64-baseline',
|
||||
integrity:
|
||||
lock.runtimes.opencode.targets.x64.integrity
|
||||
},
|
||||
entrypoint: {
|
||||
identity: 'opencode-acp',
|
||||
path: 'bin/opencode',
|
||||
argvPrefix: ['acp']
|
||||
},
|
||||
formatVersion: 2,
|
||||
limits: {
|
||||
maximumPromptRuntimeMilliseconds: 10 * 60 * 1000,
|
||||
maximumPromptInputBytes: 16 * 1024 * 1024,
|
||||
maximumPromptOutputBytes: 8 * 1024 * 1024
|
||||
}
|
||||
})
|
||||
expect(first.manifest).not.toHaveProperty('quotas')
|
||||
})
|
||||
|
||||
it('creates deterministic archives and imports only verified content', () => {
|
||||
const built = build(join(temporaryRoot, 'built'))
|
||||
const firstArchive = join(temporaryRoot, 'first.tar')
|
||||
const secondArchive = join(temporaryRoot, 'second.tar')
|
||||
runtimeBundle.createRuntimeArchive(
|
||||
built.bundleDirectory,
|
||||
firstArchive
|
||||
)
|
||||
runtimeBundle.createRuntimeArchive(
|
||||
built.bundleDirectory,
|
||||
secondArchive
|
||||
)
|
||||
expect(readFileSync(secondArchive)).toEqual(
|
||||
readFileSync(firstArchive)
|
||||
)
|
||||
|
||||
const imported = runtimeBundle.importRuntimeArchive(
|
||||
firstArchive,
|
||||
{
|
||||
projectRoot: process.cwd(),
|
||||
architecture: 'x64',
|
||||
outputRoot: join(temporaryRoot, 'imported'),
|
||||
lock,
|
||||
registry,
|
||||
verificationEnvironment: 'test',
|
||||
enforceFilesystemMode: false
|
||||
}
|
||||
)
|
||||
expect(imported.manifest).toEqual(built.manifest)
|
||||
})
|
||||
|
||||
it('rejects payload mutation and unregistered production signing', () => {
|
||||
const built = build(join(temporaryRoot, 'built'))
|
||||
writeFileSync(
|
||||
join(built.bundleDirectory, 'licenses', 'opencode-MIT.txt'),
|
||||
'tampered'
|
||||
)
|
||||
expect(() =>
|
||||
runtimeBundle.verifyBundleDirectory(built.bundleDirectory, {
|
||||
projectRoot: process.cwd(),
|
||||
architecture: 'x64',
|
||||
lock,
|
||||
registry,
|
||||
verificationEnvironment: 'test',
|
||||
enforceFilesystemMode: false
|
||||
})
|
||||
).toThrow(/payload (?:size|hash) mismatch/iu)
|
||||
|
||||
expect(() =>
|
||||
runtimeBundle.preflightProductionSigningKey({
|
||||
projectRoot: process.cwd(),
|
||||
environment: {},
|
||||
lock,
|
||||
registry
|
||||
})
|
||||
).toThrow(/are required for production Runtime signing/iu)
|
||||
})
|
||||
})
|
||||
|
||||
function build(outputRoot: string): VerifiedBundle {
|
||||
return runtimeBundle.buildRuntimeBundle({
|
||||
projectRoot: process.cwd(),
|
||||
architecture: 'x64',
|
||||
runtimeArchive,
|
||||
outputRoot,
|
||||
lock,
|
||||
registry,
|
||||
testSigningIdentity: {
|
||||
keyId: 'runtime-fixture',
|
||||
privateKey
|
||||
},
|
||||
enforceFilesystemMode: false
|
||||
})
|
||||
}
|
||||
|
||||
function createRuntimeInput(): string {
|
||||
const packageRoot = join(temporaryRoot, 'source', 'package')
|
||||
mkdirSync(join(packageRoot, 'bin'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(packageRoot, 'package.json'),
|
||||
`${JSON.stringify({
|
||||
name: 'opencode-linux-x64-baseline',
|
||||
version: '1.18.9'
|
||||
})}\n`
|
||||
)
|
||||
writeFileSync(
|
||||
join(packageRoot, 'bin', 'opencode'),
|
||||
elf('x64')
|
||||
)
|
||||
chmodSync(join(packageRoot, 'bin', 'opencode'), 0o755)
|
||||
const archive = join(
|
||||
temporaryRoot,
|
||||
'opencode-linux-x64-baseline-1.18.9.tgz'
|
||||
)
|
||||
createTar(
|
||||
{
|
||||
cwd: join(temporaryRoot, 'source'),
|
||||
file: archive,
|
||||
sync: true,
|
||||
portable: true,
|
||||
noPax: true,
|
||||
gzip: true,
|
||||
mtime: new Date(0)
|
||||
},
|
||||
['package/package.json', 'package/bin/opencode']
|
||||
)
|
||||
return archive
|
||||
}
|
||||
|
||||
function elf(architecture: 'x64' | 'arm64'): Buffer {
|
||||
const contents = Buffer.alloc(64)
|
||||
contents.set([0x7f, 0x45, 0x4c, 0x46])
|
||||
contents[5] = 1
|
||||
contents.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18)
|
||||
return contents
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
remoteRuntimeLockSchema
|
||||
} from '../src/shared/remote-runtime-launch-contracts'
|
||||
|
||||
describe('Remote Runtime lock', () => {
|
||||
it('pins the exact OpenCode Linux packages from package-lock.json', () => {
|
||||
const runtimeLock = remoteRuntimeLockSchema.parse(
|
||||
readJson('remote-runtime-lock.json')
|
||||
)
|
||||
const packageLock = readJson('package-lock.json') as {
|
||||
packages: Record<
|
||||
string,
|
||||
{ version?: string; integrity?: string }
|
||||
>
|
||||
}
|
||||
const opencode = runtimeLock.runtimes.opencode
|
||||
|
||||
expect(opencode.entrypoint).toBe('bin/opencode')
|
||||
expect(opencode.argvPrefix).toEqual(['acp'])
|
||||
for (const target of Object.values(opencode.targets)) {
|
||||
const lockedPackage =
|
||||
packageLock.packages[`node_modules/${target.package}`]
|
||||
expect(lockedPackage).toEqual(
|
||||
expect.objectContaining({
|
||||
version: opencode.version,
|
||||
integrity: target.integrity
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function readJson(fileName: string): unknown {
|
||||
return JSON.parse(
|
||||
readFileSync(resolve(process.cwd(), fileName), 'utf8')
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user