diff --git a/FEATURES.md b/FEATURES.md index 4680f515..0089dc15 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -23,7 +23,7 @@ - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。 -- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,也不会在项目激活时自动下载;用户必须先在该设置页按 Linux x64/arm64 手动下载最新兼容的签名 `.gbagent`,或导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。缺少某架构包只使该架构托管 SSH 不可用。持久化验证继续精确绑定 Agent installation 与签名 Runtime digest;托管 SSH 会话只显示支持的 OpenCode 选择。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已转为正式 Agent 0.11.3。 +- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,也不会在项目激活时自动下载;用户必须先在该设置页按 Linux x64/arm64 手动下载最新兼容的签名 `.gbagent`,或导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。缺少某架构包只使该架构托管 SSH 不可用。持久化验证继续精确绑定 Agent installation 与签名 Runtime digest;托管 SSH 会话只显示支持的 OpenCode 选择。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已转为正式 Agent 0.11.4。 - [x] **SSH Host 运行环境手动更新**:远程运行环境卡片只读取 Host 已安装 Agent/Runtime 与当前本地复合包所需版本,不会联网。用户显式触发后,Main 按 Agent、Runtime 顺序使用对应 Host 架构的已验证本地 `.gbagent` 强制重新校验并安装,显示阶段进度并允许在最终收尾前取消;尚未下载时提示先到平台功能设置下载或导入。更新期间保留旧版本卡片,失败或取消后刷新实际状态并允许重试;成功后定向清理该 Host 缓存并使引用项目重新验证,不删除 Host 配置、凭据、项目设置或 Workspace 文件。 - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。 - [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 diff --git a/agent-runtime-lock.json b/agent-runtime-lock.json index 29f20451..8de08222 100644 --- a/agent-runtime-lock.json +++ b/agent-runtime-lock.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "agentVersion": "0.11.3", + "agentVersion": "0.11.4", "protocol": { "major": 2, "minor": 0 diff --git a/build/agent-bundle.cjs b/build/agent-bundle.cjs index 19c8419d..a8b50d9a 100644 --- a/build/agent-bundle.cjs +++ b/build/agent-bundle.cjs @@ -1171,6 +1171,7 @@ module.exports = { createAgentArchive, createManifest, detectElfArchitecture, + expectedManifestMode, importAgentArchive, lockedRuntimeInput, preflightProductionSigningKey, diff --git a/build/agent-catalog.cjs b/build/agent-catalog.cjs index 9d04a8a1..1db28b52 100644 --- a/build/agent-catalog.cjs +++ b/build/agent-catalog.cjs @@ -6,18 +6,14 @@ const { } = require('node:crypto') const { closeSync, + fstatSync, openSync, readFileSync, readSync, - statSync, writeFileSync } = require('node:fs') const { basename, resolve } = require('node:path') -const { - Unzip, - UnzipInflate, - UnzipPassThrough -} = require('fflate') +const { crc32 } = require('node:zlib') const { productionSigningKey, publicKeySpkiBase64, @@ -29,6 +25,11 @@ const { descriptorContentDigest, signatureDomain: packageSignatureDomain } = require('./agent-package.cjs') +const { + localFileHeaderSignature, + readExactZipBytes, + readZipCentralDirectory +} = require('./zip-central-directory.cjs') const root = resolve(__dirname, '..') const catalogName = 'agent-catalog.json' @@ -41,6 +42,11 @@ const semanticVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?$/u const sha256Pattern = /^[a-f0-9]{64}$/u const maximumPackageBytes = 512 * 1024 * 1024 +const maximumPackageEntries = 50_002 +const maximumPackageEntryBytes = 384 * 1024 * 1024 +const maximumExpandedPackageBytes = 1024 * 1024 * 1024 +const maximumMetadataBytes = 1024 * 1024 +const maximumCentralDirectoryBytes = 32 * 1024 * 1024 const maximumCatalogEntries = 200 const windowsReservedNamePattern = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu @@ -183,14 +189,6 @@ function assertPackageDescriptor(descriptor) { function readPackageMetadata(archivePath, registry) { const archive = resolve(archivePath) - const status = statSync(archive) - if ( - !status.isFile() || - status.size <= 0 || - status.size > maximumPackageBytes - ) { - throw new Error('Agent package archive is not a bounded file') - } const streamed = streamPackageMetadata(archive) const descriptorBytes = streamed.descriptor const signatureBytes = streamed.signature @@ -225,7 +223,7 @@ function readPackageMetadata(archivePath, registry) { return { archive: expectedArchive, descriptor, - size: status.size, + size: streamed.size, sha256: streamed.sha256 } } @@ -282,127 +280,232 @@ function verifyPackageFileInventory(descriptor, actualFiles) { } } -function streamPackageMetadata(archivePath) { - const handle = openSync(archivePath, 'r') - const hash = createHash('sha256') - const buffer = Buffer.allocUnsafe(64 * 1024) - const values = new Map() - const files = new Map() +function centralDirectoryEntries(handle, archiveSize) { + const parsed = readZipCentralDirectory(handle, archiveSize, { + label: 'Agent package', + minimumEntries: 1, + maximumEntries: maximumPackageEntries, + maximumCentralDirectoryBytes + }) + const entries = [] const seen = new Set() - let fatalError - let entries = 0 let expandedBytes = 0 - const unzip = new Unzip((file) => { - entries += 1 - if (entries > 50_002) { - fatalError ??= new Error( - 'Agent package contains too many entries' - ) - file.terminate() - return + for (const entry of parsed.entries) { + if ( + entry.flags !== 0x0800 || + entry.compression !== 0 || + entry.compressedSize === 0xffffffff || + entry.size === 0xffffffff || + entry.compressedSize !== entry.size || + entry.size > maximumPackageEntryBytes || + entry.diskStart !== 0 || + entry.localOffset === 0xffffffff + ) { + throw new Error('Agent package ZIP entry is unsupported') } - if (!safePackagePath(file.name) || seen.has(file.name)) { - fatalError ??= new Error( + if ( + !safePackagePath(entry.name) || + seen.has(entry.name) + ) { + throw new Error( 'Agent package contains an unsafe or duplicate path' ) - file.terminate() - return } - seen.add(file.name) - const wanted = - file.name === 'agent-package.json' || - file.name === 'agent-package.sig' - if (wanted && values.has(file.name)) { - fatalError ??= new Error( - 'Agent package contains duplicate metadata' + seen.add(entry.name) + expandedBytes += entry.size + if (expandedBytes > maximumExpandedPackageBytes) { + throw new Error( + 'Agent package expanded payload exceeds its limit' ) - file.terminate() - return } - const chunks = [] - let size = 0 - const fileHash = createHash('sha256') - file.ondata = (error, data, final) => { - if (error) { - fatalError ??= error - return - } - size += data.byteLength - expandedBytes += data.byteLength - if ( - size > 384 * 1024 * 1024 || - expandedBytes > 1024 * 1024 * 1024 - ) { - fatalError ??= new Error( - 'Agent package expanded payload exceeds its limit' - ) - file.terminate() - return - } - fileHash.update(data) - if (wanted) { - if (size > 1024 * 1024) { - fatalError ??= new Error( - 'Agent package metadata exceeds its limit' - ) - file.terminate() - return - } - chunks.push(Buffer.from(data)) - } - if (final) { - files.set(file.name, { - size, - sha256: fileHash.digest('hex') - }) - if (wanted) { - values.set(file.name, Buffer.concat(chunks, size)) - } - } + const local = readExactZipBytes( + handle, + 30, + entry.localOffset, + 'Agent package local file header' + ) + const localNameLength = local.readUInt16LE(26) + const localExtraLength = local.readUInt16LE(28) + if ( + local.readUInt32LE(0) !== localFileHeaderSignature || + local.readUInt16LE(6) !== entry.flags || + local.readUInt16LE(8) !== entry.compression || + local.readUInt32LE(14) !== entry.checksum || + local.readUInt32LE(18) !== entry.compressedSize || + local.readUInt32LE(22) !== entry.size || + localNameLength !== entry.nameBytes.length + ) { + throw new Error('Agent package local file header is invalid') } - try { - file.start() - } catch (error) { - fatalError ??= error - file.terminate() + const localName = readExactZipBytes( + handle, + localNameLength, + entry.localOffset + 30, + 'Agent package local file name' + ) + if (!localName.equals(entry.nameBytes)) { + throw new Error('Agent package local file name is invalid') } - }) - unzip.register(UnzipPassThrough) - unzip.register(UnzipInflate) + const dataOffset = + entry.localOffset + + 30 + + localNameLength + + localExtraLength + const recordEnd = dataOffset + entry.compressedSize + if (recordEnd > parsed.centralOffset) { + throw new Error('Agent package entry exceeds its data region') + } + entries.push({ + checksum: entry.checksum, + dataOffset, + name: entry.name, + recordStart: entry.localOffset, + recordEnd + }) + } + entries.sort( + (left, right) => left.recordStart - right.recordStart + ) + for (let index = 1; index < entries.length; index += 1) { + if (entries[index].recordStart < entries[index - 1].recordEnd) { + throw new Error('Agent package ZIP entries overlap') + } + } + return entries +} + +function sameFileSnapshot(left, right) { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ) +} + +function streamPackageMetadata(archivePath) { + const handle = openSync(archivePath, 'r') + const values = new Map() + const files = new Map() try { - while (true) { + const initialStatus = fstatSync(handle) + if ( + !initialStatus.isFile() || + initialStatus.size <= 0 || + initialStatus.size > maximumPackageBytes + ) { + throw new Error( + 'Agent package archive is not a bounded file' + ) + } + const entries = centralDirectoryEntries( + handle, + initialStatus.size + ) + const states = entries.map((entry) => { + const wanted = + entry.name === 'agent-package.json' || + entry.name === 'agent-package.sig' + const size = entry.recordEnd - entry.dataOffset + if (wanted && size > maximumMetadataBytes) { + throw new Error('Agent package metadata exceeds its limit') + } + return { + ...entry, + bytes: 0, + contents: wanted + ? Buffer.allocUnsafe(size) + : undefined, + crc32: 0, + hash: createHash('sha256') + } + }) + const archiveHash = createHash('sha256') + const buffer = Buffer.allocUnsafe(1024 * 1024) + let position = 0 + let firstEntry = 0 + while (position < initialStatus.size) { const bytesRead = readSync( handle, buffer, 0, - buffer.length, - null + Math.min(buffer.length, initialStatus.size - position), + position ) - if (bytesRead === 0) { - unzip.push(new Uint8Array(), true) - break + if (bytesRead <= 0) { + throw new Error('Agent package archive is truncated') } - const chunk = Uint8Array.from( - buffer.subarray(0, bytesRead) + const chunk = buffer.subarray(0, bytesRead) + const chunkEnd = position + bytesRead + archiveHash.update(chunk) + while ( + firstEntry < states.length && + states[firstEntry].recordEnd <= position + ) { + firstEntry += 1 + } + for ( + let index = firstEntry; + index < states.length && + states[index].dataOffset < chunkEnd; + index += 1 + ) { + const state = states[index] + const dataStart = Math.max(position, state.dataOffset) + const dataEnd = Math.min(chunkEnd, state.recordEnd) + if (dataStart >= dataEnd) { + continue + } + const data = chunk.subarray( + dataStart - position, + dataEnd - position + ) + state.hash.update(data) + state.crc32 = crc32(data, state.crc32) + if (state.contents) { + data.copy( + state.contents, + dataStart - state.dataOffset + ) + } + state.bytes += data.byteLength + } + position = chunkEnd + } + if (!sameFileSnapshot(initialStatus, fstatSync(handle))) { + throw new Error( + 'Agent package archive changed during verification' ) - hash.update(chunk) - unzip.push(chunk, false) - if (fatalError) { - throw fatalError + } + for (const state of states) { + const size = state.recordEnd - state.dataOffset + if ( + state.bytes !== size || + (state.crc32 >>> 0) !== state.checksum + ) { + throw new Error( + `Agent package ZIP checksum verification failed: ${state.name}` + ) } + files.set(state.name, { + size, + sha256: state.hash.digest('hex') + }) + if (state.contents) { + values.set(state.name, state.contents) + } + } + return { + descriptor: values.get('agent-package.json'), + signature: values.get('agent-package.sig'), + files, + sha256: archiveHash.digest('hex'), + size: initialStatus.size } } finally { closeSync(handle) } - if (fatalError) { - throw fatalError - } - return { - descriptor: values.get('agent-package.json'), - signature: values.get('agent-package.sig'), - files, - sha256: hash.digest('hex') - } } function catalogEntry(metadata) { diff --git a/build/agent-package.cjs b/build/agent-package.cjs index ef9e58d2..71bfa04e 100644 --- a/build/agent-package.cjs +++ b/build/agent-package.cjs @@ -19,9 +19,10 @@ const { } = require('node:fs') const { basename, dirname, join, relative, resolve, sep } = require('node:path') const { tmpdir } = require('node:os') -const { Zip, ZipPassThrough } = require('fflate') +const { crc32 } = require('node:zlib') const { buildAgentBundle, + expectedManifestMode: expectedAgentManifestMode, preflightProductionSigningKey, productionSigningKey, publicKeySpkiBase64, @@ -36,6 +37,11 @@ const { readRemoteRuntimeLock, verifyBundleDirectory: verifyRuntimeBundle } = require('./remote-runtime-bundle.cjs') +const { + centralDirectoryHeaderSignature: zipCentralHeaderSignature, + endOfCentralDirectorySignature: zipEndSignature, + localFileHeaderSignature: zipLocalHeaderSignature +} = require('./zip-central-directory.cjs') const root = join(__dirname, '..') const packageDescriptorName = 'agent-package.json' @@ -44,7 +50,10 @@ const signatureDomain = Buffer.from( 'GoodBuddy Agent Package Descriptor Signature v1\0', 'utf8' ) -const archiveEpoch = new Date('1980-01-01T00:00:00.000Z') +const zipUtf8Flag = 0x0800 +const zipVersion = 20 +const zipMadeByUnix = (3 << 8) | zipVersion +const zipDosDate = (1 << 5) | 1 const semanticVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?$/u @@ -89,10 +98,11 @@ function listFiles(directory) { ) } -function sha256File(filePath) { +function fileDigests(filePath) { const hash = createHash('sha256') const handle = openSync(filePath, 'r') const buffer = Buffer.allocUnsafe(1024 * 1024) + let checksum = 0 try { while (true) { const bytesRead = readSync( @@ -105,12 +115,17 @@ function sha256File(filePath) { if (bytesRead === 0) { break } - hash.update(buffer.subarray(0, bytesRead)) + const chunk = buffer.subarray(0, bytesRead) + hash.update(chunk) + checksum = crc32(chunk, checksum) } } finally { closeSync(handle) } - return hash.digest('hex') + return { + crc32: checksum >>> 0, + sha256: hash.digest('hex') + } } function copyTree(source, destination) { @@ -122,6 +137,13 @@ function copyTree(source, destination) { } } +function packageFileMode(path) { + if (path.startsWith('agent/')) { + return expectedAgentManifestMode(path.slice('agent/'.length)) + } + return /\/bin\/opencode$/u.test(path) ? '0755' : '0644' +} + function stagePackagePayload(options) { const staging = options.staging copyTree(options.agentBundle, join(staging, 'agent')) @@ -157,20 +179,18 @@ function stagePackagePayload(options) { } function payloadFiles(staging) { - return listFiles(staging) + const crc32ByPath = new Map() + const files = listFiles(staging) .map((filePath) => { const path = relative(staging, filePath).split(sep).join('/') const size = statSync(filePath).size + const digests = fileDigests(filePath) + crc32ByPath.set(path, digests.crc32) return { path, size, - sha256: sha256File(filePath), - mode: - path === 'agent/goodbuddy-agent' || - path === 'agent/node' || - /\/bin\/opencode$/u.test(path) - ? '0755' - : '0644' + sha256: digests.sha256, + mode: packageFileMode(path) } }) .filter( @@ -178,18 +198,44 @@ function payloadFiles(staging) { file.path !== packageDescriptorName && file.path !== packageSignatureName ) + return { crc32ByPath, files } } -function createPackageArchive(staging, output) { +function createPackageArchive( + staging, + output, + knownCrc32 = new Map() +) { mkdirSync(dirname(output), { recursive: true }) - const outputHandle = openSync(output, 'w') - let archiveError - let completed = false - const archive = new Zip((error, data, final) => { - if (error) { - archiveError ??= error - return + const entries = listFiles(staging).map((filePath) => { + const name = relative(staging, filePath).split(sep).join('/') + const nameBytes = Buffer.from(name, 'utf8') + const size = statSync(filePath).size + if ( + nameBytes.length === 0 || + nameBytes.length > 0xffff || + size > 0xffffffff + ) { + throw new Error(`Agent package ZIP entry is too large: ${name}`) } + return { + filePath, + name, + nameBytes, + size, + crc32: + knownCrc32.get(name) ?? fileDigests(filePath).crc32, + mode: packageFileMode(name), + offset: 0 + } + }) + if (entries.length === 0 || entries.length > 0xffff) { + throw new Error('Agent package ZIP entry count is invalid') + } + const outputHandle = openSync(output, 'w') + let outputOffset = 0 + const archiveHash = createHash('sha256') + const writeBytes = (data) => { let offset = 0 while (offset < data.byteLength) { const written = writeSync( @@ -199,69 +245,101 @@ function createPackageArchive(staging, output) { data.byteLength - offset ) if (written <= 0) { - archiveError ??= new Error( - 'Agent package archive write made no progress' - ) - return + throw new Error('Agent package archive write made no progress') } offset += written } - completed ||= final - }) + archiveHash.update(data) + outputOffset += data.byteLength + } const buffer = Buffer.allocUnsafe(1024 * 1024) try { - for (const filePath of listFiles(staging)) { - const name = relative(staging, filePath).split(sep).join('/') - const input = new ZipPassThrough(name) - input.mtime = archiveEpoch - archive.add(input) - const inputHandle = openSync(filePath, 'r') - const fileSize = statSync(filePath).size + for (const entry of entries) { + entry.offset = outputOffset + const header = Buffer.alloc(30) + header.writeUInt32LE(zipLocalHeaderSignature, 0) + header.writeUInt16LE(zipVersion, 4) + header.writeUInt16LE(zipUtf8Flag, 6) + header.writeUInt16LE(0, 8) + header.writeUInt16LE(0, 10) + header.writeUInt16LE(zipDosDate, 12) + header.writeUInt32LE(entry.crc32, 14) + header.writeUInt32LE(entry.size, 18) + header.writeUInt32LE(entry.size, 22) + header.writeUInt16LE(entry.nameBytes.length, 26) + header.writeUInt16LE(0, 28) + writeBytes(header) + writeBytes(entry.nameBytes) + + const inputHandle = openSync(entry.filePath, 'r') try { let position = 0 - if (fileSize === 0) { - input.push(new Uint8Array(), true) - } - while (position < fileSize) { + while (position < entry.size) { const bytesRead = readSync( inputHandle, buffer, 0, - buffer.length, + Math.min(buffer.length, entry.size - position), null ) if (bytesRead <= 0) { throw new Error( - `Agent package source read made no progress: ${name}` + `Agent package source read made no progress: ${entry.name}` ) } position += bytesRead - input.push( - Uint8Array.from(buffer.subarray(0, bytesRead)), - position === fileSize - ) + writeBytes(buffer.subarray(0, bytesRead)) } } finally { closeSync(inputHandle) } - if (archiveError) { - throw archiveError - } } - archive.end() - if (archiveError) { - throw archiveError - } - if (!completed) { - throw new Error('Agent package archive did not finish') + + const centralOffset = outputOffset + for (const entry of entries) { + const record = Buffer.alloc(46) + record.writeUInt32LE(zipCentralHeaderSignature, 0) + record.writeUInt16LE(zipMadeByUnix, 4) + record.writeUInt16LE(zipVersion, 6) + record.writeUInt16LE(zipUtf8Flag, 8) + record.writeUInt16LE(0, 10) + record.writeUInt16LE(0, 12) + record.writeUInt16LE(zipDosDate, 14) + record.writeUInt32LE(entry.crc32, 16) + record.writeUInt32LE(entry.size, 20) + record.writeUInt32LE(entry.size, 24) + record.writeUInt16LE(entry.nameBytes.length, 28) + record.writeUInt16LE(0, 30) + record.writeUInt16LE(0, 32) + record.writeUInt16LE(0, 34) + record.writeUInt16LE(0, 36) + const unixMode = + 0o100000 | Number.parseInt(entry.mode, 8) + record.writeUInt32LE((unixMode << 16) >>> 0, 38) + record.writeUInt32LE(entry.offset, 42) + writeBytes(record) + writeBytes(entry.nameBytes) } + + const centralSize = outputOffset - centralOffset + const end = Buffer.alloc(22) + end.writeUInt32LE(zipEndSignature, 0) + end.writeUInt16LE(0, 4) + end.writeUInt16LE(0, 6) + end.writeUInt16LE(entries.length, 8) + end.writeUInt16LE(entries.length, 10) + end.writeUInt32LE(centralSize, 12) + end.writeUInt32LE(centralOffset, 16) + end.writeUInt16LE(0, 20) + writeBytes(end) fsyncSync(outputHandle) - } catch (error) { - archive.terminate() - throw error } finally { closeSync(outputHandle) } + return { + size: outputOffset, + sha256: archiveHash.digest('hex') + } } function assembleAgentPackage(options) { @@ -345,6 +423,7 @@ function assembleAgentPackage(options) { agentBundle: resolve(options.agentBundle), runtimeBundle: runtime.bundleDirectory }) + const payload = payloadFiles(staging) const initial = { format: 'goodbuddy-agent-package', formatVersion: 1, @@ -364,7 +443,7 @@ function assembleAgentPackage(options) { protocol: runtime.manifest.protocol }, contentDigest: `sha256:${'0'.repeat(64)}`, - files: payloadFiles(staging) + files: payload.files } const descriptor = { ...initial, @@ -382,12 +461,16 @@ function assembleAgentPackage(options) { `${signature.toString('base64')}\n`, 'utf8' ) - createPackageArchive(staging, options.output) + const archive = createPackageArchive( + staging, + options.output, + payload.crc32ByPath + ) return { descriptor, archive: basename(options.output), - size: statSync(options.output).size, - sha256: sha256File(options.output) + size: archive.size, + sha256: archive.sha256 } } finally { rmSync(staging, { recursive: true, force: true }) diff --git a/build/build-release.cjs b/build/build-release.cjs index c3cc3230..ea831c96 100644 --- a/build/build-release.cjs +++ b/build/build-release.cjs @@ -5,6 +5,7 @@ const { createWriteStream, existsSync, closeSync, + fstatSync, mkdirSync, mkdtempSync, openSync, @@ -38,6 +39,9 @@ const { sha256File } = require('./file-hash.cjs') const { detectBinaryArchitecture } = require('./binary-architecture.cjs') +const { + readZipCentralDirectory +} = require('./zip-central-directory.cjs') const root = join(__dirname, '..') const packageJson = JSON.parse( @@ -522,9 +526,10 @@ function targetHarnessPaths(options) { koffiPackage, koffiBinary, nodePtyBinary: - options.platform === 'linux' - ? 'build/Release/pty.node' - : `prebuilds/${platformName}-${options.arch}/pty.node`, + `prebuilds/${platformName}-${options.arch}/` + + (options.platform === 'windows' + ? 'conpty.node' + : 'pty.node'), nodePtyDirectory: `${platformName}-${options.arch}` } } @@ -1000,7 +1005,7 @@ function verifyHarnessPackage( 'DeepSeek Harness Canvas' ) - if (options.platform === 'darwin') { + if (options.platform === 'macos') { const helper = join( unpackedRoot, 'node_modules', @@ -1274,98 +1279,42 @@ async function createPortableZip( } function readZipEntryNames(filePath) { - const fileSize = statSync(filePath).size - if (fileSize < 22) { - throw new Error('Portable ZIP 缺少中央目录') - } - const endChunkSize = Math.min(fileSize, 65_557) - const endChunkStart = fileSize - endChunkSize - const endChunk = readChunk( - filePath, - endChunkSize, - endChunkStart - ) - let endOffset = -1 - for (let index = endChunk.length - 22; index >= 0; index -= 1) { - if ( - endChunk.readUInt32LE(index) === 0x06054b50 && - index + 22 + endChunk.readUInt16LE(index + 20) === - endChunk.length - ) { - endOffset = index - break - } - } - if (endOffset < 0) { - throw new Error('Portable ZIP 缺少中央目录') - } - const diskNumber = endChunk.readUInt16LE(endOffset + 4) - const centralDisk = endChunk.readUInt16LE(endOffset + 6) - const diskEntryCount = endChunk.readUInt16LE(endOffset + 8) - const entryCount = endChunk.readUInt16LE(endOffset + 10) - const centralSize = endChunk.readUInt32LE(endOffset + 12) - const centralOffset = endChunk.readUInt32LE(endOffset + 16) - const absoluteEndOffset = endChunkStart + endOffset - if ( - diskNumber !== 0 || - centralDisk !== 0 || - diskEntryCount !== entryCount || - entryCount === 0xffff || - centralSize === 0xffffffff || - centralOffset === 0xffffffff || - entryCount < portableRequiredFiles.length + 1 || - entryCount > maxPortableZipEntries || - centralSize < 46 || - centralSize > maxPortableCentralDirectoryBytes || - centralOffset + centralSize !== absoluteEndOffset - ) { - throw new Error('Portable ZIP 中央目录无效') - } - const centralDirectory = readChunk( - filePath, - centralSize, - centralOffset - ) - const names = [] - let offset = 0 - for (let index = 0; index < entryCount; index += 1) { - if ( - offset + 46 > centralDirectory.length || - centralDirectory.readUInt32LE(offset) !== 0x02014b50 - ) { - throw new Error('Portable ZIP 中央目录条目无效') - } - const nameLength = centralDirectory.readUInt16LE(offset + 28) - const extraLength = centralDirectory.readUInt16LE(offset + 30) - const commentLength = centralDirectory.readUInt16LE(offset + 32) - const entryLength = 46 + nameLength + extraLength + commentLength - if (offset + entryLength > centralDirectory.length) { - throw new Error('Portable ZIP 中央目录条目越界') - } - const name = centralDirectory - .subarray(offset + 46, offset + 46 + nameLength) - .toString( - centralDirectory.readUInt16LE(offset + 8) & 0x0800 - ? 'utf8' - : 'latin1' + const handle = openSync(filePath, 'r') + try { + let parsed + try { + parsed = readZipCentralDirectory( + handle, + fstatSync(handle).size, + { + label: 'Portable ZIP', + minimumEntries: portableRequiredFiles.length + 1, + maximumEntries: maxPortableZipEntries, + maximumCentralDirectoryBytes: + maxPortableCentralDirectoryBytes + } ) - .replaceAll('\\', '/') - if ( - !name || - name.startsWith('/') || - /^[a-z]:\//iu.test(name) || - name.includes('\0') || - name.split('/').some((part) => part === '..') - ) { - throw new Error(`Portable ZIP 包含不安全路径:${name}`) + } catch (error) { + throw new Error('Portable ZIP 中央目录无效', { + cause: error + }) } - names.push(name) - offset += entryLength + return parsed.entries.map((entry) => { + const name = entry.name.replaceAll('\\', '/') + if ( + !name || + name.startsWith('/') || + /^[a-z]:\//iu.test(name) || + name.includes('\0') || + name.split('/').some((part) => part === '..') + ) { + throw new Error(`Portable ZIP 包含不安全路径:${name}`) + } + return name + }) + } finally { + closeSync(handle) } - if (offset !== centralDirectory.length) { - throw new Error('Portable ZIP 中央目录数量不一致') - } - return names } function verifyPortableZip(filePath) { @@ -1698,6 +1647,7 @@ module.exports = { replaceOutput, stageTargetRuntimeDependencies, targetRuntimePackageNames, + targetHarnessPaths, verifyHarnessPackage, verifyArchiveIntegrity, verifyUnpackedOutput, diff --git a/build/zip-central-directory.cjs b/build/zip-central-directory.cjs new file mode 100644 index 00000000..f5959c30 --- /dev/null +++ b/build/zip-central-directory.cjs @@ -0,0 +1,141 @@ +const { readSync } = require('node:fs') + +const localFileHeaderSignature = 0x04034b50 +const centralDirectoryHeaderSignature = 0x02014b50 +const endOfCentralDirectorySignature = 0x06054b50 + +function readExactZipBytes(handle, length, position, label) { + const output = Buffer.allocUnsafe(length) + let offset = 0 + while (offset < length) { + const bytesRead = readSync( + handle, + output, + offset, + length - offset, + position + offset + ) + if (bytesRead <= 0) { + throw new Error(`${label} is truncated`) + } + offset += bytesRead + } + return output +} + +function readZipCentralDirectory(handle, archiveSize, limits) { + if (archiveSize < 22) { + throw new Error(`${limits.label} central directory is missing`) + } + const endSize = Math.min(archiveSize, 65_557) + const endStart = archiveSize - endSize + const end = readExactZipBytes( + handle, + endSize, + endStart, + `${limits.label} end record` + ) + let endOffset = -1 + for (let index = end.length - 22; index >= 0; index -= 1) { + if ( + end.readUInt32LE(index) === endOfCentralDirectorySignature && + index + 22 + end.readUInt16LE(index + 20) === end.length + ) { + endOffset = index + break + } + } + if (endOffset < 0) { + throw new Error(`${limits.label} central directory is missing`) + } + const diskNumber = end.readUInt16LE(endOffset + 4) + const centralDisk = end.readUInt16LE(endOffset + 6) + const diskEntryCount = end.readUInt16LE(endOffset + 8) + const entryCount = end.readUInt16LE(endOffset + 10) + const centralSize = end.readUInt32LE(endOffset + 12) + const centralOffset = end.readUInt32LE(endOffset + 16) + const absoluteEndOffset = endStart + endOffset + if ( + diskNumber !== 0 || + centralDisk !== 0 || + diskEntryCount !== entryCount || + entryCount === 0 || + entryCount === 0xffff || + entryCount < limits.minimumEntries || + entryCount > limits.maximumEntries || + centralSize === 0xffffffff || + centralSize < 46 || + centralSize > limits.maximumCentralDirectoryBytes || + centralOffset === 0xffffffff || + centralOffset + centralSize !== absoluteEndOffset + ) { + throw new Error(`${limits.label} central directory is invalid`) + } + const central = readExactZipBytes( + handle, + centralSize, + centralOffset, + `${limits.label} central directory` + ) + const entries = [] + let offset = 0 + for (let index = 0; index < entryCount; index += 1) { + if ( + offset + 46 > central.length || + central.readUInt32LE(offset) !== + centralDirectoryHeaderSignature + ) { + throw new Error( + `${limits.label} central directory entry is invalid` + ) + } + const nameLength = central.readUInt16LE(offset + 28) + const extraLength = central.readUInt16LE(offset + 30) + const commentLength = central.readUInt16LE(offset + 32) + const entryLength = + 46 + nameLength + extraLength + commentLength + if (offset + entryLength > central.length) { + throw new Error( + `${limits.label} central directory entry exceeds its bounds` + ) + } + const flags = central.readUInt16LE(offset + 8) + const nameBytes = central.subarray( + offset + 46, + offset + 46 + nameLength + ) + const name = new TextDecoder( + flags & 0x0800 ? 'utf-8' : 'latin1', + { fatal: true } + ).decode(nameBytes) + entries.push({ + name, + nameBytes: Buffer.from(nameBytes), + flags, + compression: central.readUInt16LE(offset + 10), + checksum: central.readUInt32LE(offset + 16), + compressedSize: central.readUInt32LE(offset + 20), + size: central.readUInt32LE(offset + 24), + diskStart: central.readUInt16LE(offset + 34), + localOffset: central.readUInt32LE(offset + 42) + }) + offset += entryLength + } + if (offset !== central.length) { + throw new Error( + `${limits.label} central directory count is invalid` + ) + } + return { + centralOffset, + entries + } +} + +module.exports = { + centralDirectoryHeaderSignature, + endOfCentralDirectorySignature, + localFileHeaderSignature, + readExactZipBytes, + readZipCentralDirectory +} diff --git a/docs/architecture/remote-host-and-goodbuddy-agent-design.md b/docs/architecture/remote-host-and-goodbuddy-agent-design.md index 51cbe238..740e43c7 100644 --- a/docs/architecture/remote-host-and-goodbuddy-agent-design.md +++ b/docs/architecture/remote-host-and-goodbuddy-agent-design.md @@ -2,7 +2,7 @@ ## 状态 -本文记录当前代码实现,不定义额外的信任框架。Windows 到 Linux x64 的安装、Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实 Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与 OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback 路径 capability 后,当前源码 lock 已转为正式 Agent `0.11.3`,尚未复用旧版本号。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry 已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 复合包和签名累计目录。 +本文记录当前代码实现,不定义额外的信任框架。Windows 到 Linux x64 的安装、Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实 Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与 OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback 路径 capability 后,当前源码 lock 已转为正式 Agent `0.11.4`。失败的 `agent-v0.11.3` 保持不可变且未发布。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry 已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 复合包和签名累计目录。 ## 产品语义 @@ -170,7 +170,7 @@ Execute 不经过 Ask 的 bubblewrap profile: - 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0` 和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布 - 的双架构验收。当前源码 lock 是正式版本 `0.11.3`,需要由新的复合包发布流程另行验证。 + 的双架构验收。当前源码 lock 是正式版本 `0.11.4`,需要由新的复合包发布流程另行验证。 - 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认 OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。 - 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生 diff --git a/package-lock.json b/package-lock.json index e0c7dee4..6804eb05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.11.3", + "version": "0.11.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.11.3", + "version": "0.11.4", "license": "0BSD", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/package.json b/package.json index f7c4192f..df56d0d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.11.3", + "version": "0.11.4", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", @@ -193,7 +193,7 @@ "to": "licenses/ppu-ocv-MIT.txt" }, { - "from": "node_modules/onnxruntime-web/LICENSE", + "from": "resources/licenses/onnxruntime-MIT.txt", "to": "licenses/onnxruntime-web-MIT.txt" }, { @@ -357,5 +357,19 @@ "@koromix/koffi-linux-x64": "3.1.4", "@koromix/koffi-win32-arm64": "3.1.4", "@koromix/koffi-win32-x64": "3.1.4" + }, + "allowScripts": { + "@deepseek-ai/dsh-subprocess-local@0.1.0-rc.8": true, + "@google/genai@1.52.0": true, + "cpu-features@0.0.10": true, + "electron-winstaller@5.4.0": true, + "esbuild@0.25.12": true, + "esbuild@0.28.1": true, + "fsevents@2.3.3": true, + "koffi@3.1.4": true, + "node-pty@1.2.0-beta.15": true, + "opencode-ai@1.18.9": true, + "protobufjs@7.6.5": true, + "ssh2@1.17.0": true } } diff --git a/resources/licenses/onnxruntime-MIT.txt b/resources/licenses/onnxruntime-MIT.txt new file mode 100644 index 00000000..88040d88 --- /dev/null +++ b/resources/licenses/onnxruntime-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/resources/release-notes.json b/resources/release-notes.json index 7606de4e..dba005f7 100644 --- a/resources/release-notes.json +++ b/resources/release-notes.json @@ -2,12 +2,12 @@ "formatVersion": 1, "releases": [ { - "version": "0.11.3", - "releasedAt": "2026-08-25", + "version": "0.11.4", + "releasedAt": "2026-08-26", "notes": { "zh-CN": { "highlights": [ - "GoodBuddy 0.11.3 新增托管 SSH 远程项目、内置 Granite 向量模型、会话分支和应用内反馈,并改进魔法笔记、知识图谱、模型下载及桌面布局。" + "GoodBuddy 0.11.4 新增托管 SSH 远程项目、内置 Granite 向量模型、会话分支和应用内反馈,并改进魔法笔记、知识图谱、模型下载及桌面布局。" ], "features": [ "**托管 SSH 远程项目(技术预览)。** 在“设置 → 平台功能”中启用后,可管理 Linux x64/arm64 Host,并通过 Ask 或 Execute 使用签名 Agent 与兼容的 OpenCode Runtime。Agent 包需在设置中手动下载、更新或离线导入,打开项目不会自动下载;SSH 断线后,Host 上的任务可继续运行并恢复未同步输出。", @@ -27,14 +27,14 @@ "**界面信息与会话列表。** 恢复关键页面说明和视觉层级;会话更多操作仅在悬停、键盘聚焦或菜单打开时显示,为标题和时间保留更多空间。" ], "notices": [ - "远程项目默认关闭且完全可选。首次使用前必须手动准备兼容的 `.gbagent` 包,下载源跟随“关于与更新”中的 GitHub 或镜像节点选择;本次正式 Agent 版本为 `0.11.3`。", - "LoongArch 仍是独立实验性预览,不进入标准更新索引;当前不包含 Agent、Remote Runtime、OpenCode 和原生 Canvas。本次 `0.11.3` 不提供 LoongArch 预览包。", + "远程项目默认关闭且完全可选。首次使用前必须手动准备兼容的 `.gbagent` 包,下载源跟随“关于与更新”中的 GitHub 或镜像节点选择;本次正式 Agent 版本为 `0.11.4`。", + "LoongArch 仍是独立实验性预览,不进入标准更新索引;当前不包含 Agent、Remote Runtime、OpenCode 和原生 Canvas。本次 `0.11.4` 不提供 LoongArch 预览包。", "反馈不会自动附带会话、日志、文件、设置或凭据。" ] }, "en-US": { "highlights": [ - "GoodBuddy 0.11.3 adds managed SSH remote projects, a built-in Granite embedding model, conversation branching, and in-app feedback, with improvements to Magic Notes, knowledge graphs, model downloads, and desktop layout." + "GoodBuddy 0.11.4 adds managed SSH remote projects, a built-in Granite embedding model, conversation branching, and in-app feedback, with improvements to Magic Notes, knowledge graphs, model downloads, and desktop layout." ], "features": [ "**Managed SSH remote projects (technical preview).** Enable Remote Projects under Settings → Platform Features to manage Linux x64/arm64 Hosts and use signed Agents with a compatible OpenCode Runtime in Ask or Execute. Agent packages must be downloaded, updated, or imported manually in Settings; opening a project never downloads one automatically. Host work can continue through an SSH disconnect and recover unsynchronized output.", @@ -54,8 +54,8 @@ "**Interface guidance and conversation rows.** Restored essential workflow guidance and visual hierarchy. Conversation actions now appear only on hover, keyboard focus, or while their menu is open, leaving more room for titles and timestamps." ], "notices": [ - "Remote Projects are disabled by default and remain optional. A compatible `.gbagent` package must be prepared manually before first use, and its source follows the GitHub or mirror selection in About & Updates. This release uses the formal Agent version `0.11.3`.", - "LoongArch remains a separate experimental preview outside the standard update index. It currently excludes Agent, Remote Runtime, OpenCode, and native Canvas. No LoongArch preview package is provided for `0.11.3`.", + "Remote Projects are disabled by default and remain optional. A compatible `.gbagent` package must be prepared manually before first use, and its source follows the GitHub or mirror selection in About & Updates. This release uses the formal Agent version `0.11.4`.", + "LoongArch remains a separate experimental preview outside the standard update index. It currently excludes Agent, Remote Runtime, OpenCode, and native Canvas. No LoongArch preview package is provided for `0.11.4`.", "Feedback never automatically includes conversations, logs, files, settings, or credentials." ] } diff --git a/tests/agent-package.test.ts b/tests/agent-package.test.ts index 2c47fd81..fc7aa285 100644 --- a/tests/agent-package.test.ts +++ b/tests/agent-package.test.ts @@ -107,6 +107,14 @@ type AgentPackageModule = { } type AgentCatalogModule = { + readPackageMetadata( + archivePath: string, + registry: AgentReleaseKeyRegistry + ): { + descriptor: AgentPackageDescriptor + size: number + sha256: string + } createCatalog(options: { projectRoot: string x64Package: string @@ -289,6 +297,12 @@ describe('compound Agent packages', () => { 'remote-runtime-lock.json' ]) ) + expect(packageResult.descriptor.files).toContainEqual( + expect.objectContaining({ + path: 'agent/helpers/fixture-helper', + mode: '0755' + }) + ) const contentRoot = join(temporaryRoot, 'verified') const verified = await extractAndVerifyAgentPackage({ @@ -576,12 +590,15 @@ describe('compound Agent packages', () => { changedRoot, agentPackageArchiveName(agentLock.agentVersion, 'x64') ) - const changedEntries = unzipSync( - new Uint8Array(readFileSync(packagePath)) - ) + const originalPackage = readFileSync(packagePath) + const changedPackage = Buffer.concat([ + originalPackage, + Buffer.from([0]) + ]) + changedPackage.writeUInt16LE(1, originalPackage.length - 2) writeFileSync( changedPath, - Buffer.from(zipSync(changedEntries, { level: 1 })) + changedPackage ) expect(() => agentCatalog.createCatalog({ @@ -608,6 +625,18 @@ describe('compound Agent packages', () => { ).toThrow('identity is immutable') }) + it('verifies stored payload bytes that contain a ZIP descriptor signature', () => { + expect( + agentCatalog.readPackageMetadata(packagePath, registry) + ).toMatchObject({ + descriptor: { + architecture: 'x64' + }, + size: packageResult.size, + sha256: packageResult.sha256 + }) + }) + it('imports and exports a fully verified offline package atomically', async () => { const keyRegistryPath = join( temporaryRoot, @@ -884,6 +913,7 @@ function writeAgentBundle( ): void { mkdirSync(join(directory, 'lib'), { recursive: true }) mkdirSync(join(directory, 'licenses'), { recursive: true }) + mkdirSync(join(directory, 'helpers'), { recursive: true }) writeFileSync(join(directory, 'node'), elf(architecture)) writeFileSync( join(directory, 'goodbuddy-agent'), @@ -893,6 +923,10 @@ function writeAgentBundle( join(directory, 'lib', 'agent.cjs'), 'module.exports={}\n' ) + writeFileSync( + join(directory, 'helpers', 'fixture-helper'), + '#!/bin/sh\n' + ) for (const [name, contents] of [ ['GoodBuddy-0BSD.txt', '0BSD'], ['Node.js-MIT.txt', 'MIT'], @@ -903,7 +937,11 @@ function writeAgentBundle( writeFileSync(join(directory, 'licenses', name), contents) } writeKoffiPayload(directory, architecture) - for (const path of ['node', 'goodbuddy-agent']) { + for (const path of [ + 'node', + 'goodbuddy-agent', + 'helpers/fixture-helper' + ]) { chmodSync(join(directory, path), 0o755) } const manifest = agentBundle.createManifest(directory, { @@ -1027,7 +1065,11 @@ function createRuntimeInput( ) writeFileSync( join(packageRoot, 'bin', 'opencode'), - elf(architecture) + Buffer.concat([ + elf(architecture), + Buffer.from([0x50, 0x4b, 0x07, 0x08]), + Buffer.from('runtime payload') + ]) ) chmodSync(join(packageRoot, 'bin', 'opencode'), 0o755) const archive = join( diff --git a/tests/build-release.test.ts b/tests/build-release.test.ts index 8c01dbf1..a0cd350c 100644 --- a/tests/build-release.test.ts +++ b/tests/build-release.test.ts @@ -93,6 +93,12 @@ interface ReleaseBuilderModule { targetRuntimePackageNames: ( options: ReleaseOptions ) => string[] + targetHarnessPaths: ( + options: ReleaseOptions + ) => { + nodePtyBinary: string + nodePtyDirectory: string + } stageTargetRuntimeDependencies: ( options: ReleaseOptions, dependencies?: { @@ -446,6 +452,10 @@ describe('release build arguments', () => { build: { asarUnpack: string[] npmRebuild: boolean + extraResources: Array<{ + from: string + to: string + }> } optionalDependencies: Record } @@ -468,6 +478,10 @@ describe('release build arguments', () => { expect(packageJson.build.asarUnpack).not.toContain( 'node_modules/npm/**/*' ) + expect(packageJson.build.extraResources).toContainEqual({ + from: 'resources/licenses/onnxruntime-MIT.txt', + to: 'licenses/onnxruntime-web-MIT.txt' + }) expect(packageJson.optionalDependencies).toEqual({ '@koromix/koffi-darwin-arm64': '3.1.4', '@koromix/koffi-darwin-x64': '3.1.4', @@ -948,6 +962,26 @@ describe('release binary architecture detection', () => { }) describe('release Harness package verification', () => { + it.each([ + ['windows', 'x64', 'prebuilds/win32-x64/conpty.node'], + ['windows', 'arm64', 'prebuilds/win32-arm64/conpty.node'], + ['macos', 'x64', 'prebuilds/darwin-x64/pty.node'], + ['macos', 'arm64', 'prebuilds/darwin-arm64/pty.node'], + ['linux', 'x64', 'prebuilds/linux-x64/pty.node'], + ['linux', 'arm64', 'prebuilds/linux-arm64/pty.node'] + ] as const)( + 'verifies the rc.8 node-pty prebuild for %s %s', + (platform, arch, expected) => { + expect( + releaseBuilder.targetHarnessPaths({ + ...windowsOptions, + platform, + arch + }).nodePtyBinary + ).toBe(expected) + } + ) + it('fails closed when the packaged Harness Host is missing', () => { const directory = mkdtempSync( join(tmpdir(), 'goodbuddy-harness-closure-')