diff --git a/Cargo.lock b/Cargo.lock index 6292c8dedb..9156eab173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,6 +942,7 @@ name = "bitfun-codex-adapter" version = "0.2.15" dependencies = [ "bitfun-product-domains", + "bitfun-services-core", "bitfun-static-hook-support", "dirs 6.0.0", "dunce", @@ -1214,6 +1215,7 @@ dependencies = [ "bitfun-static-hook-support", "dirs 6.0.0", "dunce", + "globset", "hex", "oxc", "regex", diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index ac54547d5c..aba084fd29 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -544,14 +544,30 @@ plan stale 后只保留旧选择与新 eligible 项的交集,新候选不自 规则文件优先复用项目已有文件,不复制出第二份内容。若不同生态规则冲突,导入报告必须展示目标文件、 优先级和冲突段,不能自动拼接。 -当前 Workspace Instructions 只消费真实工作区根,本地和 Remote 共用同一个解析器与 `WorkspaceFileSystem` 端口。 -固定顺序是:`AGENTS.override.md`(存在时替代 `AGENTS.md`,空文件也不回退)、根 `CLAUDE.md` 或 -`.claude/CLAUDE.md`、`CLAUDE.local.md`、不带 `paths` front matter 的 `.claude/rules/**/*.md`,最后是项目根与 -`.opencode` 中 `opencode.json/jsonc` 的本地 `instructions` 文件或 glob。Claude `@import` 只跟随工作区内文件, -深度上限为 5,并对重复和循环引用去重;所有目录遍历都跳过符号链接。运行时尚无稳定的嵌套活动目录事实,因此 -不声明 root-to-cwd 级联。递归扫描跳过 VCS、依赖与构建目录,并对扫描节点、文件数量、单文件和总内容字节设置固定 -上限,避免宽 glob 阻塞本地或 Remote 工作区。Claude path-scoped rules、全局规则、OpenCode 远程 URL、变化监听和冲突 -报告也不属于当前实现。 +本地 Workspace Instructions 先读取用户级生态来源,再追加项目来源。用户级固定顺序是 OpenCode、Codex、Claude Code: +OpenCode 读取 `$XDG_CONFIG_HOME/opencode/AGENTS.md`(默认 `~/.config/opencode/AGENTS.md`),不存在时回退 +`~/.claude/CLAUDE.md`;随后按 `config.json`、`opencode.json`、`opencode.jsonc` 的原生覆盖顺序读取最终 +`instructions` 数组,支持 workspace 相对、`~/`、绝对本地精确文件和有界 glob,不获取 HTTP/HTTPS URL。Codex 在 +`$CODEX_HOME`(默认 `~/.codex`)中读取首个非空的 `AGENTS.override.md` 或 `AGENTS.md`;空 override 继续回退到基础文件。Claude Code +从 `$CLAUDE_CONFIG_DIR`(默认 `~/.claude`)读取 `CLAUDE.md`、不带 `paths` front matter 的 `rules/**/*.md`,并只在 +该用户配置根内跟随深度最多 5 的 `@import`。跨生态按 canonical path 保留首个来源,用户来源合计限制为 256 个文件、 +单文件 1 MiB、总内容 2 MiB;进入提示词的名称只显示 `~`、环境变量根、`` 或 ``。环境变量 +指定的用户根必须是绝对路径,无法确定用户根时跳过该生态而不回退进程 cwd。Codex 只读取用户根的 Instruction 文件, +不扫描 `$CODEX_HOME/plugins/cache` 等产品插件缓存。用户与项目来源合成后再次执行一个共享的 256 文件、2 MiB 最终 +渲染预算;按既定顺序保留完整文件前缀,不截断单个 Instruction 文档。 + +项目来源仍由 Workspace Instructions owner 通过本地文件系统或 `WorkspaceFileSystem` 端口统一解析。固定顺序是: +`AGENTS.override.md`(存在时替代 `AGENTS.md`,空文件也不回退)、根 `CLAUDE.md` 或 `.claude/CLAUDE.md`、 +`CLAUDE.local.md`、不带 `paths` front matter 的 `.claude/rules/**/*.md`,最后是项目根与 `.opencode` 中 +`opencode.json/jsonc` 的本地 `instructions` 文件或 glob。Claude `@import` 只跟随工作区内文件,深度上限为 5,并对 +重复和循环引用去重;所有目录遍历都跳过符号链接。运行时尚无稳定的嵌套活动目录事实,因此不声明 root-to-cwd 级联。 +递归扫描跳过 VCS、依赖与构建目录,并对扫描节点、文件数量、单文件和总内容字节设置固定上限,避免宽 glob 阻塞本地 +或 Remote 工作区。Remote 只使用端口可见的项目来源,绝不回退到控制端的本机用户目录。单个用户生态读取失败只隔离 +该生态,项目来源和其他用户生态仍可用;I/O 或解析失败时这次 user context 构建不写缓存,下一条消息会重试。确定性 +遍历预算超限则跳过对应 OpenCode glob 或 Claude rules 集合、保留已读取的基础文件并允许缓存,避免每条消息重复同一宽扫描。 + +Claude path-scoped rules、OpenCode 远程 URL、managed/organization policy、变化监听、冲突报告和 Plugin Runtime 不属于 +当前实现。 现有对 `.claude/.codex/.opencode/.agents` Skill 根的直接发现已经保留来源身份和全局/项目使用范围,并在 GUI/TUI 展示来源和默认覆盖状态,模式配置再展示实际采用项;固定根顺序保持为 Skill Registry 的独立回归契约。Registry 仅按 diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index bf93e8fccb..79bf1127cc 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -198,7 +198,7 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 | 资产 | OpenCode 输入 | BitFun 归属模块 / 适配方式 | 默认行为 | 降级条件 | |---|---|---|---|---| -| Rules / Instructions | 项目/全局 `AGENTS.md`、Claude fallback、`instructions` glob、本地文件、远程 URL | Workspace Instructions 归属模块保存有序来源引用 | 当前实现项目根与 `.opencode` 配置中的本地精确文件/glob;全局与远程 URL 仍是目标 | 无效 JSONC 或 glob 只排除对应配置项;文件 I/O 失败时当前构建不缓存并在下一条消息重试。 | +| Rules / Instructions | 项目/全局 `AGENTS.md`、Claude fallback、`instructions` glob、本地文件、远程 URL | 各生态 adapter 保留原生用户来源语义;Workspace Instructions owner 解析项目来源;Product Assembly 有序合成 | 当前支持 OpenCode 用户 `AGENTS.md`/Claude fallback、三份全局配置的最终本地 `instructions`,以及既有项目根与 `.opencode` 本地精确文件/glob;不获取远程 URL | 单个用户生态的无效配置、glob 或文件 I/O 失败会隔离该生态,保留其他用户生态与项目来源,并使本次构建不可缓存;项目配置继续按项目解析器的逐项降级语义处理。 | | Agents / Modes | 当前生产 V1 `agent/prompt/disable/permission`、Core V2 `agents/system/disabled/permissions` 输入形状,以及 Markdown、description、mode、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、options、hidden、color | Agent 归属模块创建兼容定义和使用范围视图;OpenCode adapter 只翻译来源语义 | 当前支持 Subagent 安全子集和 Agent-local 权限约束;V1 是生产兼容主路径,Core V2 字段只按已验证安全子集解析;首次按行为、来源、模型、工具与权限范围确认,fresh single-run 调用 | primary/mode、variant/options、采样、steps 与续接保持诊断或阻断;root ambient 权限和 V1 嵌套 resource map 尚不激活,不影响其他 Agent。 | | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | OpenCode adapter 只由 `bitfun-core/external_sources` 组合并投影有序本地配置根;Skill 归属模块负责有界递归、解析、覆盖与按需加载 | 标准根及 V1 `skills.paths`/当前本地字符串数组可用;项目配置限项目根,用户配置限项目根或用户目录;配置根最多 64 个、每根 512 个 Skill、单文件 256 KiB、可选策略 64 KiB,实际加载再次执行有界非链接读取;配置根在同 scope 覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源 | URL、下载/缓存、脚本与外部依赖不加载;无效根不影响标准 Skill。 | | References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | **基础能力缺失**:先补 Workspace Reference 的异步准备与 `@alias` 消费接口 | 本地引用保留相对来源;Git 拉取按 L2 确认并保留缓存/隐藏语义 | 拉取失败不阻止项目,外部目录仍遵守工具权限。 | @@ -217,11 +217,19 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 规则内容尽量原地引用,不复制成第二份文件。组合结果保留原始段落来源和顺序。OpenCode 与 BitFun 原生规则 同时存在时,配置视图展示实际进入模型的顺序;不能把冲突文本自动改写成“合并后的真相”。 -当前 runtime-free 子集不建立通用配置来源图:Workspace Instructions owner 在每个 Session 首次需要 user context 时读取 -项目根 `opencode.json`、`opencode.jsonc`、`.opencode/opencode.json` 和 `.opencode/opencode.jsonc` 中的 -`instructions` 数组,只接受工作区内相对精确文件与 glob,确定性排序后追加到既有 `AGENTS`/Claude 来源之后。 -绝对路径、`~`、越出工作区的路径、符号链接和 URL 都不会加载。文件变更不启动 watcher;用户通过统一的 -`/reload instructions`(或默认 `/reload`)失效当前 Session 的 `UserContext` 缓存,下一条消息重新读取。 +当前 runtime-free 子集不建立通用配置来源图,也不进入 `ExternalSourceControlPlane`。OpenCode adapter 负责用户级原生 +来源语义:先选 `$XDG_CONFIG_HOME/opencode/AGENTS.md`(默认 `~/.config/opencode/AGENTS.md`),不存在时回退 +`~/.claude/CLAUDE.md`;再按 `config.json`、`opencode.json`、`opencode.jsonc` 顺序合并,后出现的 `instructions` +数组覆盖前者。最终数组支持 workspace 相对、`~/`、绝对本地精确文件和有界 glob;HTTP/HTTPS 项直接拒绝,不下载、 +缓存或探测网络。Product Assembly 依次合成 OpenCode、Codex、Claude Code 用户来源,再追加项目来源,并按 canonical path +保留首项。非递归 glob 只遍历模式所需层级;递归 glob 超出固定扫描预算时只跳过该项,保留此前已读取的用户来源。 +用户与项目来源最终进入模型前还共享既有 256 文件、2 MiB 渲染上限,避免两个独立来源预算叠加扩大固定提示词。 + +Workspace Instructions owner 继续读取项目根 `opencode.json`、`opencode.jsonc`、`.opencode/opencode.json` 和 +`.opencode/opencode.jsonc` 中的 `instructions` 数组,只接受工作区内相对精确文件与 glob,确定性排序后追加到既有 +项目 `AGENTS`/Claude 来源之后。Remote 只执行这条端口可见的项目路径,不读取控制端的用户目录。文件变更不启动 +watcher;用户通过统一的 `/reload instructions`(或默认 `/reload`)失效当前 Session 的 `UserContext` 缓存,下一条 +消息重新读取。用户生态失败不会吞掉项目 instructions,但会阻止本次 user context 写入缓存,因而下一条消息自动重试。 ### 5.2 Agents、Modes 与 Skills diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index 3f808767d4..59ef53cbb3 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -104,9 +104,9 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | OpenCode 扩展项 | BitFun 差异 | 当前状态 | 目标可实现性 | 成熟度依赖(非执行顺序) | BitFun 需要完成的工作 | 细节 | |---|---|---|---|---|---|---| | 配置层级与合并 | 融合现有能力 | 部分实现:Command/Subagent/MCP 本地来源顺序 | 可完整适配 | OC-R1 | 当前三类 provider 复用私有本地路径顺序与监听根;后续按 remote、global、自定义文件、project、`.opencode`、内联和组织配置补齐有序来源并保留最终来源 | [来源与合并](opencode-config-assets-adapter-design.md#3-配置层级与来源) | -| JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 部分实现:项目 `instructions` 的 JSON/JSONC | 可主要适配 | OC-R1 | 当前只解析项目根和 `.opencode` 配置中的本地 instruction 文件/glob;完整配置 schema、环境变量与文件变量引用仍未实现 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | +| JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 部分实现:用户/项目 `instructions` 的 JSON/JSONC | 可主要适配 | OC-R1 | 当前按原生顺序读取用户 `config.json`、`opencode.json`、`opencode.jsonc` 的最终本地 instructions,并保留项目根与 `.opencode` 本地文件/glob;完整配置 schema、环境变量与文件变量引用仍未实现 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | | 独立 `tui.json/jsonc` | 融合现有能力 + 转换参数 | 未实现 | 可完整适配 | OC-R1 | 按 global、`OPENCODE_TUI_CONFIG`、project、`.opencode` 独立顺序加载,不能复用主配置优先级 | [TUI 来源](opencode-config-assets-adapter-design.md#32-tui-独立来源顺序) | -| Rules / Instructions | 转换参数 | 部分实现:项目本地文件与 glob | 可完整适配 | OC-R1 | 当前由 Workspace Instructions owner 读取项目 `instructions` 的精确文件和 glob;全局、远程 URL、完整来源合并与变化监听仍未实现 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | +| Rules / Instructions | 转换参数 | 部分实现:用户/项目本地文件与 glob | 可完整适配 | OC-R1 | OpenCode adapter 已读取用户全局 `AGENTS.md`/Claude fallback 和最终本地 `instructions`;Product Assembly 在 Codex/Claude 用户来源与项目来源之前合成并去重。远程 URL、managed/organization policy、完整配置来源图和变化监听仍未实现 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | | Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集与 Agent-local 权限约束 | 可主要适配 | OC-R1 | 已支持当前生产 V1 `agent/prompt/disable/permission` 与 Core V2 `agents/system/disabled/permissions` 的已验证安全子集、全局/项目 Markdown 和 JSON/JSONC、subagent/all、description、精确模型和工具映射,并接入审批、冲突、更新、撤下和 fresh single-run Task;V1 `disable` 保持 deep-merge,V2 `disabled` 保持 remove/re-add,不能混用生命周期语义。有序 V2 permission rules 与 V1 扁平精确 action map 会成为只可收紧的独立约束。primary/mode、root ambient permission、V1 action pattern/嵌套 resource map、跨路径与命令资源域的歧义 pattern、variant/options、采样与续接明确阻断或降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | Skills | 转换参数 | 部分实现:标准根与本地配置根 | 可完整适配 | OC-R2 | 现有 Registry 除标准用户/项目根外,也通过 `bitfun-core/external_sources` 组合边界按 OpenCode 配置来源顺序累加 V1 `skills.paths` 与当前迁移后的本地字符串数组;仅接受项目根/用户目录内的本地目录并做有界递归发现。同 scope 配置根覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源。URL、下载/缓存、完整 allow/deny/ask 顺序及外部来源策略仍未实现 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | References | 补基础能力 + 转换参数 | 未实现 | 可主要适配 | OC-R2 | R1 解析;R2 支持本地目录和 Git repository/branch/description/hidden,异步准备并接入 `@alias` | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 939a12688b..4b66e10795 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4126,6 +4126,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/assembly/core/src/plugin_runtime.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', + 'src/crates/assembly/core/src/instruction_sources.rs', ], message: 'only a reviewed product composition root may import bitfun-opencode-adapter through a capability-specific provider boundary', @@ -4157,6 +4158,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/adapters/claude-code-adapter/tests/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', + 'src/crates/assembly/core/src/instruction_sources.rs', ], message: 'Claude Code declarative source adapter may only be imported by its fixtures and reviewed composition roots', }], @@ -4172,6 +4174,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/adapters/codex-adapter/tests/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', + 'src/crates/assembly/core/src/instruction_sources.rs', ], message: 'Codex declarative source adapter may only be imported by its fixtures and reviewed composition roots', }], diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 670e39dd30..c64bcc2e27 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -11,6 +11,7 @@ export const publicApiContractSlices = [ 'external-source-subagent-contract', 'external-source-mcp-contract', 'external-source-hook-contract', + 'user-instruction-source-boundary', 'external-integration-policy-contract', ]; @@ -25,6 +26,7 @@ const contractSlices = { externalSourceSubagentContract: 'external-source-subagent-contract', externalSourceMcpContract: 'external-source-mcp-contract', externalSourceHookContract: 'external-source-hook-contract', + userInstructionSourceBoundary: 'user-instruction-source-boundary', externalIntegrationPolicyContract: 'external-integration-policy-contract', }; @@ -255,6 +257,8 @@ export const opencodeAdapterPublicApiEntries = [ 'OpenCodeHookProviderOptions', 'OpenCode static Hook fixture tests and explicit environment injection', ), + userInstructionSourceAdapterEntry('load_opencode_user_instructions', 'OpenCode'), + userInstructionSourceAdapterEntry('OpenCodeInstructionSourceOptions', 'OpenCode'), ]; function staticHookAdapterEntry(symbol, owner, consumer) { @@ -311,6 +315,51 @@ function declarativeSourceAdapterEntry( }; } +function userInstructionSourceAdapterEntry(symbol, ecosystem) { + return { + symbol, + owner: `${ecosystem} adapter user Instruction source owner`, + consumer: 'bitfun-core instruction_sources composition root', + verification: + `${ecosystem} user Instruction fixtures, bitfun-core prompt composition tests, and core-boundary public API budget checks`, + p0: 'runtime-free local user Instruction source discovery', + contractSlice: contractSlices.userInstructionSourceBoundary, + wireImpact: false, + rationale: + 'ecosystem-specific user file precedence belongs in its adapter while Product Assembly owns cross-ecosystem order', + exit: + 'remove only if the ecosystem source semantics move behind an equivalent reviewed adapter boundary', + }; +} + +function userInstructionSourceServiceEntry(symbol) { + return { + symbol, + owner: 'services-core bounded local user Instruction file owner', + consumer: 'reviewed OpenCode, Claude Code, and Codex adapters plus bitfun-core prompt composition', + verification: + 'services-core bounded file tests, ecosystem Instruction fixtures, prompt composition tests, and core-boundary public API checks', + p0: 'runtime-free bounded local user Instruction reads and accumulation', + contractSlice: contractSlices.userInstructionSourceBoundary, + wireImpact: false, + rationale: + 'sibling adapters need one canonical, bounded file implementation while raw content stays inside backend prompt composition', + exit: + 'remove only if every reviewed adapter migrates to an equivalent bounded local source owner', + }; +} + +export const userInstructionSourceServicePublicApiEntries = [ + 'MAX_LOCAL_INSTRUCTION_FILE_BYTES', + 'MAX_LOCAL_INSTRUCTION_FILES', + 'MAX_LOCAL_INSTRUCTION_TOTAL_BYTES', + 'LocalInstructionFile', + 'LocalInstructionFiles', + 'local_instruction_path_exists', + 'read_local_instruction_file', + 'read_local_text_file', +].map(userInstructionSourceServiceEntry); + export const claudeCodeAdapterPublicApiEntries = [ 'ClaudeCodeHookProvider', 'ClaudeCodeHookProviderOptions', @@ -331,7 +380,10 @@ export const claudeCodeAdapterPublicApiEntries = [ `bitfun-core composition root and Claude Code ${capability} fixtures`, capability, contractSlice, -))); +))).concat([ + 'load_claude_code_user_instructions', + 'ClaudeCodeInstructionSourceOptions', +].map((symbol) => userInstructionSourceAdapterEntry(symbol, 'Claude Code'))); export const codexAdapterPublicApiEntries = [ 'CodexHookProvider', @@ -351,7 +403,10 @@ export const codexAdapterPublicApiEntries = [ `bitfun-core composition root and Codex ${capability} fixtures`, capability, contractSlice, -))); +))).concat([ + 'load_codex_user_instructions', + 'CodexInstructionSourceOptions', +].map((symbol) => userInstructionSourceAdapterEntry(symbol, 'Codex'))); export const staticHookSupportPublicApiEntries = [ 'BoundedFileRead', @@ -1075,7 +1130,7 @@ export const publicApiAllowlistRules = [ { path: 'src/crates/adapters/opencode-adapter/src/lib.rs', reason: - 'OpenCode adapter public API must stay limited to source and candidate mapping through the PluginRuntimeClient adapter boundary', + 'OpenCode adapter public API must stay limited to reviewed runtime-free sources and the PluginRuntimeClient adapter boundary', allowedSymbolEntries: opencodeAdapterPublicApiEntries, }, { @@ -1093,6 +1148,12 @@ export const publicApiAllowlistRules = [ reason: 'shared static Hook parsing helpers must stay narrow and redacted', allowedSymbolEntries: staticHookSupportPublicApiEntries, }, + { + path: 'src/crates/services/services-core/src/local_instructions.rs', + reason: + 'bounded local user Instruction file support must stay narrow, backend-only, and explicitly consumer-backed', + allowedSymbolEntries: userInstructionSourceServicePublicApiEntries, + }, { path: 'src/crates/execution/plugin-runtime-client/src/lib.rs', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 2ccd32057d..2489d1c3b4 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -901,6 +901,9 @@ export function runManifestParserSelfTest({ const staticHookSupportPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/adapters/static-hook-support/src/lib.rs', ); + const userInstructionSourceServicePublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/services/services-core/src/local_instructions.rs', + ); const externalSubagentPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_subagents.rs', ); @@ -1034,25 +1037,76 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeConfiguredSkillRoot,OpenCodeSkillRootProvider,OpenCodeSkillRootProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions' + 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeConfiguredSkillRoot,OpenCodeSkillRootProvider,OpenCodeSkillRootProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions,load_opencode_user_instructions,OpenCodeInstructionSourceOptions' ) { throw new Error( - 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, configured Skill root, tool, subagent, MCP, and static Hook providers', + 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, configured Skill root, tool, subagent, MCP, static Hook, and user Instruction providers', ); } + const opencodeInstructionSymbols = new Set([ + 'load_opencode_user_instructions', + 'OpenCodeInstructionSourceOptions', + ]); for (const entry of opencodeAdapterPublicApiRule.allowedSymbolEntries) { for (const field of ['owner', 'consumer', 'verification', 'p0', 'contractSlice', 'rationale', 'exit']) { if (!entry[field]) { throw new Error(`OpenCode adapter public API entry must declare ${field}: ${entry.symbol}`); } } - if (entry.contractSlice !== 'opencode-adapter-boundary') { + const expectedContractSlice = opencodeInstructionSymbols.has(entry.symbol) + ? 'user-instruction-source-boundary' + : 'opencode-adapter-boundary'; + if (entry.contractSlice !== expectedContractSlice) { throw new Error(`OpenCode adapter public API entry uses wrong contractSlice: ${entry.symbol}`); } if (entry.wireImpact !== false) { throw new Error(`OpenCode adapter public API entry must not claim wire impact: ${entry.symbol}`); } } + if (!publicApiContractSlices.includes('user-instruction-source-boundary')) { + throw new Error('user Instruction sources must have an independent contract slice'); + } + for (const [label, rule, requiredSymbols] of [ + ['OpenCode Instruction adapter', opencodeAdapterPublicApiRule, ['load_opencode_user_instructions', 'OpenCodeInstructionSourceOptions']], + ['Claude Code Instruction adapter', claudeHookAdapterPublicApiRule, ['load_claude_code_user_instructions', 'ClaudeCodeInstructionSourceOptions']], + ['Codex Instruction adapter', codexHookAdapterPublicApiRule, ['load_codex_user_instructions', 'CodexInstructionSourceOptions']], + ]) { + if (!rule || requiredSymbols.some((symbol) => !rule.allowedSymbolEntries.some( + (entry) => entry.symbol === symbol + && entry.contractSlice === 'user-instruction-source-boundary' + && entry.wireImpact === false, + ))) { + throw new Error(`${label} must have a narrow consumer-backed public API budget`); + } + } + const expectedUserInstructionSourceServiceSymbols = [ + 'MAX_LOCAL_INSTRUCTION_FILE_BYTES', + 'MAX_LOCAL_INSTRUCTION_FILES', + 'MAX_LOCAL_INSTRUCTION_TOTAL_BYTES', + 'LocalInstructionFile', + 'LocalInstructionFiles', + 'local_instruction_path_exists', + 'read_local_instruction_file', + 'read_local_text_file', + ]; + if ( + !userInstructionSourceServicePublicApiRule + || (userInstructionSourceServicePublicApiRule.allowedSymbolEntries || []) + .map((entry) => entry.symbol) + .join(',') !== expectedUserInstructionSourceServiceSymbols.join(',') + ) { + throw new Error('services-core user Instruction support must have an exact public API budget'); + } + for (const entry of userInstructionSourceServicePublicApiRule.allowedSymbolEntries) { + for (const field of ['owner', 'consumer', 'verification', 'p0', 'contractSlice', 'rationale', 'exit']) { + if (!entry[field]) { + throw new Error(`services-core user Instruction public API entry must declare ${field}: ${entry.symbol}`); + } + } + if (entry.contractSlice !== 'user-instruction-source-boundary' || entry.wireImpact !== false) { + throw new Error(`services-core user Instruction public API entry has the wrong boundary: ${entry.symbol}`); + } + } for (const [label, rule, requiredSymbols] of [ ['Claude Code Hook adapter', claudeHookAdapterPublicApiRule, ['ClaudeCodeHookProvider', 'ClaudeCodeHookProviderOptions']], ['Codex Hook adapter', codexHookAdapterPublicApiRule, ['CodexHookProvider', 'CodexHookProviderOptions']], diff --git a/src/crates/adapters/AGENTS.md b/src/crates/adapters/AGENTS.md index 8e9f175333..42ba50528a 100644 --- a/src/crates/adapters/AGENTS.md +++ b/src/crates/adapters/AGENTS.md @@ -13,9 +13,9 @@ services. |---|---|---| | `agent-runtime-ipc` | Non-published private local IPC adapter for the opt-in first-party Shared TUI Runtime; closed interactive operations only | [AGENTS.md](agent-runtime-ipc/AGENTS.md) | | `ai-adapters` | AI provider request/response adapters and stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | -| `opencode-adapter` | OpenCode source semantics for the live Command, standalone Tool, Subagent, MCP, and static Hook providers; managed-package static preview | [AGENTS.md](opencode-adapter/AGENTS.md) | -| `claude-code-adapter` | Runtime-free Claude Code Command, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](claude-code-adapter/AGENTS.md) | -| `codex-adapter` | Runtime-free Codex Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](codex-adapter/AGENTS.md) | +| `opencode-adapter` | OpenCode source semantics for user Instructions plus the live Command, standalone Tool, Subagent, MCP, and static Hook providers; managed-package static preview | [AGENTS.md](opencode-adapter/AGENTS.md) | +| `claude-code-adapter` | Runtime-free Claude Code user Instructions, Command, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](claude-code-adapter/AGENTS.md) | +| `codex-adapter` | Runtime-free Codex user Instructions, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](codex-adapter/AGENTS.md) | | `static-hook-support` | Shared bounded/redacting static-source utilities plus the JSON/TOML Hook parser used by sibling ecosystem adapters; no ecosystem policy or runtime | inherited | | `transport` | Event transport emitters and host transport adapters | [AGENTS.md](transport/AGENTS.md) | | `webdriver` | Embedded WebDriver protocol and browser automation adapter | [AGENTS.md](webdriver/AGENTS.md) | diff --git a/src/crates/adapters/claude-code-adapter/AGENTS.md b/src/crates/adapters/claude-code-adapter/AGENTS.md index b11bca6cb1..aaa9621511 100644 --- a/src/crates/adapters/claude-code-adapter/AGENTS.md +++ b/src/crates/adapters/claude-code-adapter/AGENTS.md @@ -1,6 +1,6 @@ # Claude Code Adapter Instructions -- This crate may read and normalize Claude Code Command, Subagent, MCP, and Hook configuration but must never invoke Hook handlers, start Claude Code, connect MCP servers, or own product policy. +- This crate may read and normalize Claude Code user Instructions, Command, Subagent, MCP, and Hook configuration but must never invoke Hook handlers, start Claude Code, connect MCP servers, or own product policy. - Keep handler bodies, commands, prompts, URLs, arguments, environment values, and credentials inside the adapter. Public contracts expose only bounded summaries. - Preserve native Claude Code event and handler names. Map only reviewed BitFun Hook points; report every other event as native-only. - Preserve native precedence independently for each capability. Unsupported behavior fields must block or degrade explicitly; never flatten them into a global cross-ecosystem priority. diff --git a/src/crates/adapters/claude-code-adapter/src/instruction_source.rs b/src/crates/adapters/claude-code-adapter/src/instruction_source.rs new file mode 100644 index 0000000000..665ebe53ac --- /dev/null +++ b/src/crates/adapters/claude-code-adapter/src/instruction_source.rs @@ -0,0 +1,410 @@ +use bitfun_services_core::bounded_fs::{ + collect_bounded_regular_files, BoundedDirectoryWalkError, BoundedDirectoryWalkLimits, +}; +use bitfun_services_core::local_instructions::{ + read_local_instruction_file, LocalInstructionFile, LocalInstructionFiles, + MAX_LOCAL_INSTRUCTION_FILES, +}; +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; + +const MAX_CLAUDE_IMPORT_DEPTH: usize = 5; + +#[derive(Debug, Clone)] +pub struct ClaudeCodeInstructionSourceOptions { + pub config_dir: Option, + pub home_dir: Option, + pub display_root: String, +} + +impl ClaudeCodeInstructionSourceOptions { + pub fn from_environment() -> Self { + let home_dir = dirs::home_dir().filter(|path| path.is_absolute()); + let (config_dir, display_root) = match std::env::var_os("CLAUDE_CONFIG_DIR") { + Some(value) => { + let path = PathBuf::from(value); + ( + path.is_absolute().then_some(path), + "$CLAUDE_CONFIG_DIR".to_string(), + ) + } + None => ( + home_dir.as_deref().map(|home| home.join(".claude")), + "~/.claude".to_string(), + ), + }; + Self { + config_dir, + home_dir, + display_root, + } + } +} + +impl Default for ClaudeCodeInstructionSourceOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub fn load_claude_code_user_instructions( + options: &ClaudeCodeInstructionSourceOptions, +) -> Result, String> { + let Some(config_dir) = options.config_dir.as_deref() else { + return Ok(Vec::new()); + }; + let mut files = LocalInstructionFiles::default(); + let mut read_attempts = 0usize; + append_source_tree( + &mut files, + &mut read_attempts, + options, + config_dir, + config_dir.join("CLAUDE.md"), + None, + )?; + + let rules_root = config_dir.join("rules"); + let rules = match collect_bounded_regular_files( + &rules_root, + BoundedDirectoryWalkLimits { + max_depth: 32, + max_entries: 4096, + max_directories: 1024, + max_files: 4096, + }, + |path| { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) + }, + ) { + Ok(rules) => rules, + Err(BoundedDirectoryWalkError::LimitExceeded(_)) => Vec::new(), + Err(error) => { + return Err(format!("Failed to scan Claude Code user rules: {error}")); + } + }; + for rule in rules { + if files.is_at_capacity() { + break; + } + let Some(file) = read_source(options, config_dir, &rule)? else { + continue; + }; + if claude_rule_has_paths_frontmatter(&file.content) { + continue; + } + append_source_tree( + &mut files, + &mut read_attempts, + options, + config_dir, + rule, + Some(file), + )?; + } + Ok(files.into_files()) +} + +fn append_source_tree( + files: &mut LocalInstructionFiles, + read_attempts: &mut usize, + options: &ClaudeCodeInstructionSourceOptions, + config_dir: &Path, + path: PathBuf, + initial_file: Option, +) -> Result<(), String> { + let mut pending = vec![(path, 0usize, initial_file)]; + while let Some((path, depth, prefetched)) = pending.pop() { + if files.is_at_capacity() { + break; + } + let file = match prefetched { + Some(file) => Some(file), + None => { + if *read_attempts >= MAX_LOCAL_INSTRUCTION_FILES { + break; + } + *read_attempts += 1; + read_source(options, config_dir, &path)? + } + }; + let Some(file) = file else { continue }; + if files.contains_path(&file.canonical_path) { + continue; + } + let imports = if depth < MAX_CLAUDE_IMPORT_DEPTH { + claude_import_paths( + config_dir, + options.home_dir.as_deref(), + &path, + &file.content, + ) + } else { + Vec::new() + }; + if !files.push(file) { + continue; + } + pending.extend( + imports + .into_iter() + .rev() + .map(|import| (import, depth + 1, None)), + ); + } + Ok(()) +} + +fn read_source( + options: &ClaudeCodeInstructionSourceOptions, + config_dir: &Path, + path: &Path, +) -> Result, String> { + let relative = normalize_path_lexically(path) + .strip_prefix(normalize_path_lexically(config_dir)) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| "instruction.md".to_string()); + read_local_instruction_file( + path, + config_dir, + format!("{}/{relative}", options.display_root), + ) +} + +fn claude_import_paths( + root: &Path, + home_dir: Option<&Path>, + source: &Path, + content: &str, +) -> Vec { + let root = normalize_path_lexically(root); + let source_parent = source.parent().unwrap_or(&root); + let mut imports = Vec::new(); + let mut seen = HashSet::new(); + for token in content.split_whitespace() { + let token = token.trim_start_matches(['(', '[', '{', '"', '\'']); + let Some(raw_path) = token.strip_prefix('@') else { + continue; + }; + let raw_path = + raw_path.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '}', '"', '\'']); + if raw_path.is_empty() + || raw_path.starts_with("http://") + || raw_path.starts_with("https://") + { + continue; + } + let candidate = if let Some(relative) = raw_path.strip_prefix("~/") { + let Some(home_dir) = home_dir else { + continue; + }; + normalize_path_lexically(&home_dir.join(relative)) + } else if Path::new(raw_path).is_absolute() { + normalize_path_lexically(Path::new(raw_path)) + } else { + normalize_path_lexically(&source_parent.join(raw_path)) + }; + if candidate.starts_with(&root) && seen.insert(candidate.clone()) { + imports.push(candidate); + } + } + imports +} + +fn normalize_path_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + component => normalized.push(component.as_os_str()), + } + } + normalized +} + +fn claude_rule_has_paths_frontmatter(content: &str) -> bool { + let mut lines = content.lines(); + if lines.next().map(str::trim) != Some("---") { + return false; + } + for line in lines { + let trimmed = line.trim(); + if trimmed == "---" { + return false; + } + if trimmed + .split_once(':') + .is_some_and(|(key, _)| key.trim() == "paths") + { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::{ + claude_import_paths, load_claude_code_user_instructions, ClaudeCodeInstructionSourceOptions, + }; + use std::path::{Path, PathBuf}; + + #[test] + fn user_memory_resolves_bounded_imports_and_unconditional_rules_only() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("claude"); + std::fs::create_dir_all(config_dir.join("imports")).expect("imports directory"); + std::fs::create_dir_all(config_dir.join("rules/nested")).expect("rules directory"); + std::fs::write( + config_dir.join("CLAUDE.md"), + "Claude root\n@imports/base.md\n@../outside.md\n", + ) + .expect("Claude memory"); + std::fs::write( + config_dir.join("imports/base.md"), + "Imported base\n@nested.md\n@nested.md\n", + ) + .expect("base import"); + std::fs::write( + config_dir.join("imports/nested.md"), + "Nested import\n@../CLAUDE.md\n", + ) + .expect("nested import"); + std::fs::write(config_dir.join("rules/z-last.md"), "Last rule\n").expect("last rule"); + std::fs::write(config_dir.join("rules/nested/a-first.md"), "First rule\n") + .expect("first rule"); + std::fs::write( + config_dir.join("rules/scoped.md"), + "---\npaths:\n - src/**/*.rs\n---\nScoped rule\n", + ) + .expect("scoped rule"); + let options = ClaudeCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: None, + display_root: "$CLAUDE_CONFIG_DIR".to_string(), + }; + + let files = load_claude_code_user_instructions(&options).expect("instructions"); + let names = files + .iter() + .map(|file| file.name.as_str()) + .collect::>(); + + assert_eq!( + names, + vec![ + "$CLAUDE_CONFIG_DIR/CLAUDE.md", + "$CLAUDE_CONFIG_DIR/imports/base.md", + "$CLAUDE_CONFIG_DIR/imports/nested.md", + "$CLAUDE_CONFIG_DIR/rules/nested/a-first.md", + "$CLAUDE_CONFIG_DIR/rules/z-last.md", + ] + ); + assert!(!files.iter().any(|file| file.name.contains("scoped"))); + } + + #[test] + fn import_containment_normalizes_the_config_root() { + let root = Path::new("parent/../claude"); + let source = root.join("CLAUDE.md"); + + let imports = claude_import_paths(root, None, &source, "@imports/base.md"); + + assert_eq!(imports, vec![PathBuf::from("claude/imports/base.md")]); + } + + #[test] + fn rules_and_imports_share_one_file_budget() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("claude"); + std::fs::create_dir_all(config_dir.join("rules")).expect("rules directory"); + std::fs::create_dir_all(config_dir.join("imports")).expect("imports directory"); + for index in 0..129 { + std::fs::write( + config_dir.join(format!("rules/{index:03}.md")), + format!("Rule {index}\n@../imports/{index:03}.md\n"), + ) + .expect("rule"); + std::fs::write( + config_dir.join(format!("imports/{index:03}.md")), + format!("Import {index}\n"), + ) + .expect("import"); + } + let options = ClaudeCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: None, + display_root: "$CLAUDE_CONFIG_DIR".to_string(), + }; + + let files = load_claude_code_user_instructions(&options).expect("instructions"); + + assert_eq!(files.len(), 256); + } + + #[test] + fn a_bounded_rules_scan_failure_does_not_discard_user_memory() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("claude"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + std::fs::write(config_dir.join("CLAUDE.md"), "User memory\n").expect("memory"); + let mut too_deep = config_dir.join("rules"); + for index in 0..33 { + too_deep = too_deep.join(format!("level-{index}")); + } + std::fs::create_dir_all(&too_deep).expect("deep rules tree"); + let options = ClaudeCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: None, + display_root: "$CLAUDE_CONFIG_DIR".to_string(), + }; + + let files = load_claude_code_user_instructions(&options).expect("instructions"); + + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "$CLAUDE_CONFIG_DIR/CLAUDE.md"); + } + + #[test] + fn imports_accept_home_and_absolute_paths_only_when_they_stay_in_the_config_root() { + let temp = tempfile::tempdir().expect("tempdir"); + let home = temp.path().join("home"); + let root = home.join(".claude"); + let source = root.join("CLAUDE.md"); + let absolute = root.join("imports/absolute.md"); + let outside = home.join("outside.md"); + let content = format!( + "@~/.claude/imports/home.md @{} @{}", + absolute.display(), + outside.display() + ); + + let imports = claude_import_paths(&root, Some(&home), &source, &content); + + assert_eq!( + imports, + vec![ + root.join("imports/home.md"), + root.join("imports/absolute.md") + ] + ); + } + + #[test] + fn a_missing_config_root_does_not_fall_back_to_the_process_directory() { + let files = load_claude_code_user_instructions(&ClaudeCodeInstructionSourceOptions { + config_dir: None, + home_dir: None, + display_root: "~/.claude".to_string(), + }) + .expect("instructions"); + + assert!(files.is_empty()); + } +} diff --git a/src/crates/adapters/claude-code-adapter/src/lib.rs b/src/crates/adapters/claude-code-adapter/src/lib.rs index c4c78115b6..5621e3ee74 100644 --- a/src/crates/adapters/claude-code-adapter/src/lib.rs +++ b/src/crates/adapters/claude-code-adapter/src/lib.rs @@ -3,9 +3,13 @@ mod agent_source; mod command_source; mod hook_source; +mod instruction_source; mod mcp_source; pub use agent_source::{ClaudeCodeSubagentProvider, ClaudeCodeSubagentProviderOptions}; pub use command_source::{ClaudeCodeCommandProvider, ClaudeCodeCommandProviderOptions}; pub use hook_source::{ClaudeCodeHookProvider, ClaudeCodeHookProviderOptions}; +pub use instruction_source::{ + load_claude_code_user_instructions, ClaudeCodeInstructionSourceOptions, +}; pub use mcp_source::{ClaudeCodeMcpProvider, ClaudeCodeMcpProviderOptions}; diff --git a/src/crates/adapters/codex-adapter/AGENTS.md b/src/crates/adapters/codex-adapter/AGENTS.md index f47ce20864..ddcbfc4f49 100644 --- a/src/crates/adapters/codex-adapter/AGENTS.md +++ b/src/crates/adapters/codex-adapter/AGENTS.md @@ -1,6 +1,6 @@ # Codex Adapter Instructions -- This crate may read and normalize Codex Subagent, MCP, and Hook configuration but must never invoke Hook handlers, start Codex/app-server, connect MCP servers, or own trust, enablement, or product policy. +- This crate may read and normalize Codex user Instructions, Subagent, MCP, and Hook configuration but must never invoke Hook handlers, start Codex/app-server, connect MCP servers, or own trust, enablement, or product policy. - Keep commands, prompts, arguments, environment values, and credentials inside the adapter. Public contracts expose only bounded summaries. - Preserve native Codex event and handler names. Map only reviewed BitFun Hook points; report every other event as native-only. - Preserve Codex configuration-layer and field-overlay semantics inside each typed provider. Unknown behavior fields must block explicitly; do not invent Codex Command or standalone Tool sources. diff --git a/src/crates/adapters/codex-adapter/Cargo.toml b/src/crates/adapters/codex-adapter/Cargo.toml index 8c4ed7069f..9d6faf176f 100644 --- a/src/crates/adapters/codex-adapter/Cargo.toml +++ b/src/crates/adapters/codex-adapter/Cargo.toml @@ -11,6 +11,7 @@ crate-type = ["rlib"] [dependencies] bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["external-sources"] } +bitfun-services-core = { path = "../../services/services-core", default-features = false } bitfun-static-hook-support = { path = "../static-hook-support" } dirs = { workspace = true } dunce = { workspace = true } diff --git a/src/crates/adapters/codex-adapter/src/instruction_source.rs b/src/crates/adapters/codex-adapter/src/instruction_source.rs new file mode 100644 index 0000000000..a32036836d --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/instruction_source.rs @@ -0,0 +1,80 @@ +use bitfun_services_core::local_instructions::{ + local_instruction_path_exists, read_local_instruction_file, LocalInstructionFile, +}; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct CodexInstructionSourceOptions { + pub codex_home: Option, +} + +impl CodexInstructionSourceOptions { + pub fn from_environment() -> Self { + let codex_home = match std::env::var_os("CODEX_HOME") { + Some(value) => { + let path = PathBuf::from(value); + path.is_absolute().then_some(path) + } + None => dirs::home_dir() + .filter(|path| path.is_absolute()) + .map(|home| home.join(".codex")), + }; + Self { codex_home } + } +} + +impl Default for CodexInstructionSourceOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub fn load_codex_user_instructions( + options: &CodexInstructionSourceOptions, +) -> Result, String> { + let Some(codex_home) = options.codex_home.as_deref() else { + return Ok(Vec::new()); + }; + for file_name in ["AGENTS.override.md", "AGENTS.md"] { + let path = codex_home.join(file_name); + if local_instruction_path_exists(&path)? { + if let Some(file) = + read_local_instruction_file(&path, codex_home, format!("$CODEX_HOME/{file_name}"))? + { + return Ok(vec![file]); + } + } + } + Ok(Vec::new()) +} + +#[cfg(test)] +mod tests { + use super::{load_codex_user_instructions, CodexInstructionSourceOptions}; + + #[test] + fn an_empty_override_falls_back_to_the_base_file() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::write(temp.path().join("AGENTS.override.md"), "").expect("empty override"); + std::fs::write(temp.path().join("AGENTS.md"), "base instructions\n") + .expect("base instructions"); + let options = CodexInstructionSourceOptions { + codex_home: Some(temp.path().to_path_buf()), + }; + + let files = load_codex_user_instructions(&options).expect("instructions"); + + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "$CODEX_HOME/AGENTS.md"); + assert_eq!(files[0].content, "base instructions\n"); + } + + #[test] + fn a_missing_codex_home_does_not_fall_back_to_the_process_directory() { + let files = + load_codex_user_instructions(&CodexInstructionSourceOptions { codex_home: None }) + .expect("instructions"); + + assert!(files.is_empty()); + } +} diff --git a/src/crates/adapters/codex-adapter/src/lib.rs b/src/crates/adapters/codex-adapter/src/lib.rs index 3e19a0a2c1..6c140ccc6f 100644 --- a/src/crates/adapters/codex-adapter/src/lib.rs +++ b/src/crates/adapters/codex-adapter/src/lib.rs @@ -2,8 +2,10 @@ mod agent_source; mod hook_source; +mod instruction_source; mod mcp_source; pub use agent_source::{CodexSubagentProvider, CodexSubagentProviderOptions}; pub use hook_source::{CodexHookProvider, CodexHookProviderOptions}; +pub use instruction_source::{load_codex_user_instructions, CodexInstructionSourceOptions}; pub use mcp_source::{CodexMcpProvider, CodexMcpProviderOptions}; diff --git a/src/crates/adapters/opencode-adapter/AGENTS.md b/src/crates/adapters/opencode-adapter/AGENTS.md index e41caafd9b..da2f2e32ec 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS.md +++ b/src/crates/adapters/opencode-adapter/AGENTS.md @@ -2,7 +2,7 @@ # OpenCode Adapter -The current crate owns the static OpenCode source preview used by the existing +The current crate owns OpenCode user Instruction path/config precedence, the static OpenCode source preview used by the existing managed-package path, the OpenCode-specific implementations of command, standalone-tool, subagent, and MCP provider contracts, the bounded projection of configured local Skill roots, and runtime-free mapping @@ -94,7 +94,8 @@ Product-source boundary: import the adapter directly. The composition layer does not discover dynamic sources, prepare dependencies, or import plugin modules. - Product Assembly may consume this crate only from reviewed composition modules - such as `bitfun-core/plugin_runtime` or `bitfun-core/external_sources`; boundary + such as `bitfun-core/plugin_runtime`, `bitfun-core/external_sources`, or + `bitfun-core/instruction_sources`; boundary guards and focused assembly-path tests must change with any additional consumer. - This crate must not depend on Codex, Claude Code, or another ecosystem adapter. New ecosystems are sibling adapters registered by Product Assembly, not modes of diff --git a/src/crates/adapters/opencode-adapter/Cargo.toml b/src/crates/adapters/opencode-adapter/Cargo.toml index 68dc343f96..85adc36789 100644 --- a/src/crates/adapters/opencode-adapter/Cargo.toml +++ b/src/crates/adapters/opencode-adapter/Cargo.toml @@ -19,6 +19,7 @@ bitfun-static-hook-support = { path = "../static-hook-support" } dirs = { workspace = true } dunce = { workspace = true } hex = { workspace = true } +globset = { workspace = true } oxc-parse = { workspace = true } regex = { workspace = true } serde = { workspace = true } diff --git a/src/crates/adapters/opencode-adapter/src/instruction_source.rs b/src/crates/adapters/opencode-adapter/src/instruction_source.rs new file mode 100644 index 0000000000..01ea9b1c44 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/src/instruction_source.rs @@ -0,0 +1,726 @@ +use bitfun_services_core::bounded_fs::{ + collect_bounded_regular_files_with_prune, BoundedDirectoryWalkError, BoundedDirectoryWalkLimits, +}; +use bitfun_services_core::local_instructions::{ + local_instruction_path_exists, read_local_instruction_file, read_local_text_file, + LocalInstructionFile, LocalInstructionFiles, MAX_LOCAL_INSTRUCTION_FILES, +}; +use globset::{GlobBuilder, GlobMatcher}; +use serde_json::Value; +use std::path::{Component, Path, PathBuf}; + +use bitfun_services_core::jsonc::strip_jsonc; + +#[derive(Debug, Clone)] +pub struct OpenCodeInstructionSourceOptions { + pub config_dir: Option, + pub home_dir: Option, + pub workspace_root: Option, + pub display_root: String, +} + +impl OpenCodeInstructionSourceOptions { + pub fn from_environment() -> Self { + let home_dir = dirs::home_dir().filter(|path| path.is_absolute()); + let (config_dir, display_root) = match std::env::var_os("XDG_CONFIG_HOME") { + Some(value) => { + let path = PathBuf::from(value); + ( + path.is_absolute().then(|| path.join("opencode")), + "$XDG_CONFIG_HOME/opencode".to_string(), + ) + } + None => ( + home_dir + .as_deref() + .map(|home| home.join(".config/opencode")), + "~/.config/opencode".to_string(), + ), + }; + Self { + config_dir, + home_dir, + workspace_root: None, + display_root, + } + } +} + +impl Default for OpenCodeInstructionSourceOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub fn load_opencode_user_instructions( + options: &OpenCodeInstructionSourceOptions, +) -> Result, String> { + let Some(config_dir) = options.config_dir.as_deref() else { + return Ok(Vec::new()); + }; + let mut files = LocalInstructionFiles::default(); + let agents_path = config_dir.join("AGENTS.md"); + if local_instruction_path_exists(&agents_path)? { + files.extend(read_local_instruction_file( + &agents_path, + config_dir, + format!("{}/AGENTS.md", options.display_root), + )?); + } else if let Some(home_dir) = options.home_dir.as_deref() { + files.extend(read_local_instruction_file( + &home_dir.join(".claude/CLAUDE.md"), + home_dir, + "~/.claude/CLAUDE.md", + )?); + } + + let mut configured_instructions = None; + for config_name in ["config.json", "opencode.json", "opencode.jsonc"] { + let Some(config) = read_local_text_file( + &config_dir.join(config_name), + config_dir, + format!("{}/{config_name}", options.display_root), + )? + else { + continue; + }; + let value = + serde_json::from_str::(&strip_jsonc(&config.content)).map_err(|error| { + format!("Failed to parse OpenCode user config {config_name}: {error}") + })?; + if let Some(value) = value.get("instructions") { + let instructions = value.as_array().ok_or_else(|| { + format!("OpenCode user config {config_name} instructions must be an array") + })?; + configured_instructions = Some( + instructions + .iter() + .map(|value| { + value.as_str().map(str::to_string).ok_or_else(|| { + format!( + "OpenCode user config {config_name} instructions must contain strings" + ) + }) + }) + .collect::, _>>()?, + ); + } + } + + if let Some(configured_instructions) = configured_instructions { + for raw in configured_instructions + .into_iter() + .take(MAX_LOCAL_INSTRUCTION_FILES) + { + if raw.starts_with("http://") || raw.starts_with("https://") { + continue; + } + if let Some(relative) = raw.strip_prefix("~/") { + let Some(home_dir) = options.home_dir.as_deref() else { + continue; + }; + append_configured_path(&mut files, home_dir, relative, "~")?; + continue; + } + let path = Path::new(&raw); + if path.is_absolute() { + if has_glob_meta(&raw) { + let Some((root, pattern)) = split_absolute_glob(path) else { + continue; + }; + append_configured_path(&mut files, &root, &pattern, "")?; + continue; + } + let Some(parent) = path.parent() else { + continue; + }; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("instruction"); + if let Some(file) = read_local_instruction_file( + path, + parent, + format!("/{file_name}"), + )? { + files.push(file); + } + continue; + } + let Some(workspace_root) = options.workspace_root.as_deref() else { + continue; + }; + append_configured_path(&mut files, workspace_root, &raw, "")?; + } + } + Ok(files.into_files()) +} + +fn append_configured_path( + files: &mut LocalInstructionFiles, + root: &Path, + raw: &str, + display_root: &str, +) -> Result<(), String> { + if files.is_at_capacity() { + return Ok(()); + } + let Some(normalized) = normalize_relative_pattern(raw) else { + return Ok(()); + }; + if !has_glob_meta(&normalized) { + if let Some(file) = read_local_instruction_file( + &root.join(&normalized), + root, + format!("{display_root}/{}", normalized.replace('\\', "/")), + )? { + files.push(file); + } + return Ok(()); + } + + let matcher = GlobBuilder::new(&normalized.replace('\\', "/")) + .literal_separator(true) + .backslash_escape(false) + .build() + .map_err(|error| format!("Invalid OpenCode instruction glob {raw}: {error}"))? + .compile_matcher(); + let prefix = glob_static_prefix(&normalized); + let scan_root = if prefix.as_os_str().is_empty() { + root.to_path_buf() + } else { + root.join(prefix) + }; + let directory_matchers = glob_directory_matchers(&normalized) + .map_err(|error| format!("Invalid OpenCode instruction glob {raw}: {error}"))?; + let prune_root = root.to_path_buf(); + let matches = match collect_bounded_regular_files_with_prune( + &scan_root, + BoundedDirectoryWalkLimits { + max_depth: 32, + max_entries: 4096, + max_directories: 1024, + max_files: 4096, + }, + |path| { + should_descend_instruction_glob(path) + && directory_matchers.as_ref().map_or(true, |matchers| { + path.strip_prefix(&prune_root).ok().is_some_and(|relative| { + let depth = relative.components().count(); + matchers + .get(depth.saturating_sub(1)) + .is_some_and(|matcher| { + matcher.is_match(relative.to_string_lossy().replace('\\', "/")) + }) + }) + }) + }, + |path| { + path.strip_prefix(root).ok().is_some_and(|relative| { + matcher.is_match(relative.to_string_lossy().replace('\\', "/")) + }) + }, + ) { + Ok(matches) => matches, + Err(BoundedDirectoryWalkError::LimitExceeded(_)) => return Ok(()), + Err(error) => { + return Err(format!( + "Failed to expand OpenCode instruction glob {raw}: {error}" + )); + } + }; + for path in matches { + let relative = path + .strip_prefix(root) + .expect("bounded glob result stays below its root") + .to_string_lossy() + .replace('\\', "/"); + if let Some(file) = + read_local_instruction_file(&path, root, format!("{display_root}/{relative}"))? + { + files.push(file); + } + if files.is_at_capacity() { + break; + } + } + Ok(()) +} + +fn should_descend_instruction_glob(path: &Path) -> bool { + !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| matches!(name, ".git" | ".hg" | ".svn" | "node_modules" | "target")) +} + +fn split_absolute_glob(path: &Path) -> Option<(PathBuf, String)> { + let mut root = PathBuf::new(); + let mut pattern = Vec::new(); + let mut found_glob = false; + for component in path.components() { + let value = component.as_os_str().to_string_lossy(); + if found_glob || has_glob_meta(&value) { + found_glob = true; + pattern.push(value.into_owned()); + } else { + root.push(component.as_os_str()); + } + } + (found_glob && !root.as_os_str().is_empty()).then(|| (root, pattern.join("/"))) +} + +fn normalize_relative_pattern(raw: &str) -> Option { + let path = Path::new(raw.trim()); + if path.as_os_str().is_empty() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return None; + } + Some( + path.components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy()), + Component::CurDir => None, + _ => None, + }) + .collect::>() + .join("/"), + ) +} + +fn has_glob_meta(value: &str) -> bool { + value.contains(['*', '?', '[', '{']) +} + +fn glob_static_prefix(pattern: &str) -> PathBuf { + pattern + .split('/') + .take_while(|component| !has_glob_meta(component)) + .collect() +} + +fn glob_directory_matchers(pattern: &str) -> Result>, globset::Error> { + let components = pattern.split('/').collect::>(); + let directory_components = &components[..components.len().saturating_sub(1)]; + if directory_components + .iter() + .any(|component| component.contains("**")) + { + return Ok(None); + } + let mut prefix = Vec::new(); + let mut matchers = Vec::with_capacity(directory_components.len()); + for component in directory_components { + prefix.push(*component); + matchers.push( + GlobBuilder::new(&prefix.join("/")) + .literal_separator(true) + .backslash_escape(false) + .build()? + .compile_matcher(), + ); + } + Ok(Some(matchers)) +} + +#[cfg(test)] +mod tests { + use super::{load_opencode_user_instructions, OpenCodeInstructionSourceOptions}; + + #[test] + fn global_config_uses_native_override_order_and_rejects_urls() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("config"); + let home_dir = temp.path().join("home"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + std::fs::create_dir_all(&home_dir).expect("home directory"); + std::fs::write(config_dir.join("AGENTS.md"), "OpenCode base\n").expect("AGENTS"); + for name in ["from-config.md", "from-json.md", "from-jsonc.md"] { + std::fs::write(home_dir.join(name), format!("{name}\n")).expect("instruction file"); + } + std::fs::write( + config_dir.join("config.json"), + r#"{"instructions":["~/from-config.md"]}"#, + ) + .expect("config.json"); + std::fs::write( + config_dir.join("opencode.json"), + r#"{"instructions":["~/from-json.md"]}"#, + ) + .expect("opencode.json"); + std::fs::write( + config_dir.join("opencode.jsonc"), + r#"{ + // Later global config files override array fields. + "instructions": ["~/from-jsonc.md", "https://example.invalid/rules.md"], + }"#, + ) + .expect("opencode.jsonc"); + let options = OpenCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: Some(home_dir), + workspace_root: None, + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let files = load_opencode_user_instructions(&options).expect("instructions"); + let names = files + .iter() + .map(|file| file.name.as_str()) + .collect::>(); + + assert_eq!( + names, + vec!["$XDG_CONFIG_HOME/opencode/AGENTS.md", "~/from-jsonc.md"] + ); + assert!(!files.iter().any(|file| file.name.starts_with("http"))); + } + + #[test] + fn configured_local_paths_resolve_exact_absolute_and_workspace_glob_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("config"); + let home_dir = temp.path().join("home"); + let workspace = temp.path().join("workspace"); + let outside = temp.path().join("explicit.md"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + std::fs::create_dir_all(&home_dir).expect("home directory"); + std::fs::create_dir_all(workspace.join("rules/nested")).expect("rules directory"); + std::fs::write(workspace.join("rules/b.md"), "b\n").expect("b rule"); + std::fs::write(workspace.join("rules/a.md"), "a\n").expect("a rule"); + std::fs::write(workspace.join("rules/nested/c.md"), "c\n").expect("nested rule"); + std::fs::write(workspace.join("exact.txt"), "exact\n").expect("exact instruction"); + std::fs::write(&outside, "explicit\n").expect("absolute instruction"); + let config = serde_json::json!({ + "instructions": [ + "rules/*.md", + "exact.txt", + outside.to_string_lossy(), + "https://example.invalid/rules.md" + ] + }); + std::fs::write( + config_dir.join("opencode.json"), + serde_json::to_vec(&config).expect("serialized config"), + ) + .expect("opencode.json"); + let options = OpenCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: Some(home_dir), + workspace_root: Some(workspace), + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let files = load_opencode_user_instructions(&options).expect("instructions"); + let names = files + .iter() + .map(|file| file.name.as_str()) + .collect::>(); + + assert_eq!( + names, + vec![ + "/rules/a.md", + "/rules/b.md", + "/exact.txt", + "/explicit.md", + ] + ); + } + + #[test] + fn an_existing_empty_global_agents_file_suppresses_the_claude_fallback() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("config"); + let home_dir = temp.path().join("home"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + std::fs::create_dir_all(home_dir.join(".claude")).expect("Claude directory"); + std::fs::write(config_dir.join("AGENTS.md"), "").expect("empty AGENTS"); + std::fs::write( + home_dir.join(".claude/CLAUDE.md"), + "fallback must stay suppressed\n", + ) + .expect("Claude fallback"); + let options = OpenCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: Some(home_dir), + workspace_root: None, + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let files = load_opencode_user_instructions(&options).expect("instructions"); + + assert!(files.is_empty()); + } + + #[test] + fn configured_absolute_glob_is_bounded_to_its_declared_directory() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("config"); + let home_dir = temp.path().join("home"); + let external = temp.path().join("external"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + std::fs::create_dir_all(&home_dir).expect("home directory"); + std::fs::create_dir_all(&external).expect("external directory"); + std::fs::create_dir_all(external.join("nested")).expect("nested directory"); + std::fs::write(external.join("b.md"), "b\n").expect("b rule"); + std::fs::write(external.join("a.md"), "a\n").expect("a rule"); + std::fs::write(external.join("nested/c.md"), "c\n").expect("nested rule"); + std::fs::write(external.join("ignored.txt"), "ignored\n").expect("ignored file"); + let pattern = external.join("**/*.md").to_string_lossy().to_string(); + std::fs::write( + config_dir.join("opencode.json"), + serde_json::to_vec(&serde_json::json!({ "instructions": [pattern] })) + .expect("serialized config"), + ) + .expect("OpenCode config"); + let options = OpenCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: Some(home_dir), + workspace_root: None, + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let files = load_opencode_user_instructions(&options).expect("instructions"); + let names = files + .iter() + .map(|file| file.name.as_str()) + .collect::>(); + + assert_eq!( + names, + vec![ + "/a.md", + "/b.md", + "/nested/c.md", + ] + ); + } + + #[test] + fn an_existing_empty_config_is_reported_instead_of_treated_as_absent() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("config"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + std::fs::write(config_dir.join("opencode.json"), "").expect("empty config"); + let options = OpenCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: None, + workspace_root: None, + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let error = load_opencode_user_instructions(&options).expect_err("invalid empty config"); + + assert!(error.contains("Failed to parse OpenCode user config opencode.json")); + } + + #[test] + fn a_missing_config_root_does_not_fall_back_to_the_process_directory() { + let files = load_opencode_user_instructions(&OpenCodeInstructionSourceOptions { + config_dir: None, + home_dir: None, + workspace_root: None, + display_root: "~/.config/opencode".to_string(), + }) + .expect("instructions"); + + assert!(files.is_empty()); + } + + #[test] + fn broad_workspace_globs_skip_repository_and_dependency_directories() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("config"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + for directory in ["docs", ".git", "node_modules/pkg", "target/debug"] { + std::fs::create_dir_all(workspace.join(directory)).expect("workspace directory"); + } + std::fs::write(workspace.join("docs/team.md"), "visible\n").expect("visible rule"); + std::fs::write(workspace.join(".git/private.md"), "git internals\n").expect("git rule"); + std::fs::write( + workspace.join("node_modules/pkg/dependency.md"), + "dependency\n", + ) + .expect("dependency rule"); + std::fs::write(workspace.join("target/debug/output.md"), "build output\n") + .expect("build rule"); + std::fs::write( + config_dir.join("opencode.json"), + r#"{"instructions":["**/*.md"]}"#, + ) + .expect("OpenCode config"); + let options = OpenCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: None, + workspace_root: Some(workspace), + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let files = load_opencode_user_instructions(&options).expect("instructions"); + let names = files + .iter() + .map(|file| file.name.as_str()) + .collect::>(); + + assert_eq!(names, vec!["/docs/team.md"]); + } + + #[test] + fn a_non_recursive_glob_does_not_scan_unrelated_descendants() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("config"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + std::fs::create_dir_all(&workspace).expect("workspace directory"); + std::fs::write(workspace.join("root.md"), "root\n").expect("root rule"); + let mut unrelated = workspace.join("unrelated"); + for index in 0..33 { + unrelated = unrelated.join(format!("level-{index}")); + } + std::fs::create_dir_all(&unrelated).expect("deep unrelated tree"); + std::fs::write( + config_dir.join("opencode.json"), + r#"{"instructions":["*.md"]}"#, + ) + .expect("OpenCode config"); + let options = OpenCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: None, + workspace_root: Some(workspace), + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let files = load_opencode_user_instructions(&options).expect("instructions"); + + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "/root.md"); + } + + #[test] + fn wildcard_directory_components_prune_non_matching_sibling_trees() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("config"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + std::fs::create_dir_all(workspace.join("team-alpha")).expect("matching directory"); + std::fs::write(workspace.join("team-alpha/rule.md"), "matching\n").expect("matching rule"); + let unrelated = workspace.join("vendor"); + std::fs::create_dir_all(&unrelated).expect("unrelated sibling tree"); + for index in 0..4097 { + std::fs::write(unrelated.join(format!("entry-{index}.txt")), "") + .expect("unrelated entry"); + } + std::fs::write( + config_dir.join("opencode.json"), + r#"{"instructions":["team-*/*.md"]}"#, + ) + .expect("OpenCode config"); + let options = OpenCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: None, + workspace_root: Some(workspace), + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let files = load_opencode_user_instructions(&options).expect("instructions"); + + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "/team-alpha/rule.md"); + } + + #[test] + fn a_bounded_glob_failure_does_not_discard_existing_global_instructions() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("config"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + std::fs::write(config_dir.join("AGENTS.md"), "global\n").expect("global rule"); + let mut too_deep = workspace.join("docs"); + for index in 0..33 { + too_deep = too_deep.join(format!("level-{index}")); + } + std::fs::create_dir_all(&too_deep).expect("deep instruction tree"); + std::fs::write( + config_dir.join("opencode.json"), + r#"{"instructions":["**/*.md"]}"#, + ) + .expect("OpenCode config"); + let options = OpenCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: None, + workspace_root: Some(workspace), + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let files = load_opencode_user_instructions(&options).expect("instructions"); + + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "$XDG_CONFIG_HOME/opencode/AGENTS.md"); + } + + #[test] + fn adapter_enforces_the_shared_file_and_total_byte_budgets() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_dir = temp.path().join("config"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(&config_dir).expect("config directory"); + std::fs::create_dir_all(&workspace).expect("workspace directory"); + for index in 0..257 { + let name = format!("rules/{index:03}.md"); + let path = workspace.join(&name); + std::fs::create_dir_all(path.parent().expect("rule parent")).expect("rule directory"); + std::fs::write(path, "instruction\n").expect("rule"); + } + std::fs::write( + config_dir.join("opencode.json"), + serde_json::to_vec(&serde_json::json!({ "instructions": ["rules/*.md"] })) + .expect("serialized config"), + ) + .expect("OpenCode config"); + let options = OpenCodeInstructionSourceOptions { + config_dir: Some(config_dir), + home_dir: None, + workspace_root: Some(workspace), + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let files = load_opencode_user_instructions(&options).expect("instructions"); + + assert_eq!(files.len(), 256); + + let large_config_dir = temp.path().join("large-config"); + let large_workspace = temp.path().join("large-workspace"); + std::fs::create_dir_all(&large_config_dir).expect("large config directory"); + std::fs::create_dir_all(&large_workspace).expect("large workspace directory"); + for index in 0..3 { + std::fs::write( + large_workspace.join(format!("large-{index}.md")), + vec![b'x'; 1024 * 1024], + ) + .expect("large rule"); + } + std::fs::write( + large_config_dir.join("opencode.json"), + r#"{"instructions":["large-0.md","large-1.md","large-2.md"]}"#, + ) + .expect("large OpenCode config"); + let large_options = OpenCodeInstructionSourceOptions { + config_dir: Some(large_config_dir), + home_dir: None, + workspace_root: Some(large_workspace), + display_root: "$XDG_CONFIG_HOME/opencode".to_string(), + }; + + let large_files = load_opencode_user_instructions(&large_options).expect("large files"); + + assert_eq!(large_files.len(), 2); + } +} diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index d69d46cbbf..10fa1abcab 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -10,6 +10,7 @@ mod agent_source; mod command_source; mod hook_contributions; mod hook_source; +mod instruction_source; mod local_source_paths; mod mcp_source; mod skill_source; @@ -19,6 +20,7 @@ mod tool_source; pub use agent_source::{OpenCodeSubagentProvider, OpenCodeSubagentProviderOptions}; pub use command_source::{OpenCodeCommandProvider, OpenCodeCommandProviderOptions}; pub use hook_source::{OpenCodeHookProvider, OpenCodeHookProviderOptions}; +pub use instruction_source::{load_opencode_user_instructions, OpenCodeInstructionSourceOptions}; pub use mcp_source::{OpenCodeMcpProvider, OpenCodeMcpProviderOptions}; pub use skill_source::{ OpenCodeConfiguredSkillRoot, OpenCodeSkillRootProvider, OpenCodeSkillRootProviderOptions, diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index d5fd94de78..43e467b22e 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -65,7 +65,7 @@ SessionManager -> Session -> DialogTurn -> ModelRound concrete managed-package discovery and trust persistence stay in `services-integrations`, while ecosystem parsing and PluginRuntimeClient behavior remain in their adapter and execution owners. -- `plugin_runtime` and `external_sources` are the reviewed product-full +- `plugin_runtime`, `external_sources`, and `instruction_sources` are the reviewed product-full composition files allowed to select ecosystem adapters for their respective capability contracts. Product surfaces consume product-level views and must not import adapter or raw plugin runtime client types. diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index ed978a843b..238f89a14b 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -43,7 +43,9 @@ use crate::service::config::types::{ automatic_max_output_tokens, model_runtime_binding_fingerprint, ModelCapability, ModelCategory, }; use crate::service::instruction_context::{ - build_workspace_instruction_files_context, build_workspace_instruction_files_context_with_fs, + build_local_workspace_instruction_files_context_with_fs_detailed, + build_workspace_instruction_files_context_detailed, + build_workspace_instruction_files_context_with_fs, InstructionContextBuild, }; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::token_counter::TokenCounter; @@ -1061,25 +1063,51 @@ impl ExecutionEngine { ) -> (Option, bool) { let mut cacheable = true; if policy.includes(UserContextSection::WorkspaceInstructions) { - let instruction_context: BitFunResult> = + let instruction_context: BitFunResult = if let Some(workspace) = workspace { - if let Some(services) = workspace_services { - build_workspace_instruction_files_context_with_fs( - services.fs.as_ref(), - &workspace.root_path_string(), - ) - .await - } else if workspace.is_remote() { - cacheable = false; - Ok(None) + if workspace.is_remote() { + if let Some(services) = workspace_services { + build_workspace_instruction_files_context_with_fs( + services.fs.as_ref(), + &workspace.root_path_string(), + ) + .await + .map(|content| InstructionContextBuild { + content, + cacheable: true, + }) + } else { + Ok(InstructionContextBuild { + content: None, + cacheable: false, + }) + } } else { - build_workspace_instruction_files_context(workspace.root_path()).await + if let Some(services) = workspace_services { + build_local_workspace_instruction_files_context_with_fs_detailed( + workspace.root_path(), + services.fs.as_ref(), + &workspace.root_path_string(), + ) + .await + } else { + build_workspace_instruction_files_context_detailed( + workspace.root_path(), + ) + .await + } } } else { - Ok(None) + Ok(InstructionContextBuild { + content: None, + cacheable: true, + }) }; let instruction_context = match instruction_context { - Ok(instruction_context) => instruction_context, + Ok(instruction_context) => { + cacheable &= instruction_context.cacheable; + instruction_context.content + } Err(error) => { cacheable = false; warn!( @@ -4502,6 +4530,7 @@ mod tests { use crate::agentic::session::{TokenAnchor, TokenAnchorInput}; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::{local_workspace_services, WorkspaceBinding}; + use crate::instruction_sources::test_support::{lock_environment, EnvironmentGuard}; use crate::service::config::types::AIConfig; use crate::service::config::types::AIModelConfig; use crate::service::remote_ssh::workspace_state::workspace_session_identity; @@ -4537,6 +4566,13 @@ mod tests { } } + fn stable() -> Self { + Self { + operation_count: Arc::new(AtomicUsize::new(0)), + fail_next_probe: Arc::new(AtomicBool::new(false)), + } + } + fn record(&self) { self.operation_count.fetch_add(1, Ordering::SeqCst); } @@ -4614,8 +4650,17 @@ mod tests { let mut workspace_services = local_workspace_services(workspace_root.to_string_lossy().to_string()); workspace_services.fs = fs; + let identity = + workspace_session_identity("/workspace", Some("instruction-test"), Some("remote-host")) + .expect("remote test identity"); ( - WorkspaceBinding::new(None, workspace_root), + WorkspaceBinding::new_remote( + None, + workspace_root, + "instruction-test".to_string(), + "Instruction test".to_string(), + identity, + ), workspace_services, ) } @@ -4701,6 +4746,96 @@ mod tests { .contains("Recovered workspace instructions.")); } + #[tokio::test] + async fn local_workspace_services_still_include_local_user_instruction_sources() { + let _environment = lock_environment(); + let temp = tempfile::tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let xdg = temp.path().join("xdg"); + let codex = temp.path().join("codex"); + let claude = temp.path().join("claude"); + std::fs::create_dir_all(xdg.join("opencode")).expect("OpenCode config directory"); + std::fs::create_dir_all(&codex).expect("Codex config directory"); + std::fs::create_dir_all(&claude).expect("Claude config directory"); + std::fs::create_dir_all(&workspace_root).expect("workspace directory"); + std::fs::write(xdg.join("opencode/AGENTS.md"), "Local engine user\n") + .expect("OpenCode instructions"); + std::fs::write(workspace_root.join("AGENTS.md"), "Local engine project\n") + .expect("workspace instructions"); + let _guard = EnvironmentGuard::set(&[ + ("XDG_CONFIG_HOME", &xdg), + ("CODEX_HOME", &codex), + ("CLAUDE_CONFIG_DIR", &claude), + ]); + let workspace = WorkspaceBinding::new(None, workspace_root.clone()); + let workspace_services = + local_workspace_services(workspace_root.to_string_lossy().to_string()); + let policy = UserContextPolicy::empty().with_workspace_instructions(); + + let (context, cacheable) = ExecutionEngine::build_user_context_for_cache_miss( + Some(&workspace), + Some(&workspace_services), + PromptBuilderContext::new( + workspace_root.to_string_lossy().to_string(), + Some("session".to_string()), + Some("model".to_string()), + ), + &policy, + ) + .await; + let context = context.expect("user context"); + + assert!(cacheable); + assert!(context.contains("Local engine user")); + assert!(context.contains("Local engine project")); + } + + #[tokio::test] + async fn local_workspace_services_remain_the_project_instruction_io_owner() { + let _environment = lock_environment(); + let temp = tempfile::tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let xdg = temp.path().join("xdg"); + let codex = temp.path().join("codex"); + let claude = temp.path().join("claude"); + std::fs::create_dir_all(xdg.join("opencode")).expect("OpenCode config directory"); + std::fs::create_dir_all(&codex).expect("Codex config directory"); + std::fs::create_dir_all(&claude).expect("Claude config directory"); + std::fs::create_dir_all(&workspace_root).expect("workspace directory"); + std::fs::write(xdg.join("opencode/AGENTS.md"), "Local user source\n") + .expect("OpenCode instructions"); + std::fs::write(workspace_root.join("AGENTS.md"), "Disk project source\n") + .expect("disk instructions"); + let _guard = EnvironmentGuard::set(&[ + ("XDG_CONFIG_HOME", &xdg), + ("CODEX_HOME", &codex), + ("CLAUDE_CONFIG_DIR", &claude), + ]); + let workspace = WorkspaceBinding::new(None, workspace_root.clone()); + let mut workspace_services = + local_workspace_services(workspace_root.to_string_lossy().to_string()); + workspace_services.fs = Arc::new(InstructionWorkspaceFs::stable()); + let policy = UserContextPolicy::empty().with_workspace_instructions(); + + let (context, cacheable) = ExecutionEngine::build_user_context_for_cache_miss( + Some(&workspace), + Some(&workspace_services), + PromptBuilderContext::new( + workspace_root.to_string_lossy().to_string(), + Some("session".to_string()), + Some("model".to_string()), + ), + &policy, + ) + .await; + let context = context.expect("user context"); + + assert!(cacheable); + assert!(context.contains("Local user source")); + assert!(context.contains("Recovered workspace instructions.")); + assert!(!context.contains("Disk project source")); + } + #[tokio::test] async fn remote_workspace_without_services_is_not_cacheable() { let identity = workspace_session_identity( diff --git a/src/crates/assembly/core/src/instruction_sources.rs b/src/crates/assembly/core/src/instruction_sources.rs new file mode 100644 index 0000000000..d6cfba6a9e --- /dev/null +++ b/src/crates/assembly/core/src/instruction_sources.rs @@ -0,0 +1,164 @@ +//! Product-full composition for local user instruction source adapters. + +use bitfun_claude_code_adapter::{ + load_claude_code_user_instructions, ClaudeCodeInstructionSourceOptions, +}; +use bitfun_codex_adapter::{load_codex_user_instructions, CodexInstructionSourceOptions}; +use bitfun_opencode_adapter::{load_opencode_user_instructions, OpenCodeInstructionSourceOptions}; +use bitfun_services_core::local_instructions::{LocalInstructionFile, LocalInstructionFiles}; +use std::path::Path; + +pub(crate) struct LocalUserInstructionFiles { + pub(crate) files: Vec, + pub(crate) cacheable: bool, +} + +pub(crate) async fn load_local_user_instruction_files( + workspace_root: &Path, +) -> LocalUserInstructionFiles { + let workspace_root = workspace_root.to_path_buf(); + let result = tokio::task::spawn_blocking(move || { + let mut files = Vec::new(); + let mut cacheable = true; + let mut opencode_options = OpenCodeInstructionSourceOptions::from_environment(); + opencode_options.workspace_root = Some(workspace_root); + match load_opencode_user_instructions(&opencode_options) { + Ok(source_files) => files.extend(source_files), + Err(_) => { + cacheable = false; + log::warn!( + "Failed to load OpenCode user instructions; retrying on the next message" + ); + } + } + match load_codex_user_instructions(&CodexInstructionSourceOptions::from_environment()) { + Ok(source_files) => files.extend(source_files), + Err(_) => { + cacheable = false; + log::warn!("Failed to load Codex user instructions; retrying on the next message"); + } + } + match load_claude_code_user_instructions( + &ClaudeCodeInstructionSourceOptions::from_environment(), + ) { + Ok(source_files) => files.extend(source_files), + Err(_) => { + cacheable = false; + log::warn!( + "Failed to load Claude Code user instructions; retrying on the next message" + ); + } + } + deduplicate_user_instruction_files(&mut files); + LocalUserInstructionFiles { files, cacheable } + }) + .await; + match result { + Ok(files) => files, + Err(_) => { + log::warn!("Failed to join local instruction discovery; retrying on the next message"); + LocalUserInstructionFiles { + files: Vec::new(), + cacheable: false, + } + } + } +} + +fn deduplicate_user_instruction_files(files: &mut Vec) { + let mut bounded = LocalInstructionFiles::default(); + bounded.extend(std::mem::take(files)); + *files = bounded.into_files(); +} + +#[cfg(test)] +mod tests { + use super::deduplicate_user_instruction_files; + use bitfun_services_core::local_instructions::LocalInstructionFile; + use std::path::PathBuf; + + #[test] + fn merged_user_sources_keep_first_identity_and_enforce_the_shared_file_budget() { + let mut files = (0..257) + .map(|index| LocalInstructionFile { + canonical_path: PathBuf::from(format!("source-{index}.md")), + name: format!("source-{index}.md"), + content: format!("instruction {index}"), + }) + .collect::>(); + files.insert( + 1, + LocalInstructionFile { + canonical_path: PathBuf::from("source-0.md"), + name: "duplicate.md".to_string(), + content: "duplicate must lose".to_string(), + }, + ); + + deduplicate_user_instruction_files(&mut files); + + assert_eq!(files.len(), 256); + assert_eq!(files[0].name, "source-0.md"); + assert!(!files.iter().any(|file| file.name == "duplicate.md")); + } + + #[test] + fn merged_user_sources_enforce_the_shared_total_byte_budget() { + let mut files = (0..3) + .map(|index| LocalInstructionFile { + canonical_path: PathBuf::from(format!("large-{index}.md")), + name: format!("large-{index}.md"), + content: "x".repeat(1024 * 1024), + }) + .collect::>(); + + deduplicate_user_instruction_files(&mut files); + + assert_eq!(files.len(), 2); + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::ffi::OsString; + use std::path::Path; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + static ENVIRONMENT: OnceLock> = OnceLock::new(); + + pub(crate) fn lock_environment() -> MutexGuard<'static, ()> { + ENVIRONMENT + .get_or_init(|| Mutex::new(())) + .lock() + .expect("instruction environment lock") + } + + pub(crate) struct EnvironmentGuard { + values: Vec<(&'static str, Option)>, + } + + impl EnvironmentGuard { + pub(crate) fn set(values: &[(&'static str, &Path)]) -> Self { + let previous = values + .iter() + .map(|(name, value)| { + let previous = std::env::var_os(name); + std::env::set_var(name, value); + (*name, previous) + }) + .collect(); + Self { values: previous } + } + } + + impl Drop for EnvironmentGuard { + fn drop(&mut self) { + for (name, value) in self.values.drain(..) { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + } +} diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 9f4e98ec79..a5eca6660e 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -27,6 +27,8 @@ mod external_tools; #[cfg(feature = "product-domains")] pub mod function_agents; // Function-based agents pub mod infrastructure; // AI clients, storage, logging, events +#[cfg(feature = "product-full")] +mod instruction_sources; #[cfg(feature = "product-domains")] pub mod miniapp; // AI-generated instant apps (Zero-Dialect Runtime) #[cfg(feature = "product-full")] diff --git a/src/crates/assembly/core/src/service/instruction_context.rs b/src/crates/assembly/core/src/service/instruction_context.rs index d5dcbb9959..bead53113a 100644 --- a/src/crates/assembly/core/src/service/instruction_context.rs +++ b/src/crates/assembly/core/src/service/instruction_context.rs @@ -3,20 +3,91 @@ use bitfun_runtime_ports::WorkspaceFileSystem; use bitfun_services_core::workspace_instructions::WorkspaceInstructionFile; use std::path::Path; +const MAX_RENDERED_INSTRUCTION_FILES: usize = 256; +const MAX_RENDERED_INSTRUCTION_BYTES: usize = 2 * 1024 * 1024; + +pub(crate) struct InstructionContextBuild { + pub(crate) content: Option, + pub(crate) cacheable: bool, +} + pub(crate) async fn build_workspace_instruction_files_context( workspace_root: &Path, ) -> BitFunResult> { - let instruction_files = + Ok( + build_workspace_instruction_files_context_detailed(workspace_root) + .await? + .content, + ) +} + +pub(crate) async fn build_workspace_instruction_files_context_detailed( + workspace_root: &Path, +) -> BitFunResult { + let user_instruction_files = + crate::instruction_sources::load_local_user_instruction_files(workspace_root).await; + let workspace_instruction_files = bitfun_services_core::workspace_instructions::read_workspace_instruction_files( workspace_root, ) .await .map_err(BitFunError::service)?; - Ok(render_workspace_instruction_files_section( - &instruction_files, + Ok(compose_local_instruction_sources( + workspace_root, + user_instruction_files, + workspace_instruction_files, )) } +pub(crate) async fn build_local_workspace_instruction_files_context_with_fs_detailed( + workspace_root: &Path, + fs: &dyn WorkspaceFileSystem, + workspace_root_path: &str, +) -> BitFunResult { + let user_instruction_files = + crate::instruction_sources::load_local_user_instruction_files(workspace_root).await; + let workspace_instruction_files = + bitfun_services_core::workspace_instructions::read_workspace_instruction_files_with_fs( + fs, + workspace_root_path, + ) + .await + .map_err(BitFunError::service)?; + Ok(compose_local_instruction_sources( + workspace_root, + user_instruction_files, + workspace_instruction_files, + )) +} + +fn compose_local_instruction_sources( + workspace_root: &Path, + user_instruction_files: crate::instruction_sources::LocalUserInstructionFiles, + mut workspace_instruction_files: Vec, +) -> InstructionContextBuild { + bitfun_services_core::workspace_instructions::retain_distinct_local_workspace_instruction_files( + workspace_root, + user_instruction_files + .files + .iter() + .map(|file| file.canonical_path.clone()), + &mut workspace_instruction_files, + ); + let mut instruction_files = user_instruction_files + .files + .into_iter() + .map(|file| WorkspaceInstructionFile { + name: file.name, + content: file.content, + }) + .collect::>(); + instruction_files.extend(workspace_instruction_files); + InstructionContextBuild { + content: render_workspace_instruction_files_section(&instruction_files), + cacheable: user_instruction_files.cacheable, + } +} + pub(crate) async fn build_workspace_instruction_files_context_with_fs( fs: &dyn WorkspaceFileSystem, workspace_root: &str, @@ -43,13 +114,267 @@ fn render_workspace_instruction_files_section( let mut rendered = String::from("## Codebase and user instructions\n\nBe sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n"); - for file in files { - rendered.push_str(&format!( + let mut rendered_file_count = 0usize; + for file in files.iter().take(MAX_RENDERED_INSTRUCTION_FILES) { + let escaped_name = escape_document_name(&file.name); + let document = format!( "\n{}\n\n\n", - file.name, + escaped_name, file.content.trim() - )); + ); + if rendered.len().saturating_add(document.len()) > MAX_RENDERED_INSTRUCTION_BYTES { + break; + } + rendered.push_str(&document); + rendered_file_count += 1; + } + + (rendered_file_count > 0).then(|| rendered.trim_end().to_string()) +} + +fn escape_document_name(name: &str) -> String { + name.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::{ + build_workspace_instruction_files_context, + build_workspace_instruction_files_context_detailed, + build_workspace_instruction_files_context_with_fs, + render_workspace_instruction_files_section, WorkspaceInstructionFile, + }; + use crate::instruction_sources::test_support::{lock_environment, EnvironmentGuard}; + use bitfun_services_core::workspace::LocalWorkspaceFs; + + #[tokio::test] + async fn local_user_instructions_precede_workspace_instructions_by_ecosystem_priority() { + let _environment = lock_environment(); + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let xdg = temp.path().join("xdg"); + let codex = temp.path().join("codex"); + let claude = temp.path().join("claude"); + std::fs::create_dir_all(xdg.join("opencode")).expect("OpenCode config directory"); + std::fs::create_dir_all(&codex).expect("Codex config directory"); + std::fs::create_dir_all(&claude).expect("Claude config directory"); + std::fs::create_dir_all(&workspace).expect("workspace directory"); + std::fs::write(xdg.join("opencode/AGENTS.md"), "OpenCode user\n") + .expect("OpenCode instructions"); + std::fs::write(codex.join("AGENTS.md"), "Codex user\n").expect("Codex instructions"); + std::fs::write(claude.join("CLAUDE.md"), "Claude user\n").expect("Claude instructions"); + std::fs::write(workspace.join("AGENTS.md"), "Workspace project\n") + .expect("workspace instructions"); + let _guard = EnvironmentGuard::set(&[ + ("XDG_CONFIG_HOME", &xdg), + ("CODEX_HOME", &codex), + ("CLAUDE_CONFIG_DIR", &claude), + ]); + + let rendered = build_workspace_instruction_files_context(&workspace) + .await + .expect("instruction context") + .expect("rendered instructions"); + + let positions = [ + rendered + .find("OpenCode user") + .expect("OpenCode instructions"), + rendered.find("Codex user").expect("Codex instructions"), + rendered.find("Claude user").expect("Claude instructions"), + rendered + .find("Workspace project") + .expect("workspace instructions"), + ]; + assert!(positions.windows(2).all(|pair| pair[0] < pair[1])); + } + + #[tokio::test] + async fn opencode_global_config_resolves_relative_instructions_in_the_local_workspace() { + let _environment = lock_environment(); + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let xdg = temp.path().join("xdg"); + let codex = temp.path().join("codex"); + let claude = temp.path().join("claude"); + std::fs::create_dir_all(xdg.join("opencode")).expect("OpenCode config directory"); + std::fs::create_dir_all(&codex).expect("Codex config directory"); + std::fs::create_dir_all(&claude).expect("Claude config directory"); + std::fs::create_dir_all(workspace.join("shared")).expect("workspace shared directory"); + std::fs::write(xdg.join("opencode/AGENTS.md"), "OpenCode user\n") + .expect("OpenCode instructions"); + std::fs::write( + xdg.join("opencode/opencode.json"), + r#"{"instructions":["shared/*.md"]}"#, + ) + .expect("OpenCode config"); + std::fs::write( + workspace.join("shared/team.md"), + "Shared workspace policy\n", + ) + .expect("configured workspace instructions"); + let _guard = EnvironmentGuard::set(&[ + ("XDG_CONFIG_HOME", &xdg), + ("CODEX_HOME", &codex), + ("CLAUDE_CONFIG_DIR", &claude), + ]); + + let rendered = build_workspace_instruction_files_context(&workspace) + .await + .expect("instruction context") + .expect("rendered instructions"); + + assert!(rendered.contains("Shared workspace policy")); + assert!(rendered.contains("")); + } + + #[tokio::test] + async fn invalid_user_source_does_not_hide_workspace_instructions() { + let _environment = lock_environment(); + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let xdg = temp.path().join("xdg"); + let codex = temp.path().join("codex"); + let claude = temp.path().join("claude"); + std::fs::create_dir_all(xdg.join("opencode")).expect("OpenCode config directory"); + std::fs::create_dir_all(&codex).expect("Codex config directory"); + std::fs::create_dir_all(&claude).expect("Claude config directory"); + std::fs::create_dir_all(&workspace).expect("workspace directory"); + std::fs::write(xdg.join("opencode/AGENTS.md"), "OpenCode user\n") + .expect("OpenCode instructions"); + std::fs::write(xdg.join("opencode/opencode.json"), "{ invalid json") + .expect("invalid OpenCode config"); + std::fs::write(workspace.join("AGENTS.md"), "Workspace survives\n") + .expect("workspace instructions"); + let _guard = EnvironmentGuard::set(&[ + ("XDG_CONFIG_HOME", &xdg), + ("CODEX_HOME", &codex), + ("CLAUDE_CONFIG_DIR", &claude), + ]); + + let build = build_workspace_instruction_files_context_detailed(&workspace) + .await + .expect("workspace instructions survive user source failure"); + let rendered = build.content.expect("rendered instructions"); + + assert!(rendered.contains("Workspace survives")); + assert!(!build.cacheable); + } + + #[tokio::test] + async fn a_user_configured_workspace_file_is_not_rendered_again_as_a_project_source() { + let _environment = lock_environment(); + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let xdg = temp.path().join("xdg"); + let codex = temp.path().join("codex"); + let claude = temp.path().join("claude"); + std::fs::create_dir_all(xdg.join("opencode")).expect("OpenCode config directory"); + std::fs::create_dir_all(&codex).expect("Codex config directory"); + std::fs::create_dir_all(&claude).expect("Claude config directory"); + std::fs::create_dir_all(&workspace).expect("workspace directory"); + std::fs::write( + xdg.join("opencode/opencode.json"), + r#"{"instructions":["AGENTS.md"]}"#, + ) + .expect("OpenCode config"); + std::fs::write(workspace.join("AGENTS.md"), "One physical source\n") + .expect("workspace instructions"); + let _guard = EnvironmentGuard::set(&[ + ("XDG_CONFIG_HOME", &xdg), + ("CODEX_HOME", &codex), + ("CLAUDE_CONFIG_DIR", &claude), + ]); + + let rendered = build_workspace_instruction_files_context(&workspace) + .await + .expect("instruction context") + .expect("rendered instructions"); + + assert_eq!(rendered.matches("One physical source").count(), 1); } - Some(rendered.trim_end().to_string()) + #[tokio::test] + async fn port_backed_workspace_never_falls_back_to_local_user_sources() { + let _environment = lock_environment(); + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let xdg = temp.path().join("xdg"); + let codex = temp.path().join("codex"); + let claude = temp.path().join("claude"); + std::fs::create_dir_all(xdg.join("opencode")).expect("OpenCode config directory"); + std::fs::create_dir_all(&codex).expect("Codex config directory"); + std::fs::create_dir_all(&claude).expect("Claude config directory"); + std::fs::create_dir_all(&workspace).expect("workspace directory"); + std::fs::write( + xdg.join("opencode/AGENTS.md"), + "Local user must stay absent\n", + ) + .expect("OpenCode instructions"); + std::fs::write(workspace.join("AGENTS.md"), "Port project instructions\n") + .expect("workspace instructions"); + let _guard = EnvironmentGuard::set(&[ + ("XDG_CONFIG_HOME", &xdg), + ("CODEX_HOME", &codex), + ("CLAUDE_CONFIG_DIR", &claude), + ]); + let root = workspace.to_string_lossy(); + + let rendered = build_workspace_instruction_files_context_with_fs(&LocalWorkspaceFs, &root) + .await + .expect("port-backed instruction context") + .expect("rendered instructions"); + + assert!(rendered.contains("Port project instructions")); + assert!(!rendered.contains("Local user must stay absent")); + } + + #[test] + fn rendered_document_names_escape_markup_characters() { + let rendered = render_workspace_instruction_files_section(&[WorkspaceInstructionFile { + name: "/team\"&.md".to_string(), + content: "Team instructions".to_string(), + }]) + .expect("rendered instructions"); + + assert!(rendered.contains("")); + assert!(!rendered.contains("name=\"")); + } + + #[test] + fn rendered_instruction_context_keeps_one_shared_content_budget() { + let files = (0..3) + .map(|index| WorkspaceInstructionFile { + name: format!("source-{index}.md"), + content: format!("source-{index}\n{}", "x".repeat(700 * 1024)), + }) + .collect::>(); + + let rendered = + render_workspace_instruction_files_section(&files).expect("rendered instructions"); + + assert!(rendered.len() <= 2 * 1024 * 1024); + assert!(rendered.contains("source-0\n")); + assert!(rendered.contains("source-1\n")); + assert!(!rendered.contains("source-2\n")); + } + + #[test] + fn rendered_instruction_context_keeps_one_shared_file_budget() { + let files = (0..257) + .map(|index| WorkspaceInstructionFile { + name: format!("source-{index}.md"), + content: format!("instruction {index}"), + }) + .collect::>(); + + let rendered = + render_workspace_instruction_files_section(&files).expect("rendered instructions"); + + assert_eq!(rendered.matches(" bool, +) -> Result, BoundedDirectoryWalkError> { + collect_bounded_regular_files_with_prune(root, limits, |_| true, matches) +} + +/// Iteratively collects matching regular files while allowing callers to prune +/// known-unrelated directories before they consume traversal limits. +pub fn collect_bounded_regular_files_with_prune( + root: &Path, + limits: BoundedDirectoryWalkLimits, + mut should_descend: impl FnMut(&Path) -> bool, mut matches: impl FnMut(&Path) -> bool, ) -> Result, BoundedDirectoryWalkError> { let metadata = match std::fs::symlink_metadata(root) { @@ -142,6 +153,9 @@ pub fn collect_bounded_regular_files( continue; } if metadata.is_dir() { + if !should_descend(&path) { + continue; + } let next_depth = depth.saturating_add(1); if next_depth > limits.max_depth { return Err(BoundedDirectoryWalkError::LimitExceeded( diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 6ae9961e08..1e427037e8 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -12,6 +12,7 @@ mod file_lock; pub mod filesystem; pub mod json_store; pub mod jsonc; +pub mod local_instructions; #[cfg(feature = "workspace-runtime")] pub mod local_runtime_ports; #[cfg(feature = "lsp")] diff --git a/src/crates/services/services-core/src/local_instructions.rs b/src/crates/services/services-core/src/local_instructions.rs new file mode 100644 index 0000000000..094d7a679c --- /dev/null +++ b/src/crates/services/services-core/src/local_instructions.rs @@ -0,0 +1,205 @@ +//! Bounded local reads shared by ecosystem-specific instruction adapters. + +use crate::bounded_fs::{read_bounded_text, BoundedTextRead}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +pub const MAX_LOCAL_INSTRUCTION_FILE_BYTES: usize = 1024 * 1024; +pub const MAX_LOCAL_INSTRUCTION_FILES: usize = 256; +pub const MAX_LOCAL_INSTRUCTION_TOTAL_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalInstructionFile { + pub canonical_path: PathBuf, + pub name: String, + pub content: String, +} + +#[derive(Debug, Default)] +pub struct LocalInstructionFiles { + files: Vec, + canonical_paths: HashSet, + total_bytes: usize, +} + +impl LocalInstructionFiles { + pub fn push(&mut self, file: LocalInstructionFile) -> bool { + if self.files.len() >= MAX_LOCAL_INSTRUCTION_FILES + || self.canonical_paths.contains(&file.canonical_path) + { + return false; + } + if self.total_bytes.saturating_add(file.content.len()) > MAX_LOCAL_INSTRUCTION_TOTAL_BYTES { + self.total_bytes = MAX_LOCAL_INSTRUCTION_TOTAL_BYTES; + return false; + } + self.canonical_paths.insert(file.canonical_path.clone()); + self.total_bytes += file.content.len(); + self.files.push(file); + true + } + + pub fn extend(&mut self, files: impl IntoIterator) { + for file in files { + self.push(file); + if self.is_at_capacity() { + break; + } + } + } + + pub fn contains_path(&self, path: &Path) -> bool { + self.canonical_paths.contains(path) + } + + pub fn is_at_capacity(&self) -> bool { + self.files.len() >= MAX_LOCAL_INSTRUCTION_FILES + || self.total_bytes >= MAX_LOCAL_INSTRUCTION_TOTAL_BYTES + } + + pub fn into_files(self) -> Vec { + self.files + } +} + +pub fn local_instruction_path_exists(path: &Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(metadata) => Ok(metadata.file_type().is_file() || metadata.file_type().is_symlink()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!( + "Failed to inspect local instruction file {}: {error}", + path.display() + )), + } +} + +pub fn read_local_instruction_file( + path: &Path, + allowed_root: &Path, + name: impl Into, +) -> Result, String> { + Ok(read_local_text_file(path, allowed_root, name)? + .filter(|file| !file.content.trim().is_empty())) +} + +pub fn read_local_text_file( + path: &Path, + allowed_root: &Path, + name: impl Into, +) -> Result, String> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if !metadata.file_type().is_file() && !metadata.file_type().is_symlink() => { + return Ok(None); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "Failed to inspect local instruction file {}: {error}", + path.display() + )); + } + } + + let canonical_root = std::fs::canonicalize(allowed_root).map_err(|error| { + format!( + "Failed to resolve local instruction root {}: {error}", + allowed_root.display() + ) + })?; + let canonical_path = std::fs::canonicalize(path).map_err(|error| { + format!( + "Failed to resolve local instruction file {}: {error}", + path.display() + ) + })?; + if !canonical_path.starts_with(&canonical_root) { + return Err(format!( + "Rejected local instruction file outside its declared root: {}", + path.display() + )); + } + let metadata = std::fs::metadata(&canonical_path).map_err(|error| { + format!( + "Failed to inspect resolved local instruction file {}: {error}", + path.display() + ) + })?; + if !metadata.is_file() { + return Ok(None); + } + let content = match read_bounded_text(&canonical_path, MAX_LOCAL_INSTRUCTION_FILE_BYTES) + .map_err(|error| { + format!( + "Failed to read local instruction file {}: {error}", + path.display() + ) + })? { + BoundedTextRead::Content(content) => content, + BoundedTextRead::TooLarge => { + return Err(format!( + "Local instruction file exceeds the per-file byte limit: {}", + path.display() + )); + } + BoundedTextRead::InvalidUtf8 => { + return Err(format!( + "Local instruction file is not valid UTF-8: {}", + path.display() + )); + } + }; + Ok(Some(LocalInstructionFile { + canonical_path, + name: name.into(), + content, + })) +} + +#[cfg(test)] +mod tests { + use super::{ + read_local_instruction_file, read_local_text_file, MAX_LOCAL_INSTRUCTION_FILE_BYTES, + }; + + #[test] + fn empty_text_files_remain_distinguishable_from_empty_instruction_files() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("empty.json"); + std::fs::write(&path, "").expect("empty file"); + + assert!(read_local_text_file(&path, temp.path(), "empty.json") + .expect("text read") + .is_some()); + assert!(read_local_instruction_file(&path, temp.path(), "empty.md") + .expect("instruction read") + .is_none()); + } + + #[test] + fn explicit_paths_outside_the_declared_root_are_rejected() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("root"); + let outside = temp.path().join("outside.md"); + std::fs::create_dir_all(&root).expect("root directory"); + std::fs::write(&outside, "outside\n").expect("outside file"); + + let error = read_local_instruction_file(&outside, &root, "outside.md") + .expect_err("outside path must fail closed"); + + assert!(error.contains("outside its declared root")); + } + + #[test] + fn oversized_local_sources_fail_instead_of_looking_absent() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("oversized.md"); + std::fs::write(&path, vec![b'x'; MAX_LOCAL_INSTRUCTION_FILE_BYTES + 1]) + .expect("oversized file"); + + let error = read_local_instruction_file(&path, temp.path(), "oversized.md") + .expect_err("oversized source must fail closed"); + + assert!(error.contains("exceeds the per-file byte limit")); + } +} diff --git a/src/crates/services/services-core/src/workspace_instructions.rs b/src/crates/services/services-core/src/workspace_instructions.rs index 291aa6b05d..31f0ac8a11 100644 --- a/src/crates/services/services-core/src/workspace_instructions.rs +++ b/src/crates/services/services-core/src/workspace_instructions.rs @@ -39,6 +39,23 @@ pub struct WorkspaceInstructionFile { pub content: String, } +/// Keeps the first physical local file across a preceding source set and the +/// workspace-relative files resolved by this owner. +pub fn retain_distinct_local_workspace_instruction_files( + workspace_root: &Path, + preceding_canonical_paths: impl IntoIterator, + files: &mut Vec, +) { + let mut seen = preceding_canonical_paths + .into_iter() + .collect::>(); + files.retain(|file| { + std::fs::canonicalize(workspace_root.join(&file.name)) + .map(|path| seen.insert(path)) + .unwrap_or(true) + }); +} + #[derive(Debug, Clone)] struct InstructionDirEntry { relative_path: String,