- AI Agent
- 人工智能
- 大模型
- AI 应用
- 工具调用
- 本地部署
- MCP Clients
- Agent 记忆
【免费下载链接】Operit
The most powerful AI agent and AI chat software on Android/Operit是一款Android上能力最为强大、发展最久的AI Agent
本篇技术指南聚焦 Operit 中 ToolPkg 插件的发布处理链路:如何在直接上传时把当前登录用户写入市场来源(market origin),以及在开启混淆时如何基于 manifest 入口与静态模块引用构建"可达条目集合",剪掉未到达的src/、source map、测试与构建文件,形成既保留运行能力又保护源码的受保护归档。读完本文,你将掌握 Operit 的发布期归档处理模型、市场来源编码与注入格式、可达性剪枝算法,以及它们与 GitHub Release 资产发布流程的边界关系。核心设计记录见 docs/TODO/toolpkg_publish_provenance_20260729/index.md。
现状与变更动机
在引入来源注入之前,Operit 对插件制品的发布处理并不统一:
- 直接上传(DirectUpload)过去只在开启混淆时处理脚本;关闭混淆时文件会被原样上传,因此不会写入市场来源(market origin);
- ToolPkg 归档中的
src/目录和 source map 会随包原样保留,即使开启混淆,未进入执行路径的开发文件也会一并分发。
这意味着市场侧无法可靠追溯"这个包是谁发布的、从哪个市场渠道进入",同时发布者的源码细节会通过src/与 source map 泄露。
本次变更确立了两个目标:
- 直接上传始终写入当前登录用户的市场来源,无论是否开启混淆;
- 开启混淆时生成受保护归档:只保留 manifest、主入口、子包入口、声明资源/WASM 以及这些入口依赖的模块,未到达的
src/、source map、测试及构建文件一律排除。
实现集中在两个文件:ToolPkgArtifactMinifier.kt(归档处理与剪枝)与 GitHubForgePublishService.kt(发布编排)。
市场来源的数据模型与编码
来源信息由ToolPkgMarketOrigin承载,定义在 ToolPkgMarketOrigin.kt:
@Serializable internal data class ToolPkgMarketOrigin( val market: String, // 市场标识,固定为 "Operit" val toolpkgId: String, // 包唯一 ID val version: String, // 包版本 val author: List<String> // 发布者(登录用户名列表) )注入格式由ToolPkgMarketOriginCodec负责,采用异或(XOR)混淆 + 可执行语句注入:
private const val XOR_KEY = 0x5a fun encode(origin: ToolPkgMarketOrigin): String { val encoded = encodeBytes(origin).joinToString(prefix = "[", postfix = "]") return "ToolPkg._m(${encoded},$XOR_KEY);" }编码细节值得注意:
- 载荷先序列化为 JSON,非 ASCII 字符会被转义为
\uXXXX(escapeNonAscii); - 随后每个字节与
0x5a异或,得到一串 0~255 的整数列表; - 最终包装为
ToolPkg._m([...],90);这样的可执行语句,追加到脚本/主入口源码末尾。
选择可执行语句而非注释,是为了让来源信息随混淆一起被压缩进执行体内,而不是被 Terser 当作注释剥离(见 ToolPkgArtifactMinifier.kt 的minifyJavaScriptBytes与注释说明)。
运行时侧则通过validateForPackage校验来源是否匹配当前包:
fun validateForPackage(origin: ToolPkgMarketOrigin?, packageId: String): ToolPkgMarketOrigin? { val normalized = origin ?: return null if (normalized.market != MARKET) return null // 必须来自 Operit 市场 if (normalized.toolpkgId != packageId.trim()) return null // 包 ID 必须一致 if (normalized.version.isBlank()) return null return normalized.copy( toolpkgId = normalized.toolpkgId.trim(), version = normalized.version.trim(), author = normalized.author.map(String::trim).filter(String::isNotBlank) ) }即:市场来源只有在market == "Operit"、toolpkgId与当前包一致、版本非空时才被接受,author 会做 trim 与空值过滤。对应编解码的单元测试见 ToolPkgMarketOriginCodecTest.kt。
直接上传的完整发布流程
GitHubForgePublishService.publishArtifact是发布编排的入口,其PublishArtifactSource分两种:
DirectUpload:本地文件直接上传,走发布期处理管线(来源注入 + 可选混淆);GitHubReleaseAsset:引用已有 GitHub Release 资产,仅校验本地文件与远端资产 SHA-256 一致,不做任何改写。
DirectUpload的关键路径如下(GitHubForgePublishService.kt):
- 登录校验:
githubAuth.isLoggedIn()不通过则直接失败; - 本地校验:
validateSourceFile检查文件存在、是文件、可读;validateSupportedAppVersions校验支持的应用版本区间; - 获取当前用户:
getCurrentUser()得到currentUser.login,作为来源 author; - 确保 Forge 仓库:
ensureForgeRepository在用户的OperitForge仓库下发布资产——仓库不存在且允许创建时自动创建(allowCreateForgeRepo),仓库为空时写入 README 初始化;若用户未初始化且不允许创建,返回NeedsForgeInitialization让 UI 引导初始化; - 确保 Release:
ensureRelease按 tag 查找,不存在则创建、存在则更新(均非 draft、非 prerelease); - 构造市场来源并处理制品:
val marketOrigin = ToolPkgMarketOrigin( market = "Operit", toolpkgId = descriptor.runtimePackageId, version = descriptor.version, author = listOf(currentUser.login) ) val processedFileBytes = ToolPkgArtifactMinifier.processArtifactFile( context = context, sourceFile = sourceFile, isToolPkg = descriptor.type == PublishArtifactType.PACKAGE, marketOrigin = marketOrigin, minify = source.minifyArtifact )- 上传资产:
uploadAssetReplacingExisting先删除同名的旧资产再上传新资产,避免同名残留; - 计算 SHA-256:对处理后的字节计算
sha256Hex,随MarketRegistrationPayload一并注册到市场(registerMarketEntry),市场侧以github_release_asset形式记录ghOwner/ghRepo/ghReleaseTag/assetName/sha256。
整个流程通过PublishProgressStage向 UI 回传进度(VALIDATING → ENSURING_REPO → CREATING_RELEASE → UPLOADING_ASSET → REGISTERING_MARKET → COMPLETED)。
脚本与 ToolPkg 归档的差异化处理
processArtifactFile依据isToolPkg分流:
- 独立脚本(
processScriptFile):读取 UTF-8 源码 →injectScriptMarketOriginIntoMetadata把来源写入脚本的METADATA块 → 关闭混淆时直接返回改写后的字节;开启混淆时先注入再 AST 压缩。 - ToolPkg 归档(
processToolPkgArchive):来源作为容器级信息注入,只改写主入口(manifest.main解析出的 zip 条目),见 ToolPkgArtifactMinifier.kt。
脚本的 METADATA 注入
独立脚本要求存在/* METADATA ... */块,否则直接抛错:
private fun injectScriptMarketOriginIntoMetadata(source: String, marketOrigin: ToolPkgMarketOrigin): String { val metadataBlock = findMetadataBlock(source) ?: throw IllegalArgumentException("JavaScript package METADATA block is required for marketplace origin") val metadata = JSONObject(JsonValue.readHjson(metadataBlock.content).toString()) metadata.put(SCRIPT_MARKET_ORIGIN_METADATA_KEY, ToolPkgMarketOriginCodec.encodeForMetadata(marketOrigin)) val updatedMetadataBlock = "/* METADATA\n${metadata}\n*/" return source.replaceRange(metadataBlock.range, updatedMetadataBlock) }注入的键为__operit_market_origin,值采用xor-v1:前缀的逗号分隔字节序列(encodeForMetadata)。读取侧可用decodeMetadata还原出ToolPkgMarketOrigin。
ToolPkg 主入口的来源注入
ToolPkg 归档不要求 METADATA 块,而是把来源编码为ToolPkg._m(...);语句追加到主入口源码末尾(injectToolPkgMarketOrigin)。主入口来源构造为:
val mainOrigin = ToolPkgMarketOrigin( market = "Operit", toolpkgId = manifestPreview.manifest.toolpkgId, version = manifestPreview.manifest.version, author = marketOrigin.author )这里market固定为"Operit",toolpkgId/version取自 manifest,author取当前登录用户——这印证了文档中"市场来源是 ToolPkg 容器级信息,由主入口承载"的设计:多子包分别处理,但来源只挂在主入口上。
开启混淆时的可达性剪枝
混淆模式下的核心是collectReachableToolPkgEntries(ToolPkgArtifactMinifier.kt),它构建"可达条目集合":
- 种子集合:
- manifest 条目本身;
- 所有可执行入口:
manifest.main+ 每个subpackages[].entry(经resolveManifestRelativeZipEntryPath解析为绝对 zip 路径); - 所有资源根:
manifest.resources[].path与manifest.wasmModules[].path(资源根会按name == root || name.startsWith("$root/")展开,目录资源整棵保留);
- 静态引用扫描:从每个可执行入口出发,用
staticModuleReferencePattern正则(匹配require('...')与from/import '...')扫描源码,解析出模块引用并加入待处理队列; - 模块解析:
resolveToolPkgModuleEntry只处理以.开头的相对引用,按./、../语义归并路径段,依次尝试候选形式:
val candidates = listOf( modulePath, "$modulePath.js", "$modulePath.mjs", "$modulePath.cjs", "$modulePath.json", "$modulePath/index.js" )- BFS 迭代:循环从队列取条目、扫描、解析、入队,直到不再有新条目(仅
isJavaScriptEntry——js/mjs/cjs/ts/jsx/tsx扩展名——才展开扫描)。
剪枝写入与安全校验
写入阶段(ToolPkgArtifactMinifier.kt):
- 未命中可达集合的条目(含
src/、source map、测试与构建文件)被跳过; - 命中主入口的条目:混淆时先注入来源再 AST 压缩;不混淆时仅注入来源;
- 命中其他可执行条目或 JS 扩展名条目的:AST 压缩(资源根下的文件不压缩);
- manifest 与资源原样复制。
同时有两条显式require兜底,防止"剪枝剪出空壳包":
require(manifestEntryName in reachableEntryNames) { "Minified toolpkg lost its manifest entry: $manifestEntryName" } val missingExecutables = executableEntryNames.filterNot { it in reachableEntryNames } require(missingExecutables.isEmpty()) { "Minified toolpkg lost executable entries: ${missingExecutables.joinToString()}" }源码注释特别提醒:剪枝以 manifest 所在目录为根,若包内混入嵌套 manifest 导致选错 manifest,真正的入口会被判为不可达并整包丢弃,产出"能装但内容为空壳"的包,因此必须显式校验。这里的"可达集合"本质是让受保护归档与运行时依赖图对齐——复制整棵作者树既破坏源码保护又增加加载开销。
AST 压缩实现:Terser 与保留策略
混淆由 ToolPkgJsAstMinifier.kt 完成,它在 Operit 内置的 QuickJS 引擎(OperitQuickJsEngine)中加载assets/js/terser.bundle.min.js,调用Terser.minify_sync,压缩选项如下:
root.__operitToolPkgAstMinify = function(source, entryName) { var result = root.Terser.minify_sync(String(source), { ecma: 2020, module: /\.mjs$/i.test(normalizedEntryName), compress: { defaults: true, passes: 3, toplevel: false // 保留 CommonJS export 与词法声明顺序 }, mangle: { toplevel: false, keep_classnames: false, keep_fnames: false }, format: { comments: false, ascii_only: true }, sourceMap: false }); ... };要点:
toplevel: false刻意保留顶层绑定与 CommonJS export 名称,保证外部调用接口不被破坏;comments: false会剥离注释,因此市场来源必须作为可执行语句注入(这正是ToolPkg._m(...);设计的原因);- 对含 METADATA 块的脚本/条目,
minifyJavaScriptSourcePreservingMetadata会先把/* METADATA ... */块整体抽出、对剩余 body 做压缩,再把原 METADATA 注释拼回头部,避免 HJSON 元数据被压缩破坏; - 压缩产物仍是普通 ZIP/脚本,可被现有市场路径、文件选择器和调试安装直接加载(对应 docs/TODO/toolpkg_protection_ast_minify/index.md 的完成标准)。
多子包与容器级来源
文档明确"多子包仍分别处理":subpackages[].entry会被逐个加入可执行入口集合参与剪枝与压缩,但市场来源只注入主入口,因为来源是 ToolPkg 容器级信息。这也意味着:即使包内含多个子包,运行时解析到的来源始终归属于容器整体,而非某个子包。
边界与限制:GitHub Release 资产不可变
一个重要边界(文档原文):已有 GitHub Release 资产是外部不可变引用。加载该模式(PublishArtifactSource.GitHubReleaseAsset)时不会改写远端文件——GitHubForgePublishService只做 SHA-256 一致性校验(本地文件必须与远端资产字节一致),远端 Release 资产保持原样。要获得来源注入与剪枝保护,必须通过直接上传发布处理后的资产,即选择DirectUpload走processArtifactFile管线。
因此给发布者的实操建议是:
- 需要市场溯源 + 源码保护的新版本,一律走直接上传;
- 引用历史 Release 资产只适合"登记已有制品",不会获得来源注入;
- 混淆开关(
minifyArtifact)决定剪枝与压缩是否生效;关闭混淆时仍会注入来源(脚本走 METADATA、ToolPkg 走主入口),但src/与 source map 会随包保留。
验证与回归
仓库中已有配套测试覆盖本主题的核心行为:
- ToolPkgMarketOriginCodecTest.kt:验证 XOR 编解码、
xor-v1:元数据格式与validateForPackage的字段规约; - JsToolPkgRegistrationTest.kt:验证来源在 JS 注册侧的解析与消费(涉及
ToolPkgMarketOrigin在 JsToolPkgRegistration.kt 与 JsEngine.kt 中的使用)。
回归时建议覆盖四类场景:脚本 + 混淆 / 脚本不混淆 / ToolPkg + 混淆(含子包与资源目录)/ ToolPkg 不混淆,并分别断言来源注入位置(METADATA vs 主入口尾部ToolPkg._m)、剪枝后条目集合以及压缩后仍可被市场路径与调试安装正常加载。
小结
Operit 的发布处理把"市场溯源"与"源码保护"统一进了同一管线:来源信息通过 XOR 编码以可执行语句注入主入口(脚本则写入 METADATA),混淆时借助静态模块引用分析构建可达条目集合完成剪枝,再由内置 Terser 做 AST 压缩,最终产物以 GitHub Release 资产 + 市场注册(含 SHA-256)的形式分发。理解这条链路后,无论是排查"来源未注入"(多半是走了 Release 资产引用而非直接上传)、"包能装但功能缺失"(多为 manifest 选择错误导致的剪枝误判),还是"src/ 意外泄露"(混淆未开启),都能在 ToolPkgArtifactMinifier.kt 与 GitHubForgePublishService.kt 中找到对应实现依据。
- AI Agent
- 人工智能
- 大模型
- AI 应用
- 工具调用
- 本地部署
- MCP Clients
- Agent 记忆
【免费下载链接】Operit
The most powerful AI agent and AI chat software on Android/Operit是一款Android上能力最为强大、发展最久的AI Agent
相关推荐
Operit ToolPkg 市场来源溯源:导入通知与自动化测试实现指南
Operit ToolPkg 市场来源溯源:导入通知与自动化测试实现指南 本文基于 Operit 仓库 docs/TODO/toolpkg_market_ori
AI Agent人工智能大模型AI 应用工具调用本地部署MCP ClientsAgent 记忆GUI 自动化Operit ToolPkg 市场溯源(Market Origin)机制详解:从发布注入、导入校验到来源提示的完整实现
Operit ToolPkg 市场溯源(Market Origin)机制详解:从发布注入、导入校验到来源提示的完整实现 导读 本文以 Operit 仓库中 do
AI Agent人工智能大模型AI 应用工具调用本地部署MCP ClientsAgent 记忆GUI 自动化Operit ToolPkg 直传发布:脚本 METADATA 注释任意位置定位与市场来源注入实现解析
Operit ToolPkg 直传发布:脚本 METADATA 注释任意位置定位与市场来源注入实现解析 本篇技术指南讲解 Operit 在 ToolPkg /
AI Agent人工智能大模型AI 应用工具调用本地部署MCP ClientsAgent 记忆GUI 自动化
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考