From 6e1b0560cd1ac616b4deb8ed3d2b8b79818612f7 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Wed, 2 Sep 2026 06:22:23 +0500 Subject: [PATCH] feat: remove reads a bundle, cursor gets its workspace scope, and the pins that moved Rendered from NDDev-it-com/setup-systems by tools/render_public_trees.py at 0.0.54. This tree is generated: fix the source and the renderer, never this checkout. --- .github/workflows/ci.yml | 8 +- .github/workflows/security.yml | 12 +- CHANGELOG.md | 29 + Cargo.lock | 8 +- Cargo.toml | 8 +- README.md | 2 +- SUPPORT.md | 20 +- crates/harness-runtime/src/facts.rs | 2 +- crates/harness-runtime/src/human.rs | 13 + crates/harness-runtime/src/software.rs | 2 +- crates/harness-runtime/src/wire.rs | 541 +++++++++++++++++- crates/opencode-setup-system/src/main.rs | 4 +- crates/opencode-setup-system/src/software.rs | 78 +-- crates/provider-v3/src/info.rs | 56 +- crates/provider-v3/src/plan.rs | 195 +++++++ install.ps1 | 2 +- install.sh | 2 +- provider-kit/v3/KIT-IDENTITY.json | 4 +- provider-kit/v3/README.md | 61 +- provider-kit/v3/SHA256SUMS | 4 +- provider-kit/v3/manifest.json | 2 +- provider-kit/v3/provider-info.schema.json | 1 + references/opencode-baseline.json | 132 ++--- setups/full-auto/setup.json | 2 +- .../references/authoring-agents.md | 2 +- .../references/authoring-instructions.md | 2 +- .../nddev-builder/references/surfaces.md | 10 +- 27 files changed, 1006 insertions(+), 196 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8c3037..eb3c312 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: name: rust permissions: contents: read - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/rust-ci.yml@dfdad5c71a5c9d1c952a7fb9472b7ecc8a28ed56 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/rust-ci.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 with: toolchain: '1.98.0' # The three-OS matrix is the evidence ADR-0113 asks for, and standard @@ -46,7 +46,7 @@ jobs: name: supply-chain permissions: contents: read - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/rust-supply-chain.yml@dfdad5c71a5c9d1c952a7fb9472b7ecc8a28ed56 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/rust-supply-chain.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 with: enable_deny: true enable_audit: true @@ -56,7 +56,7 @@ jobs: name: actionlint permissions: contents: read - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/actionlint.yml@dfdad5c71a5c9d1c952a7fb9472b7ecc8a28ed56 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/actionlint.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 pr-hygiene: name: pr-hygiene @@ -68,7 +68,7 @@ jobs: # that would use them is off. issues: write # the stale bot closes threads pull-requests: write # the labeler applies labels - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/pr-hygiene.yml@dfdad5c71a5c9d1c952a7fb9472b7ecc8a28ed56 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/pr-hygiene.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 boundary: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4d43867..b09e376 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -26,7 +26,7 @@ jobs: actions: read # CodeQL reads the workflow definitions it analyses contents: read security-events: write # CodeQL publishes its findings to code scanning - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/public-codeql.yml@dfdad5c71a5c9d1c952a7fb9472b7ecc8a28ed56 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/public-codeql.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 with: languages: '["rust", "actions"]' queries: security-and-quality @@ -37,7 +37,7 @@ jobs: permissions: contents: read pull-requests: write # writes the review it produces - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/public-dependency-review.yml@dfdad5c71a5c9d1c952a7fb9472b7ecc8a28ed56 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/public-dependency-review.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 with: fail_on_severity: moderate @@ -52,13 +52,13 @@ jobs: contents: read id-token: write # mints the OIDC token Scorecard's publication is signed with security-events: write # Scorecard publishes its findings to code scanning - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/public-scorecard.yml@dfdad5c71a5c9d1c952a7fb9472b7ecc8a28ed56 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/public-scorecard.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 osv: name: osv permissions: contents: read - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/osv-scan.yml@dfdad5c71a5c9d1c952a7fb9472b7ecc8a28ed56 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/osv-scan.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 zizmor: name: zizmor @@ -66,10 +66,10 @@ jobs: actions: read # the SARIF upload reads this run to attach its results contents: read security-events: write # zizmor publishes its findings to code scanning - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/zizmor-sarif.yml@dfdad5c71a5c9d1c952a7fb9472b7ecc8a28ed56 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/zizmor-sarif.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 secret-scan: name: secret-scan permissions: contents: read - uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/secret-scan.yml@dfdad5c71a5c9d1c952a7fb9472b7ecc8a28ed56 + uses: NDDev-OpenNetwork/ci-workflows/.github/workflows/secret-scan.yml@1ab6708b62ec7bd17f2d8a519c6fcc39edb22243 diff --git a/CHANGELOG.md b/CHANGELOG.md index c0f80ea..77c068d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,35 @@ sibling project the same week this note was added. ## [Unreleased] +## [0.0.54] - 2026-09-02 + +`remove` reads a bundle, and the plan says per path what stays. +The consumer's ADR-0129 case — a component that owns one key of a file the +person also writes — was inexpressible on the wire: a remove plan was built +without bytes, so "this path outlives me at bytes-without-the-key" had no +carrier. Now `plan-operation --operation remove` takes the same five bundle +arguments `replace` takes, and the plan gains an `end_state` member only when +one rides — per touched path, `removed`, or `final_bytes` with the member, +sha256 and byte_length copied from the bundle's own manifest — so a plan without a +bundle is byte-identical to what 0.0.53 produced. The apply refuses a bundle +the plan never described, a plan with survivors fed no bundle, and a bundle +whose members are not the ones the plan bound, all before the lock. After a +remove with survivors the record names no file: the bytes are the person's. + +Declared through `plan_request_fields` in the ADR-0125 order, measured at each +step: kit 0.2.8 names the field, `ai-stp-cli 0.0.14` on PyPI accepts it, then +this release declares it. Kit 0.2.8 is vendored. + +Cursor declares a `project` scope — `.cursor/rules`, `.cursor/commands`, +`.cursor/hooks.json`, `.cursor/mcp.json`, `.cursor/agents`, `.cursor/skills`, +each a literal workspace join in the 2026.08.31-4057e58 bytes. The home +`agents` question (cursor#94) was re-measured and the answer stayed no; the +global profile and its digest do not move. + +Five products moved overnight and are pinned at what they publish today: +Claude Code 2.1.258, Codex 0.152.1, OpenCode 1.18.26, Cursor CLI +2026.08.31-4057e58, Antigravity 1.1.23. Grok 1.0.13 and Pi 0.84.4 stand. + ## [0.0.53] - 2026-09-01 The other two readers of the exposed name, and the boundary that diff --git a/Cargo.lock b/Cargo.lock index 00e2107..25b5341 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "harness-runtime" -version = "0.0.53" +version = "0.0.54" dependencies = [ "provider-v3", "serde", @@ -128,7 +128,7 @@ dependencies = [ [[package]] name = "opencode-setup-system" -version = "0.0.53" +version = "0.0.54" dependencies = [ "harness-runtime", "provider-v3", @@ -147,7 +147,7 @@ dependencies = [ [[package]] name = "provider-v3" -version = "0.0.53" +version = "0.0.54" dependencies = [ "serde", "serde_json", @@ -209,7 +209,7 @@ dependencies = [ [[package]] name = "setup-core" -version = "0.0.53" +version = "0.0.54" dependencies = [ "miniz_oxide", "serde", diff --git a/Cargo.toml b/Cargo.toml index ba422b8..ce96094 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.0.53" +version = "0.0.54" edition = "2024" rust-version = "1.89" license = "AGPL-3.0-or-later" @@ -23,9 +23,9 @@ sha2 = "0.11" # `setup-core::archive`); an inflate loop is not, because its bugs are # memory-safety bugs and it is not improved by being hand-written here. miniz_oxide = "0.9" -setup-core = { path = "crates/setup-core", version = "0.0.53" } -provider-v3 = { path = "crates/provider-v3", version = "0.0.53" } -harness-runtime = { path = "crates/harness-runtime", version = "0.0.53" } +setup-core = { path = "crates/setup-core", version = "0.0.54" } +provider-v3 = { path = "crates/provider-v3", version = "0.0.54" } +harness-runtime = { path = "crates/harness-runtime", version = "0.0.54" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 4021bad..def7a18 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ release is a convenience, not the authorised copy. ```bash docker run --rm -v "$HOME/.config:/config" \ - ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.53 \ + ghcr.io/nddev-opennetwork/opencode-setup-system:0.0.54 \ status --target /config/ --json ``` diff --git a/SUPPORT.md b/SUPPORT.md index 497c04a..3cdd551 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -185,7 +185,7 @@ every path below is relative to that root. | Path | Component kinds routed here | Decided by | | --- | --- | --- | -| `skills` | `skill` | [source](https://opencode.ai/docs/skills/) -- and measured by running the pinned 1.18.25 product with `debug skill` against a temporary HOME, 2026-08-29 | +| `skills` | `skill` | [source](https://opencode.ai/docs/skills/) -- and measured by running the 1.18.25 product with `debug skill` against a temporary HOME, 2026-08-29 | This root is read by several products at once, so under this scope `remove`, the backup and a restore act on the files this program @@ -212,7 +212,7 @@ other file beside a target. **`opencode-runtime-state`** -- One row for what the product writes **outside its configuration home entirely**, because it writes to three other roots and none of them had a row. -Measured 2026-08-28 by running the pinned 1.18.25 binary in a clean `HOME` with every XDG variable cleared. A bare `--version` created: +Measured 2026-08-28 by running the 1.18.25 binary in a clean `HOME` with every XDG variable cleared. A bare `--version` created: * `~/.local/share/opencode/{log,repos}` * `~/.local/state/opencode` * `~/.cache/opencode/bin` @@ -221,15 +221,15 @@ The product is XDG-native and this is the rest of that specification: the config **None of them gets a row of its own**, because every recorded path here is relative to the target and these are relative to roots this provider never evaluates against; the guard that enforces it refuses such a row, correctly. Recorded in this sentence instead, the same way antigravity's `~/.cache/ms-playwright-go` is, so a reader looking for everything the product writes does not stop at `~/.config/opencode` and get a wrong answer. -**And it settles a search result that says otherwise.** Several pages claim opencode uses `%APPDATA%` on Windows; they are about third-party plugins rather than the vendor. The product's own resolver has **no `win32` branch at all**, so `~/.config/opencode` is correct on all three systems and this baseline's `config_home` stands. (measured by running the pinned 1.18.25 binary in a clean HOME, and confirmed at the line in its own Global module, 2026-08-28) +**And it settles a search result that says otherwise.** Several pages claim opencode uses `%APPDATA%` on Windows; they are about third-party plugins rather than the vendor. The product's own resolver has **no `win32` branch at all**, so `~/.config/opencode` is correct on all three systems and this baseline's `config_home` stands. (measured by running the 1.18.25 binary in a clean HOME, and confirmed at the line in its own Global module, 2026-08-28) -**`agent`** -- Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/agent(s)/.md` and the project row as `.opencode/agent/.md` or `.opencode/agents/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. (measured in the pinned 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings) +**`agent`** -- Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/agent(s)/.md` and the project row as `.opencode/agent/.md` or `.opencode/agents/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. (measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings) -**`command`** -- Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/command(s)/.md` and the project row as `.opencode/command/.md` or `.opencode/commands/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. (measured in the pinned 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings) +**`command`** -- Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/command(s)/.md` and the project row as `.opencode/command/.md` or `.opencode/commands/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. (measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings) -**`skill`** -- Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/skill(s)//SKILL.md` and the project row as `.opencode/skill//SKILL.md` or `.opencode/skills//SKILL.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. (measured in the pinned 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings) +**`skill`** -- Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/skill(s)//SKILL.md` and the project row as `.opencode/skill//SKILL.md` or `.opencode/skills//SKILL.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. (measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings) -**`plugin`** -- Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/plugin(s)//` and the project row as `.opencode/plugin//` or `.opencode/plugins//`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. (measured in the pinned 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings) +**`plugin`** -- Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/plugin(s)//` and the project row as `.opencode/plugin//` or `.opencode/plugins//`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. (measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings) **`managed-config`** -- Not a path in the target, and named without an extension for that reason: the managed configuration directory is a **system** path, one per operating system, and every recorded path here is relative to the target. @@ -237,15 +237,15 @@ The product is XDG-native and this is the rest of that specification: the config * macOS — `/Library/Application Support/opencode/` * Windows — `%ProgramData%\\opencode` -All three are in the pinned 1.18.25 bundle's own emitted source, in one switch: `case"darwin": return "/Library/Application Support/opencode"; case"win32": return join(process.env.ProgramData||"C:\\ProgramData","opencode"); default: return "/etc/opencode"`. macOS additionally reads a managed plist from the `ai.opencode.managed` preference domain, deployable by MDM, which the same bundle carries as `parseManagedPlist` and `managedPreferences`. +All three are in the 1.18.25 bundle's own emitted source, in one switch: `case"darwin": return "/Library/Application Support/opencode"; case"win32": return join(process.env.ProgramData||"C:\\ProgramData","opencode"); default: return "/etc/opencode"`. macOS additionally reads a managed plist from the `ai.opencode.managed` preference domain, deployable by MDM, which the same bundle carries as `parseManagedPlist` and `managedPreferences`. **It bears on the `full-auto` posture**, the same way the managed policy does on the harness next door. An administrator's `opencode.json` there is loaded at the highest priority tier and overrides everything, so this provider can install, verify and restore a permissive posture cleanly on a managed machine and change nothing about what the product actually permits. The setup is not wrong and the target is not wrong; a higher layer wins. Recorded and never touched: it needs root or Administrator to write, it is not under the configuration home this provider is given, and a provider that edited an organisation's policy would be doing the one thing this estate refuses everywhere else. -The user configuration home is **not** per-OS, which is why only this row is. The same bundle resolves it as `XDG_CONFIG_HOME || ~/.config` joined with the application name, with no platform branch at all. ([source](https://opencode.ai/docs/config) -- measured in the pinned 1.18.25 bundle, whose bytes match this baseline's own sha256) +The user configuration home is **not** per-OS, which is why only this row is. The same bundle resolves it as `XDG_CONFIG_HOME || ~/.config` joined with the application name, with no platform branch at all. ([source](https://opencode.ai/docs/config) -- measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256) -**`mcp_config.json`** -- MCP servers are the `mcp` key inside `opencode.json` -- `{"mcp": {"": {"type": "local", "command": [...]}}}` -- confirmed on the vendor's MCP page 2026-08-29 and in the product's own built-in `customize-opencode` skill. That file is owned here and written and restored whole, so MCP is covered by the `setting` kind. **A key inside a file is not a projection surface.** No separate MCP file exists under the config home. ([source](https://opencode.ai/docs/mcp-servers/) -- and the product's own built-in customize-opencode skill, read from the pinned 1.18.25 artifact) +**`mcp_config.json`** -- MCP servers are the `mcp` key inside `opencode.json` -- `{"mcp": {"": {"type": "local", "command": [...]}}}` -- confirmed on the vendor's MCP page 2026-08-29 and in the product's own built-in `customize-opencode` skill. That file is owned here and written and restored whole, so MCP is covered by the `setting` kind. **A key inside a file is not a projection surface.** No separate MCP file exists under the config home. ([source](https://opencode.ai/docs/mcp-servers/) -- and the product's own built-in customize-opencode skill, read from the 1.18.25 artifact) **`hooks.json`** -- **Hooks are functions a plugin module exports**, not a file. The vendor's plugin page, read 2026-08-29: *"A plugin is a JavaScript/TypeScript module that exports one or more plugin functions. Each function receives a context object and returns a hooks object."* The names -- `tool.execute.before`, `session.created`, `permission.asked` and the rest -- are keys of that returned object. There is no `hooks.json` and no `hooks` key in `opencode.json`, so a hook reaches this product through `plugins/`, which is owned and routes `plugin`. ([source](https://opencode.ai/docs/plugins/)) diff --git a/crates/harness-runtime/src/facts.rs b/crates/harness-runtime/src/facts.rs index 50fc57a..2b8d14c 100644 --- a/crates/harness-runtime/src/facts.rs +++ b/crates/harness-runtime/src/facts.rs @@ -124,7 +124,7 @@ pub struct Harness { /// /// Ownership decides what `remove` takes and what the target digest /// covers. It does not decide what the product obeys, and for one harness - /// here those are different sets: measured against the pinned 1.18.25 + /// here those are different sets: measured against the 1.18.25 /// binary, `opencode` reads `opencode.jsonc` after `opencode.json` and /// keeps the later one, and globs `{skill,skills}` where only the plural /// is owned. So a target whose owned bytes are clean can be running a file diff --git a/crates/harness-runtime/src/human.rs b/crates/harness-runtime/src/human.rs index 95d083c..2487d95 100644 --- a/crates/harness-runtime/src/human.rs +++ b/crates/harness-runtime/src/human.rs @@ -1122,6 +1122,9 @@ fn mutate( // install: that arrives over the wire, with artifacts somebody else // downloaded between planning and applying. software_artifacts: Vec::new(), + // The human surface removes whole and carries no bundle, so no path + // has a second sentence. + end_state: Vec::new(), effects: effect_lines(harness, &effect, applied.setup_id.as_deref()), })?; let plan_digest = artifact.digest()?; @@ -1194,6 +1197,16 @@ fn effect_lines(harness: &Harness, effect: &Effect<'_>, setup_id: Option<&str>) capture, format!("write the {} files the bundle declares", files.len()), ], + // Nor this one: the surviving bytes are packed by the consumer. + Effect::RemoveKeeping { files } => { + let mut lines = vec![capture]; + lines.extend(wire::taken_before_writing(harness, HUMAN_SCOPE)); + lines.push(format!( + "leave {} declared files behind at the bytes the bundle carries", + files.len() + )); + lines + } Effect::Materialize { setup } => { // "over the entries this provider owns" is true and is heard as // "writes the setup's files". `replace_managed_from` removes each diff --git a/crates/harness-runtime/src/software.rs b/crates/harness-runtime/src/software.rs index 0e9f5a9..8428dee 100644 --- a/crates/harness-runtime/src/software.rs +++ b/crates/harness-runtime/src/software.rs @@ -679,7 +679,7 @@ mod tests { /// it came from, and the rollback to the version beside it -- are all false /// the moment the product replaces those bytes itself. Anthropic documents /// the variable for exactly this case, a distribution channel somebody else - /// controls, and the pinned 2.1.251 artifact carries it nine times. + /// controls, and the 2.1.251 artifact carries it nine times. /// /// Both directions, because only the pair says anything. A build that added /// the variable to every launch would pass the first assertion while setting diff --git a/crates/harness-runtime/src/wire.rs b/crates/harness-runtime/src/wire.rs index 68ed319..d5eded8 100644 --- a/crates/harness-runtime/src/wire.rs +++ b/crates/harness-runtime/src/wire.rs @@ -22,8 +22,8 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; use provider_v3::argv::{Bundle as ArgvBundle, Invocation, PlanRequest}; -use provider_v3::bundle::{Bundle, Claim}; -use provider_v3::plan::{PlanArtifact, PlanInputs}; +use provider_v3::bundle::{Bundle, Claim, FILES_PREFIX}; +use provider_v3::plan::{EndState, PlanArtifact, PlanInputs}; use provider_v3::{Error, Operation, Result, WireReason}; use setup_core::backup::{BackupRef, Pool, SLOT_SCHEMA, SlotRecord}; use setup_core::journal::{JOURNAL_SCHEMA, Journal, Phase}; @@ -532,17 +532,23 @@ fn honourable(harness: &Harness, request: &PlanRequest) -> Result<()> { // released 0.0.50 while the consumer designed remove's `end_state` // extension (their ADR-0129): plan `planned, valid: true`, apply removed // everything, dummy bundle untouched -- accept and ignore, twice, exit 0. - // Their rollout story assumed the loud refusal existed; now it does. When - // remove learns to read a bundle (kit 0.2.8+, `end_state`), this narrows - // to the operations that still read none. + // Their rollout story assumed the loud refusal existed; now it does. + // + // `remove` learned to read one on 2026-09-02 (kit 0.2.8, `end_state`): + // the bundle carries the bytes a path keeps once the setup is gone. So + // this narrowed rather than lifted -- backup, restore and the software + // operations still read none and still refuse. if request.bundle.is_some() - && !matches!(request.operation, Operation::Install | Operation::Replace) + && !matches!( + request.operation, + Operation::Install | Operation::Replace | Operation::Remove + ) { return Err(Error::refuse( WireReason::UnsupportedOperation, format!( "{} reads no bundle, so one named for it would be echoed into \ - the plan and never read; install and replace are the \ + the plan and never read; install, replace and remove are the \ operations that take one", request.operation ), @@ -731,6 +737,7 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result { let (planned, effects) = software::plan( @@ -765,6 +772,27 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result { let mut lines = vec!["capture the current target before removing".to_owned()]; lines.extend(taken_before_writing(harness, request.target_scope)); + // A bundle on a remove names what stays: the consumer rebuilt a + // host file without the key this setup put there, and the file + // outlives the setup at exactly those bytes. Read and verified + // here, as install's is, so the plan is never issued for bytes + // the apply would refuse -- same reader, same limits, same + // `validate-bundle` semantics, by the consumer's request. + if let Some(named) = request.bundle.as_ref() { + let verified = verified_bundle(harness, named, Surface::At(request.target_scope))?; + end_state = end_states_of(harness, &resolved, request.target_scope, &verified)?; + lines.push(format!( + "leave {} declared files behind at the bytes the bundle carries", + verified.files.len() + )); + lines.extend( + verified + .files + .keys() + .take(16) + .map(|path| format!("leave {path}")), + ); + } (lines, None, None) } Operation::Install | Operation::Replace => (bundle_effects(harness, request)?, None, None), @@ -793,11 +821,172 @@ fn plan(harness: &Harness, target: &Path, request: &PlanRequest) -> Result, + verified: &Bundle, +) -> Result> { + let taken: Vec = if harness.scoped_for(scope).is_some() { + // No readable record is the state `remove_managed` refuses, and the + // plan says nothing rather than guessing: the apply will refuse by + // name, as it does without a bundle. + match ProviderState::read(target.root(), harness.state_file)? { + StateReading::Current(state) => state.written_paths, + StateReading::Absent | StateReading::ForeignSchema { .. } => Vec::new(), + } + } else { + harness + .native_namespaces + .iter() + .map(|namespace| (*namespace).to_owned()) + .collect() + }; + let mut entries: Vec = taken + .iter() + .filter(|path| !verified.files.contains_key(*path)) + .map(|path| EndState::removed(path)) + .collect(); + for path in verified.files.keys() { + let Some(record) = verified + .manifest + .files + .iter() + .find(|file| &file.path == path) + else { + // `Bundle::read` refuses a member the manifest never declared, so + // this is unreachable by construction; refusing keeps it so. + return Err(Error::refuse( + WireReason::DigestMismatch, + format!("the bundle carries {path:?} and its manifest does not declare it"), + )); + }; + entries.push(EndState::final_bytes( + path, + &format!("{FILES_PREFIX}{path}"), + &record.digest, + record.byte_length, + )); + } + Ok(entries) +} + +/// The end states a remove plan recorded, or none. +fn end_states_in(artifact: &serde_json::Value) -> Result> { + match artifact.get("end_state") { + None => Ok(Vec::new()), + Some(value) => serde_json::from_value(value.clone()).map_err(|source| { + Error::refuse( + WireReason::ProviderUnavailable, + format!("the approved plan's end_state member cannot be read: {source}"), + ) + }), + } +} + +/// What a remove plan authorizes: everything gone, or everything gone and the +/// bundle's files left behind -- and only the bundle the plan described. +fn removal_effect<'a>( + harness: &Harness, + artifact: &serde_json::Value, + bundle: Option<&ArgvBundle>, + verified: &'a mut Option, + applied: &mut Applied, +) -> Result> { + let planned = end_states_in(artifact)?; + if !planned.iter().any(EndState::survives) { + // The plan the consumer approved leaves nothing behind, so a bundle + // at apply is an authorization the plan never gave. + if bundle.is_some() { + return Err(Error::refuse( + WireReason::UnsupportedOperation, + "this remove plan leaves no bytes behind, so a bundle named at apply \ + was never authorized; plan the removal with the bundle", + )); + } + return Ok(Effect::Remove); + } + let Some(named) = bundle else { + return Err(Error::refuse( + WireReason::UnsupportedBundleFormat, + "this remove was planned with surviving bytes, and no bundle was \ + named to carry them", + )); + }; + let ready = verified.insert(verified_bundle( + harness, + named, + Surface::At(scope_of(artifact)), + )?); + check_survivors(&planned, ready)?; + // Which bundle put the surviving bytes there is provenance worth keeping; + // the passport's setup identity is not, because the setup is the thing + // that just ended. + applied.bundle_format = Some(named.binding.bundle_format.clone()); + applied.bundle_digest = Some(named.binding.bundle_digest.clone()); + applied.artifact_digest = Some(named.binding.artifact_digest.clone()); + Ok(Effect::RemoveKeeping { + files: &ready.files, + }) +} + +/// The bundle handed to `apply` must be the one the plan described, member by +/// member: the plan digest the consumer approved binds these entries, and the +/// bundle's own manifest binds its files, so the two are compared here rather +/// than trusted to agree. +fn check_survivors(planned: &[EndState], ready: &Bundle) -> Result<()> { + for entry in planned.iter().filter(|entry| entry.survives()) { + let record = ready + .manifest + .files + .iter() + .find(|file| file.path == entry.path); + let agrees = record.is_some_and(|file| { + Some(&file.digest) == entry.sha256.as_ref() + && Some(file.byte_length) == entry.byte_length + && entry.member.as_deref() == Some(format!("{FILES_PREFIX}{}", file.path).as_str()) + }); + if !agrees { + return Err(Error::refuse( + WireReason::DigestMismatch, + format!( + "the plan leaves {:?} at bytes the bundle named for apply does not carry; \ + no effect was made", + entry.path + ), + )); + } + } + let planned_survivors = planned.iter().filter(|entry| entry.survives()).count(); + if planned_survivors != ready.files.len() { + return Err(Error::refuse( + WireReason::DigestMismatch, + format!( + "the plan leaves {planned_survivors} files behind and the bundle carries {}; \ + no effect was made", + ready.files.len() + ), + )); + } + Ok(()) +} + /// The backup a restore names, or the newest when it names none. pub(crate) fn chosen_backup(pool: &Pool, requested: Option<&str>) -> Result { match requested { @@ -838,6 +1027,18 @@ pub(crate) enum Effect<'a> { }, /// Withdraw everything this provider owns. Remove, + /// Withdraw everything this provider owns, then put back the files a + /// verified bundle says outlive the setup -- a host file without the key + /// this setup contributed, at the consumer's reconstructed bytes. + /// + /// The same capture-before-effect sequence as everything else here; what + /// changes is what the target looks like afterwards and what the state + /// records about it: nothing of ours, because the surviving bytes are the + /// person's. + RemoveKeeping { + /// Each surviving file's bytes and mode, by target-relative path. + files: &'a BTreeMap, u32)>, + }, /// Write a complete setup from the local catalog over those namespaces. Materialize { /// The setup to write. @@ -951,7 +1152,7 @@ fn apply( // a similar name a few lines below, and one of the two had to give way. downloaded: &[std::path::PathBuf], ) -> Result { - let verified: Option; + let mut verified: Option = None; let artifact = load_plan(plan_path, plan_digest)?; let operation = operation_of(&artifact)?; let expires_at = string_field(&artifact, "expires_at")?; @@ -1005,7 +1206,9 @@ fn apply( .and_then(serde_json::Value::as_str) .map(str::to_owned), }, - Operation::Remove => Effect::Remove, + Operation::Remove => { + removal_effect(harness, &artifact, bundle, &mut verified, &mut applied)? + } Operation::Install | Operation::Replace => { let Some(named) = bundle.as_ref() else { return Err(Error::refuse( @@ -1162,6 +1365,13 @@ pub(crate) fn perform( Effect::Remove => { remove_managed(harness, &resolved, mutation.target_scope).map(|()| vec![]) } + // Gone, then present -- and recorded as not ours. `written_paths` is + // the inventory a later scoped removal deletes from, and a file the + // person keeps after this setup ended is exactly the file that removal + // must not take (their ADR-0129: the file stays the user's). + Effect::RemoveKeeping { files } => { + remove_keeping_files(harness, &resolved, mutation.target_scope, files) + } Effect::Materialize { setup } => { setup.check_within(harness)?; replace_managed_from(harness, &resolved, &setup.payload, mutation.target_scope) @@ -1756,6 +1966,26 @@ fn remove_managed( Ok(()) } +/// Withdraw what this provider owns, then put the survivors back. +/// +/// Returns the empty inventory on purpose: the bytes written here are the +/// person's, not this provider's, and a later scoped removal must not find +/// them in the record. +fn remove_keeping_files( + harness: &Harness, + target: &Target, + scope: Option, + files: &BTreeMap, u32)>, +) -> Result> { + remove_managed(harness, target, scope)?; + for (relative, (bytes, mode)) in files { + let destination = target.root().join(relative); + lock::atomic_write(&destination, bytes)?; + set_mode(&destination, *mode)?; + } + Ok(Vec::new()) +} + /// Remove `path`, keeping anything the harness promised never to touch. /// /// `never_touch` named three effects of ownership and protected two of them: a @@ -4647,14 +4877,16 @@ mod tests { // loud refusal that did not exist. This is that refusal. When remove // learns to read a bundle (kit 0.2.8+), it narrows rather than lifts: // the operations that read none keep refusing. - let target = seeded("bundle-on-remove"); + // 0.0.54 taught remove to read one, so the operation that reads none + // here is backup; the shape of the refusal is the same. + let target = seeded("bundle-on-backup"); let (bytes, bundle_digest, artifact) = bundle_bytes(&[("AGENTS.md", "x\n", 0o644)]); - let artifact_path = target.join("..").join("bundle-on-remove.zip"); + let artifact_path = target.join("..").join("bundle-on-backup.zip"); fs::write(&artifact_path, &bytes).unwrap(); let mut plan_args = vec![ "--operation".to_owned(), - "remove".to_owned(), + "backup".to_owned(), "--provider-release-digest".to_owned(), RELEASE.to_owned(), "--operation-id".to_owned(), @@ -4678,6 +4910,291 @@ mod tests { ); } + /// Plan a remove that keeps `files` at the bundle's bytes, and hand back + /// the plan response with the apply arguments it authorizes. + fn remove_keeping_plan( + target: &Path, + tag: &str, + files: &[(&str, &str, u32)], + scope: Option<&str>, + ) -> (serde_json::Value, Vec) { + let (bytes, bundle_digest, artifact) = bundle_bytes(files); + let artifact_path = target.join("..").join(format!("keep-{tag}.zip")); + fs::write(&artifact_path, &bytes).unwrap(); + let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); + let mut plan_args = vec![ + "--operation".to_owned(), + "remove".to_owned(), + "--provider-release-digest".to_owned(), + RELEASE.to_owned(), + "--operation-id".to_owned(), + format!("operation_01KEEP{}", tag.to_uppercase()), + "--expires-at".to_owned(), + far_future().to_owned(), + ]; + if let Some(scope) = scope { + plan_args.push("--target-scope".to_owned()); + plan_args.push(scope.to_owned()); + } + plan_args.extend(flags.clone()); + let borrowed: Vec<&str> = plan_args.iter().map(String::as_str).collect(); + let planned = run(args("plan-operation", target, &borrowed)); + assert_eq!(planned["state"], "planned", "{planned}"); + let plan_path = target.join("..").join(format!("keep-{tag}-plan.json")); + fs::write( + &plan_path, + setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(), + ) + .unwrap(); + let mut apply_args = vec![ + "--plan".to_owned(), + plan_path.to_string_lossy().into_owned(), + "--plan-digest".to_owned(), + planned["plan_digest"].as_str().unwrap().to_owned(), + "--provider-release-digest".to_owned(), + RELEASE.to_owned(), + ]; + apply_args.extend(flags); + (planned, apply_args) + } + + /// The consumer's `ADR-0129` case, end to end: a contribution put one key + /// into a file the person also writes, and removing the contribution must + /// leave the file at the person's remaining bytes rather than delete it. + /// The bytes arrive as an ordinary bundle on `remove`; the plan says, per + /// path, what stays and what goes; the apply does exactly that. + #[test] + fn a_remove_may_carry_the_bytes_a_path_keeps_and_leaves_them_behind() { + let target = seeded("remove-keeping"); + let survivor = "{\"model\":\"mine, not the setup's\"}\n"; + let (planned, apply_args) = remove_keeping_plan( + &target, + "global", + &[("settings.json", survivor, 0o644)], + None, + ); + + // The plan states the end state of every path it touches: the two + // namespaces that go, and the one file that stays -- bound to the + // bundle member that carries it. + let states = planned["plan"]["end_state"].as_array().unwrap(); + let of = |path: &str| { + states + .iter() + .find(|entry| entry["path"] == path) + .unwrap_or_else(|| panic!("no end state for {path}: {states:?}")) + }; + assert_eq!(of("AGENTS.md")["end_state"], "removed"); + assert_eq!(of("skills")["end_state"], "removed"); + assert_eq!(of("settings.json")["end_state"], "final_bytes"); + assert_eq!(of("settings.json")["member"], "files/settings.json"); + assert_eq!( + of("settings.json")["sha256"], + setup_core::digest::of_bytes(survivor.as_bytes()) + ); + assert_eq!(of("settings.json")["byte_length"], survivor.len()); + assert_eq!(states.len(), 3, "{states:?}"); + assert!( + planned["effects"] + .as_array() + .unwrap() + .iter() + .any(|line| line.as_str().unwrap().contains("leave settings.json")), + "{}", + planned["effects"] + ); + + let borrowed: Vec<&str> = apply_args.iter().map(String::as_str).collect(); + let applied = run(args("apply-operation", &target, &borrowed)); + assert_eq!(applied["state"], "verified", "{applied}"); + assert_eq!( + fs::read_to_string(target.join("settings.json")).unwrap(), + survivor, + "the surviving file is not at the bytes the bundle carried" + ); + assert!(!target.join("AGENTS.md").exists()); + assert!(!target.join("skills").exists()); + assert_eq!( + fs::read_to_string(target.join("unrelated.txt")).unwrap(), + "keep me" + ); + assert_eq!( + fs::read_to_string(target.join(".credentials.json")).unwrap(), + "SECRET" + ); + // Not ours any more: the record names no file, and the bundle that + // put the bytes there is named as provenance. + assert!(recorded_written(&target).is_empty()); + let state: serde_json::Value = + serde_json::from_slice(&fs::read(target.join(TEST.state_file)).unwrap()).unwrap(); + assert_eq!(state["bundle_digest"], planned["bundle_digest"]); + assert!(state["setup_stable_id"].is_null(), "{state}"); + let after = run(args("status", &target, &[])); + assert_eq!(after["state"], "managed", "{after}"); + assert_eq!(after["drift_state"], "clean", "{after}"); + } + + /// A remove planned without a bundle carries no `end_state` member at all, + /// so every plan digest that verified before this build still verifies. + #[test] + fn a_remove_without_a_bundle_carries_no_end_state_member() { + let target = seeded("remove-bare-plan"); + let planned = run(args( + "plan-operation", + &target, + &[ + "--operation", + "remove", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01BARE", + "--expires-at", + far_future(), + ], + )); + assert_eq!(planned["state"], "planned", "{planned}"); + assert!( + planned["plan"].get("end_state").is_none(), + "{}", + planned["plan"] + ); + } + + /// The plan the consumer approved is the authorization. A bundle that the + /// plan never described is refused at apply, and a plan that described one + /// refuses to apply without it -- both before the lock, with no effect. + #[test] + fn a_remove_apply_takes_exactly_the_bundle_its_plan_described() { + let target = seeded("remove-authorization"); + let (bytes, bundle_digest, artifact) = + bundle_bytes(&[("settings.json", "{\"kept\":true}\n", 0o644)]); + let artifact_path = target.join("..").join("unplanned.zip"); + fs::write(&artifact_path, &bytes).unwrap(); + let flags = bundle_flags(&artifact_path, &bundle_digest, &artifact, bytes.len()); + + // Planned bare, applied with a bundle: never authorized. + let planned = run(args( + "plan-operation", + &target, + &[ + "--operation", + "remove", + "--provider-release-digest", + RELEASE, + "--operation-id", + "operation_01UNPLANNED", + "--expires-at", + far_future(), + ], + )); + let plan_path = target.join("..").join("bare-plan.json"); + fs::write( + &plan_path, + setup_core::canonical::to_canonical_bytes(&planned["plan"]).unwrap(), + ) + .unwrap(); + let mut apply_args = vec![ + "--plan".to_owned(), + plan_path.to_string_lossy().into_owned(), + "--plan-digest".to_owned(), + planned["plan_digest"].as_str().unwrap().to_owned(), + "--provider-release-digest".to_owned(), + RELEASE.to_owned(), + ]; + apply_args.extend(flags); + let borrowed: Vec<&str> = apply_args.iter().map(String::as_str).collect(); + let error = refuse(args("apply-operation", &target, &borrowed)); + assert_eq!(error.reason(), Some(WireReason::UnsupportedOperation)); + assert!( + error.detail().contains("never authorized"), + "{}", + error.detail() + ); + assert!( + target.join("AGENTS.md").exists(), + "a refusal made an effect" + ); + + // Planned with survivors, applied without the bundle: nothing to + // leave them at. + let (_, with_bundle) = + remove_keeping_plan(&target, "unfed", &[("settings.json", "{}\n", 0o644)], None); + let bare: Vec<&str> = with_bundle.iter().take(6).map(String::as_str).collect(); + let error = refuse(args("apply-operation", &target, &bare)); + assert_eq!(error.reason(), Some(WireReason::UnsupportedBundleFormat)); + assert!( + target.join("AGENTS.md").exists(), + "a refusal made an effect" + ); + + // Planned with one bundle, applied with another whose bytes differ: + // the plan's end state names bytes this bundle does not carry. + let (other_bytes, other_digest, other_artifact) = + bundle_bytes(&[("settings.json", "{\"other\":1}\n", 0o644)]); + let other_path = target.join("..").join("other.zip"); + fs::write(&other_path, &other_bytes).unwrap(); + let mut swapped: Vec = with_bundle.iter().take(6).cloned().collect(); + swapped.extend(bundle_flags( + &other_path, + &other_digest, + &other_artifact, + other_bytes.len(), + )); + let borrowed: Vec<&str> = swapped.iter().map(String::as_str).collect(); + let error = refuse(args("apply-operation", &target, &borrowed)); + assert_eq!(error.reason(), Some(WireReason::DigestMismatch)); + assert!( + error.detail().contains("does not carry"), + "{}", + error.detail() + ); + assert!( + target.join("AGENTS.md").exists(), + "a refusal made an effect" + ); + } + + /// Under a shared root the record is the inventory. A file this build + /// leaves behind at the person's bytes must leave the record too, or the + /// next removal would take it -- which is the file the whole extension + /// exists to keep. + #[test] + fn under_a_scope_a_file_left_behind_is_not_this_builds_to_take_next_time() { + let target = seeded("remove-keeping-scoped"); + install_scoped(&target, "keep", "ours", "the setup's bytes\n"); + assert_eq!(recorded_written(&target), vec!["shared/ours/SKILL.md"]); + + let theirs = "the person's remaining bytes\n"; + let (planned, apply_args) = remove_keeping_plan( + &target, + "scoped", + &[("shared/ours/SKILL.md", theirs, 0o644)], + Some("user_root"), + ); + let states = planned["plan"]["end_state"].as_array().unwrap(); + assert_eq!(states.len(), 1, "{states:?}"); + assert_eq!(states[0]["end_state"], "final_bytes"); + let borrowed: Vec<&str> = apply_args.iter().map(String::as_str).collect(); + let applied = run(args("apply-operation", &target, &borrowed)); + assert_eq!(applied["state"], "verified", "{applied}"); + assert_eq!( + fs::read_to_string(target.join("shared").join("ours").join("SKILL.md")).unwrap(), + theirs + ); + assert!(recorded_written(&target).is_empty()); + + // The next scoped removal reads the record, finds nothing of ours, + // and leaves the person's file where it is. + let again = scoped_plan(&target, "remove", "operation_01AGAIN"); + let done = scoped_apply(&target, &again, "again"); + assert_eq!(done["state"], "verified", "{done}"); + assert!( + target.join("shared").join("ours").join("SKILL.md").exists(), + "the second removal took the file the first one left to the person" + ); + } + #[test] fn a_bundle_installs_over_the_wire_and_leaves_unowned_files_alone() { let target = seeded("bundle-install"); diff --git a/crates/opencode-setup-system/src/main.rs b/crates/opencode-setup-system/src/main.rs index 15a4c7c..1fc81e7 100644 --- a/crates/opencode-setup-system/src/main.rs +++ b/crates/opencode-setup-system/src/main.rs @@ -34,7 +34,7 @@ pub const OPENCODE: Harness = Harness { launch_binding: LaunchBinding::Complete { how: "measured by asking the product which configuration it resolved", }, - // Measured 2026-08-31 in the pinned 1.18.25 artifact. The automatic path + // Measured 2026-08-31 in the 1.18.25 artifact. The automatic path // reads `if (autoupdate === false || OPENCODE_DISABLE_AUTOUPDATE) return;` // -- the key and the variable are alternatives, and only the variable is // reachable from a launch this provider controls. @@ -71,7 +71,7 @@ pub const OPENCODE: Harness = Harness { "plugins", ], // Five names the product reads and this provider does not own, each - // measured 2026-08-31 by running the pinned 1.18.25 binary against a + // measured 2026-08-31 by running the 1.18.25 binary against a // temporary home. Declared so `status` can say what it cannot decide. shadowing_names: &[ Shadow { diff --git a/crates/opencode-setup-system/src/software.rs b/crates/opencode-setup-system/src/software.rs index a04db4c..f7b3700 100644 --- a/crates/opencode-setup-system/src/software.rs +++ b/crates/opencode-setup-system/src/software.rs @@ -21,103 +21,103 @@ use harness_runtime::{Artifact, Delivery, Previous, Shape, Software}; pub(crate) const ARTIFACTS: &[Artifact] = &[ Artifact { platform: "linux/arm64", - url: "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.25.tgz", - bytes: 59_965_131, - sha256: "sha256:2b14bd75252cbaec62abd5b3df43da01c4ae521a7e62a2f577af7ea0edd7c7a1", + url: "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.26.tgz", + bytes: 59_947_971, + sha256: "sha256:5e0cc6c6c48d6629c8f5d3d5c9f9670e8dac7ba14d295801bb3f6a783a8f841b", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "linux/x86_64", - url: "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.25.tgz", - bytes: 60_179_907, - sha256: "sha256:3e6d285607b6e9acd1f60ec350cc3954d7351d9dcad970ded390f7b733e34280", + url: "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.26.tgz", + bytes: 60_169_535, + sha256: "sha256:990d8b07111517a78ba779709ff8f438e0dcf2a7fb66d36df7507c8e93358f02", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "macos/arm64", - url: "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.25.tgz", - bytes: 45_945_992, - sha256: "sha256:5a2ba8cdd01e8d9d3b3658cc8aeec27e22c81414a885bbe05af5958b022581c2", + url: "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.26.tgz", + bytes: 45_942_652, + sha256: "sha256:d9c09ba039dd62f983fc66c65777910f20eead2c4e30cbff888f26d640607e15", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "macos/x86_64", - url: "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.25.tgz", - bytes: 48_128_085, - sha256: "sha256:f42ee1f37d6dce61501140357cadfc0c153224e1224dd0ef00fbb073ce538abb", + url: "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.26.tgz", + bytes: 48_118_308, + sha256: "sha256:dff2571b3ad3f04dff7f0555bf4e679615c1f70afb35258f139d22a491da57e3", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "windows/arm64", - url: "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.25.tgz", - bytes: 58_410_963, - sha256: "sha256:33a0d88c0fd16cf93eb6302c2eeefd70c84400bf33c50cc5456993eb43c5cc3a", + url: "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.26.tgz", + bytes: 58_398_040, + sha256: "sha256:419799338b25d5e62a393136c61166ddf0e78229b784daf0a9fabfb0df66eb9f", shape: Shape::GzipTar, member: "package/bin/opencode.exe", }, Artifact { platform: "windows/x86_64", - url: "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.25.tgz", - bytes: 60_101_564, - sha256: "sha256:07bcd049b7f1c7ba7184ab97240fb9cd63332fdbfa1d53d84dfbde0f010f4796", + url: "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.26.tgz", + bytes: 60_082_922, + sha256: "sha256:fca4106836f9ca9d9485d010a247d0d928eecfff972b9019ff522b6ba9885934", shape: Shape::GzipTar, member: "package/bin/opencode.exe", }, ]; -/// The artifacts 1.18.24 was published as, kept so +/// The artifacts 1.18.25 was published as, kept so /// `software_update` has a version to move from and `rollback` a tree to /// return to. Measured from bytes when it was the current pin. pub(crate) const PREVIOUS_ARTIFACTS: &[Artifact] = &[ Artifact { platform: "linux/arm64", - url: "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.24.tgz", - bytes: 59_965_342, - sha256: "sha256:23c9aebc2e2860989b4513bd2dc42f4537f8168ee00625ee87ac7c36175afe97", + url: "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.25.tgz", + bytes: 59_965_131, + sha256: "sha256:2b14bd75252cbaec62abd5b3df43da01c4ae521a7e62a2f577af7ea0edd7c7a1", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "linux/x86_64", - url: "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.24.tgz", - bytes: 60_179_731, - sha256: "sha256:3862620ef10f1f6c536e36a278a36a02aab8081953a9df620180544bc316aeb2", + url: "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.25.tgz", + bytes: 60_179_907, + sha256: "sha256:3e6d285607b6e9acd1f60ec350cc3954d7351d9dcad970ded390f7b733e34280", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "macos/arm64", - url: "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.24.tgz", - bytes: 45_945_938, - sha256: "sha256:f148c5a5dee4469d60acd45c110f672e40a071932a0f0b16fdf0a44343d3a6d0", + url: "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.25.tgz", + bytes: 45_945_992, + sha256: "sha256:5a2ba8cdd01e8d9d3b3658cc8aeec27e22c81414a885bbe05af5958b022581c2", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "macos/x86_64", - url: "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.24.tgz", - bytes: 48_128_468, - sha256: "sha256:359e9bda41ded7a46724934567a112826203831671804bf5975143d7b7cdee3e", + url: "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.25.tgz", + bytes: 48_128_085, + sha256: "sha256:f42ee1f37d6dce61501140357cadfc0c153224e1224dd0ef00fbb073ce538abb", shape: Shape::GzipTar, member: "package/bin/opencode", }, Artifact { platform: "windows/arm64", - url: "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.24.tgz", - bytes: 58_410_484, - sha256: "sha256:7e0db5c40ecd51c44969627bdc6ea168b1bd1cb29dc89b2b364aa884590d896f", + url: "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.25.tgz", + bytes: 58_410_963, + sha256: "sha256:33a0d88c0fd16cf93eb6302c2eeefd70c84400bf33c50cc5456993eb43c5cc3a", shape: Shape::GzipTar, member: "package/bin/opencode.exe", }, Artifact { platform: "windows/x86_64", - url: "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.24.tgz", - bytes: 60_100_741, - sha256: "sha256:a936a3a13d1e41b9f14ce74803414c14a07f7d8b4ce4e31e755920dcc7b5933e", + url: "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.25.tgz", + bytes: 60_101_564, + sha256: "sha256:07bcd049b7f1c7ba7184ab97240fb9cd63332fdbfa1d53d84dfbde0f010f4796", shape: Shape::GzipTar, member: "package/bin/opencode.exe", }, @@ -125,12 +125,12 @@ pub(crate) const PREVIOUS_ARTIFACTS: &[Artifact] = &[ /// Opencode's program, and where its bytes come from. pub(crate) const SOFTWARE: Software = Software { - version: "1.18.25", + version: "1.18.26", command: "opencode", delivery: Delivery::Artifacts(ARTIFACTS), unsupported: &[], previous: Some(Previous { - version: "1.18.24", + version: "1.18.25", artifacts: PREVIOUS_ARTIFACTS, }), }; diff --git a/crates/provider-v3/src/info.rs b/crates/provider-v3/src/info.rs index 3222a1d..7ccd1b1 100644 --- a/crates/provider-v3/src/info.rs +++ b/crates/provider-v3/src/info.rs @@ -16,6 +16,7 @@ use serde::Serialize; use setup_core::digest; use crate::error::{Error, Result}; +use crate::plan::EndState; use crate::vocabulary::{ Command, ComponentKind, Operation, PROJECTION_DOMAIN, PROTOCOL_VERSION, ProjectionKind, TargetScope, @@ -382,7 +383,17 @@ impl ProviderInfo { // `written_paths` shipped the day the kit named it -- and a // `provider-info` field needs both, because the field set is // compared for exact equality. - plan_request_fields: vec![TargetScope::REQUEST_FIELD.to_owned()], + // + // `end_state` followed the same order on 2026-09-02: kit `0.2.8` + // opened the enum to it, `ai-stp-cli 0.0.14` on PyPI carries + // `PLAN_REQUEST_FIELDS = {"target_scope", "end_state"}` (read out of + // the installed wheel), and only then is it declared here. Every + // build declares it because the runtime that honours it is shared: + // a remove may carry a bundle of surviving bytes on every harness. + plan_request_fields: vec![ + TargetScope::REQUEST_FIELD.to_owned(), + EndState::REQUEST_FIELD.to_owned(), + ], }) } @@ -734,6 +745,49 @@ mod tests { ); } + /// The kit publishes the closed enum; a name declared outside it refuses + /// the whole document on the consumer's side, and a name inside it that + /// this build does not declare is a capability withheld -- which is a + /// decision, so it is written down here rather than inferred from a diff. + #[test] + fn every_declared_request_field_is_one_the_kit_names_and_none_is_withheld() { + let schema: serde_json::Value = serde_json::from_str(include_str!( + "../../../provider-kit/v3/provider-info.schema.json" + )) + .unwrap(); + let mut published: Vec<&str> = schema["properties"]["plan_request_fields"]["items"]["enum"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + published.sort_unstable(); + let mut declared = info(Command::ALL, Operation::CORE) + .unwrap() + .plan_request_fields; + declared.sort_unstable(); + // Held back on purpose, with the reason beside the name. Empty since + // 2026-09-02, when `end_state` shipped with the released runner that + // reads it; a field goes here while the kit names it and no released + // consumer accepts it, and comes out the day one does. + let withheld: &[(&str, &str)] = &[]; + for (name, _reason) in withheld { + assert!( + published.contains(name), + "{name} is withheld but the kit does not name it" + ); + } + let expected: Vec<&str> = published + .iter() + .copied() + .filter(|name| !withheld.iter().any(|(held, _)| held == name)) + .collect(); + assert_eq!( + declared, expected, + "declared {declared:?}, kit {published:?}" + ); + } + /// One target, one owner. #[test] fn two_profiles_cannot_claim_the_same_scope() { diff --git a/crates/provider-v3/src/plan.rs b/crates/provider-v3/src/plan.rs index f38d5ba..851a18c 100644 --- a/crates/provider-v3/src/plan.rs +++ b/crates/provider-v3/src/plan.rs @@ -76,6 +76,83 @@ pub struct SoftwareArtifact { pub entry_point: String, } +/// What one path looks like once a `remove` plan has been applied. +/// +/// A removal used to have one sentence for every path it touched: gone. That +/// was exact for a setup that owns a file and false for a component that owns +/// one key of it -- a contribution's host file such as codex's `config.toml` +/// holds the person's own keys beside the installed one, and "remove the +/// component" cannot mean "delete the file". The consumer reconstructs the +/// host file without the key and ships the surviving bytes as an ordinary +/// bundle on the same five arguments `replace` takes; this record names, per +/// path, which of the two things the apply will do. +/// +/// Agreed with the consumer on 2026-09-01 (their `ADR-0129`, our issue +/// `#255`) and declared through `plan_request_fields` under the name kit +/// `0.2.8` publishes for it. The bytes travel in the bundle rather than here: +/// a digest without a payload is an assertion with no carrier, which was the +/// hole in the first draft of the shape. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndState { + /// The target-relative path this entry is about. + pub path: String, + /// [`EndState::REMOVED`] or [`EndState::FINAL_BYTES`]. + pub end_state: String, + /// The bundle member that carries the surviving bytes. `final_bytes` only. + #[serde(skip_serializing_if = "Option::is_none")] + pub member: Option, + /// The `sha256:`-prefixed digest of those bytes. `final_bytes` only. + #[serde(skip_serializing_if = "Option::is_none")] + pub sha256: Option, + /// Their exact length. `final_bytes` only. + #[serde(skip_serializing_if = "Option::is_none")] + pub byte_length: Option, +} + +impl EndState { + /// The request field that declares this capability, as the kit's + /// `plan_request_fields` enum spells it. + /// + /// One constant for the same reason `TargetScope::REQUEST_FIELD` is one: + /// the declaration and the kit's enum are two spellings of a name a + /// consumer compares by exact membership. + pub const REQUEST_FIELD: &'static str = "end_state"; + /// The path is gone once the plan is applied. + pub const REMOVED: &'static str = "removed"; + /// The path is present at exactly the named member's bytes. + pub const FINAL_BYTES: &'static str = "final_bytes"; + + /// An entry for a path the apply takes. + #[must_use] + pub fn removed(path: &str) -> Self { + Self { + path: path.to_owned(), + end_state: Self::REMOVED.to_owned(), + member: None, + sha256: None, + byte_length: None, + } + } + + /// An entry for a path the apply leaves at the bytes of `member`. + #[must_use] + pub fn final_bytes(path: &str, member: &str, sha256: &str, byte_length: u64) -> Self { + Self { + path: path.to_owned(), + end_state: Self::FINAL_BYTES.to_owned(), + member: Some(member.to_owned()), + sha256: Some(sha256.to_owned()), + byte_length: Some(byte_length), + } + } + + /// Whether this entry leaves bytes behind. + #[must_use] + pub fn survives(&self) -> bool { + self.end_state == Self::FINAL_BYTES + } +} + /// The provider's immutable description of one effect. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PlanArtifact { @@ -134,6 +211,15 @@ pub struct PlanArtifact { /// and for `software_remove`, which downloads nothing. #[serde(skip_serializing_if = "Vec::is_empty")] pub software_artifacts: Vec, + /// What each touched path looks like after a `remove`, when the request + /// carried a bundle of surviving bytes. + /// + /// Present on `remove` plans only, and only when there is a bundle: a + /// remove without one is byte-identical to what this build produced + /// before the member existed, so no plan digest that ever verified stops + /// verifying. Every other operation has one sentence per path already. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub end_state: Vec, /// What applying it will do, in order. Never empty. pub effects: Vec, } @@ -174,6 +260,9 @@ pub struct PlanInputs<'a> { /// The artifacts a software operation will fetch, in the order `apply` /// will be handed them. pub software_artifacts: Vec, + /// Per-path end states, for a `remove` that carries a bundle. Empty + /// otherwise, and refused on any other operation. + pub end_state: Vec, /// What applying it will do. Never empty. pub effects: Vec, } @@ -212,6 +301,32 @@ impl PlanArtifact { } _ => {} } + // An end state on a path is a `remove` sentence. Any other operation + // carrying one would be describing a removal it does not perform. + if !inputs.end_state.is_empty() && inputs.operation != Operation::Remove { + return Err(Error::refuse( + WireReason::ProviderUnavailable, + format!( + "a {} plan must not carry per-path end states; only remove does", + inputs.operation + ), + )); + } + if inputs.end_state.iter().any(|entry| { + entry.path.is_empty() + || (entry.end_state != EndState::REMOVED + && entry.end_state != EndState::FINAL_BYTES) + || (entry.survives() + != (entry.member.is_some() + && entry.sha256.is_some() + && entry.byte_length.is_some())) + }) { + return Err(Error::refuse( + WireReason::ProviderUnavailable, + "an end state names a path and is removed, or final_bytes with member, \ + sha256 and byte_length", + )); + } Ok(Self { format: PLAN_FORMAT.to_owned(), @@ -233,6 +348,7 @@ impl PlanArtifact { platform: platform::echo(), expires_at: inputs.expires_at.to_owned(), software_artifacts: inputs.software_artifacts, + end_state: inputs.end_state, effects: inputs.effects, }) } @@ -364,6 +480,7 @@ mod tests { PlanInputs { target_scope: None, software_artifacts: Vec::new(), + end_state: Vec::new(), provider_id: "claude-setup-system", provider_version: "0.1.0", provider_build_digest: DIGEST, @@ -536,4 +653,82 @@ mod tests { assert_eq!(accepted["valid"], true); assert!(accepted.get("rejected").is_none()); } + + /// The member that is absent is the promise: a remove without a bundle + /// serializes to the bytes it always did, so no plan digest moves. + #[test] + fn a_plan_without_end_states_carries_no_trace_of_the_member() { + let artifact = PlanArtifact::new(inputs(Operation::Remove)).unwrap(); + let encoded = serde_json::to_value(&artifact).unwrap(); + assert!( + !encoded.as_object().unwrap().contains_key("end_state"), + "{encoded}" + ); + } + + #[test] + fn a_remove_may_name_what_each_path_becomes_and_the_digest_binds_it() { + let bare = PlanArtifact::new(inputs(Operation::Remove)).unwrap(); + let mut stated = inputs(Operation::Remove); + stated.end_state = vec![ + EndState::removed("skills"), + EndState::final_bytes( + "settings.json", + "files/settings.json", + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + 12, + ), + ]; + let artifact = PlanArtifact::new(stated).unwrap(); + let encoded = serde_json::to_value(&artifact).unwrap(); + assert_eq!(encoded["end_state"][0]["end_state"], "removed"); + assert!(encoded["end_state"][0].get("member").is_none()); + assert_eq!(encoded["end_state"][1]["end_state"], "final_bytes"); + assert_eq!(encoded["end_state"][1]["member"], "files/settings.json"); + assert_eq!(encoded["end_state"][1]["byte_length"], 12); + assert_ne!( + bare.digest().unwrap(), + artifact.digest().unwrap(), + "two plans that leave different bytes behind cannot share a digest" + ); + } + + /// Every other operation already has one sentence per path. + #[test] + fn an_end_state_on_anything_but_remove_is_refused_before_it_is_planned() { + for operation in [Operation::Install, Operation::Replace, Operation::Backup] { + let mut stated = inputs(operation); + stated.end_state = vec![EndState::removed("skills")]; + let error = PlanArtifact::new(stated).unwrap_err(); + assert!( + error.detail().contains("only remove does"), + "{operation}: {}", + error.detail() + ); + } + } + + /// A survivor without its bytes' identity, or a removal carrying one, is a + /// sentence that contradicts itself. + #[test] + fn an_end_state_that_is_half_stated_is_refused() { + let mut stated = inputs(Operation::Remove); + stated.end_state = vec![EndState { + path: "settings.json".to_owned(), + end_state: EndState::FINAL_BYTES.to_owned(), + member: Some("files/settings.json".to_owned()), + sha256: None, + byte_length: None, + }]; + assert!(PlanArtifact::new(stated).is_err()); + let mut invented = inputs(Operation::Remove); + invented.end_state = vec![EndState { + path: "settings.json".to_owned(), + end_state: "kept".to_owned(), + member: None, + sha256: None, + byte_length: None, + }]; + assert!(PlanArtifact::new(invented).is_err()); + } } diff --git a/install.ps1 b/install.ps1 index a3614c9..ecf9f1d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,7 +7,7 @@ # powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.1.0 [CmdletBinding()] param( - [string]$Version = "0.0.53", + [string]$Version = "0.0.54", [string]$InstallDir = "$env:LOCALAPPDATA\Programs\opencode-setup-system" ) $ErrorActionPreference = "Stop" diff --git a/install.sh b/install.sh index 00a1717..201ef66 100644 --- a/install.sh +++ b/install.sh @@ -14,7 +14,7 @@ set -eu REPO="NDDev-OpenNetwork/opencode-setup-system" BINARY="opencode-setup-system" -VERSION="${1:-0.0.53}" +VERSION="${1:-0.0.54}" PREFIX="${OPENCODE_INSTALL_DIR:-$HOME/.local/bin}" case "$(uname -s)" in diff --git a/provider-kit/v3/KIT-IDENTITY.json b/provider-kit/v3/KIT-IDENTITY.json index 4cd55c0..969fd03 100644 --- a/provider-kit/v3/KIT-IDENTITY.json +++ b/provider-kit/v3/KIT-IDENTITY.json @@ -1,12 +1,12 @@ { - "aggregate_digest": "sha256:9dd94310e656459294d4b80e3bb15ce30e98e4f45ca348ac99cda22b90cce9f0", + "aggregate_digest": "sha256:2a8777184eb1c4e3a445606dfb591c41f92df89fff795649d330b9ff4db066fa", "files": [ "conformance-cases.json", "manifest.json", "provider-info.schema.json", "status-response.schema.json" ], - "kit_version": "0.2.7", + "kit_version": "0.2.8", "protocol_version": 3, "schema": "ai-stp-provider-kit-identity/1" } diff --git a/provider-kit/v3/README.md b/provider-kit/v3/README.md index fa0d352..e664503 100644 --- a/provider-kit/v3/README.md +++ b/provider-kit/v3/README.md @@ -1,38 +1,39 @@ # Public provider conformance kit v3 -Этот каталог является порождённым переносимым контрактом provider protocol v3. -Публичный provider может проверять свою реализацию по этим JSON-файлам без доступа к -закрытым репозиториям `ai_stp` или контура авторинга и без зависимости от них во время -исполнения. +This directory is the generated, portable contract for provider protocol v3. +A public provider can validate its implementation against these JSON files without +access to the private `ai_stp` or authoring repositories and without depending on +them at runtime. -- `manifest.json` фиксирует команды, operations, native vocabularies, provenance и - network phases. -- `provider-info.schema.json` является закрытой JSON Schema ответа `provider-info`. -- `status-response.schema.json` является закрытой JSON Schema ответа `status`. -- `conformance-cases.json` перечисляет обязательные fail-closed классы. -- `SHA256SUMS` привязывает точные bytes остальных артефактов. -- `KIT-IDENTITY.json` называет ровно одну ревизию комплекта: агрегатный digest - плюс `kit_version`. Закреплять следует агрегат — он неподделываем; - `kit_version` является читаемой меткой, и версия `0.1.0` неоднозначна и - ссылкой быть не может (`ADR-0085`). +- `manifest.json` fixes the commands, operations, native vocabularies, provenance, + and network phases. +- `provider-info.schema.json` is the closed JSON Schema for the `provider-info` + response. +- `status-response.schema.json` is the closed JSON Schema for the `status` response. +- `conformance-cases.json` lists the required fail-closed classes. +- `SHA256SUMS` binds the exact bytes of the other artifacts. +- `KIT-IDENTITY.json` names exactly one kit revision: an aggregate digest plus + `kit_version`. Pin the aggregate because it cannot be forged; `kit_version` is + a readable label, and version `0.1.0` is ambiguous and cannot be a reference + (`ADR-0085`). - Агрегат берётся от **файла `SHA256SUMS` как он лежит**, байт в байт, без - нормализации: `sha256sum SHA256SUMS` даёт ровно `aggregate_digest` без - префикса `sha256:`. Здесь стояло «от канонических байт», и слово - «канонический» в этом репозитории означает канонизацию JSON — то есть - читатель, применивший его к `SHA256SUMS`, получил бы другое число и не понял - бы, почему. Проверено читателем комплекта, а не автором. +The aggregate is taken from **the `SHA256SUMS` file as stored**, byte for byte, +without normalization: `sha256sum SHA256SUMS` gives exactly `aggregate_digest` +without the `sha256:` prefix. The previous wording said "canonical bytes"; in +this repository, "canonical" means JSON canonicalization, so a reader that +applied it to `SHA256SUMS` would calculate a different value. This is verified +by the kit reader, not by the author. -В каком дереве вы находитесь, решает, что из этого можно запустить. +The tree you are in determines what can be run. -`release_scripts/provider_kit.py` живёт в репозитории `ai_stp` и порождает эти -файлы; там же они проверяются той же командой с `--check`. У читателя комплекта -этого пути нет — второй абзац выше обещает ровно это, — и команда названа здесь -как происхождение файлов, а не как действие. +`release_scripts/provider_kit.py` lives in the `ai_stp` repository and generates +these files; the same command validates them there with `--check`. A kit reader +does not have this path—the earlier paragraph promises exactly that—and the +command is named here as the file origin, not as an action. -Читателю комплекта принадлежит другая проверка, и она не требует ничего чужого: -`SHA256SUMS` привязывает точные байты остальных артефактов, а `KIT-IDENTITY.json` -называет SHA-256 самого файла `SHA256SUMS` без нормализации. Для этого комплект -их и несёт. +The kit reader owns a different check, which requires nothing external: +`SHA256SUMS` binds the exact bytes of the other artifacts, and `KIT-IDENTITY.json` +names the SHA-256 of the `SHA256SUMS` file itself, without normalization. The kit +carries these files for that purpose. -Редактировать generated JSON вручную нельзя. +Do not edit generated JSON by hand. diff --git a/provider-kit/v3/SHA256SUMS b/provider-kit/v3/SHA256SUMS index 1277ca3..e38a7cb 100644 --- a/provider-kit/v3/SHA256SUMS +++ b/provider-kit/v3/SHA256SUMS @@ -1,4 +1,4 @@ fe04d03b15cfe8d5b61835eafd3ea788074684e2be69d419f9c22a37ea0461b7 conformance-cases.json -2d37b51636c29f3976ba83cd296befa67217790cc141bf18037146e2f8148059 manifest.json -6b0e5f02c6ebc1503f4d6a1aa39b93df58cfd9d92037803244fe260bdb9235c4 provider-info.schema.json +cdaa34cd38e1ff0005793280194c31c4781c4505cbc3da6e823811e88f4aea5e manifest.json +165075bb253bdc1746e67a776957f709f4bbd36fe97d62cff9003af9d0f65186 provider-info.schema.json 2c5e01df3b02369832b74842b128c42b6b84d1e1455db3800474e54e71b645e4 status-response.schema.json diff --git a/provider-kit/v3/manifest.json b/provider-kit/v3/manifest.json index 1c37c0e..fb21eba 100644 --- a/provider-kit/v3/manifest.json +++ b/provider-kit/v3/manifest.json @@ -39,7 +39,7 @@ ], "decision": "docs/adr/ADR-0061-capability-negotiated-provider-protocol-v3.md", "generated_from": "apps/cli/src/ai_stp_cli/provider/protocol_v3.py", - "kit_version": "0.2.7", + "kit_version": "0.2.8", "operation_network": { "backup": [ { diff --git a/provider-kit/v3/provider-info.schema.json b/provider-kit/v3/provider-info.schema.json index f62f800..d62e97b 100644 --- a/provider-kit/v3/provider-info.schema.json +++ b/provider-kit/v3/provider-info.schema.json @@ -18,6 +18,7 @@ "plan_request_fields": { "items": { "enum": [ + "end_state", "target_scope" ], "type": "string" diff --git a/references/opencode-baseline.json b/references/opencode-baseline.json index 2952c88..aadffa2 100644 --- a/references/opencode-baseline.json +++ b/references/opencode-baseline.json @@ -8,9 +8,9 @@ "minimum_version_ref": "build/version.json:opencode_min" }, "release": { - "github_release": "https://github.com/anomalyco/opencode/releases/tag/v1.18.25", - "github_release_api": "https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.18.25", - "tag": "v1.18.25", + "github_release": "https://github.com/anomalyco/opencode/releases/tag/v1.18.26", + "github_release_api": "https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.18.26", + "tag": "v1.18.26", "cli_signature": null, "cli_signature_note": "Official CLI zip/tar assets expose GitHub release asset SHA-256 digests but no PGP/cosign signature was published for the CLI assets." }, @@ -39,11 +39,11 @@ "windows_note": "The vendor publishes `opencode-windows-x64` and `opencode-windows-arm64` and its own `opencode-ai` package declares `os: [darwin, linux, win32]`. Read from the npm registry on 2026-08-29, not from a page -- a third-party guide asserted that no windows-arm64 binary exists, and the registry has one at the same version as every other host.", "coverage_note": "Platform coverage is decided by `software_artifacts` and nothing else. This block held `windows` under unsupported until 2026-08-29, inherited from the retired nddev-*-app line, and by then it was false: the vendor ships Windows and this provider installs it. It said so for as long as it did because nothing in this repository read the block -- see `tools/transcribe_software.py --check`, which now compares it against the artifact table so the two cannot disagree again.", "x64_baseline_builds": { - "opencode-linux-x64": "identical", "opencode-darwin-x64": "identical", + "opencode-linux-x64": "identical", "opencode-windows-x64": "signature-only" }, - "x64_baseline_builds_note": "The vendor publishes a `-baseline` package beside each x64 build, for CPUs without AVX2, and its own launcher tries them as a fallback list and keeps whichever answers `--version`. This provider selects one package per platform and never falls back, so a review filed every non-AVX2 x64 host as incorrectly covered. Measured 2026-08-31 against the pinned 1.18.25 packages, by comparing the program inside each pair rather than the tarballs: there is no second build. Linux and macOS are byte-identical to their `-baseline` siblings, and the Windows pair differs in 1694 bytes of 179MB -- an Authenticode countersignature three seconds later and the header fields signing rewrites -- with the same size and the same AVX2 instruction counts. So the finding is empty at this pin, and empty is a property of this release rather than of the design: `refresh_software_pins.py` now compares the pair at every refresh and says so when they stop matching, which is the point at which the host scope would need a CPU dimension. The distinction the vendor keeps the names for is the fallback list, not a second program.", + "x64_baseline_builds_note": "The vendor publishes a `-baseline` package beside each x64 build, for CPUs without AVX2, and its own launcher tries them as a fallback list and keeps whichever answers `--version`. This provider selects one package per platform and never falls back, so a review filed every non-AVX2 x64 host as incorrectly covered. Measured 2026-08-31 against the 1.18.25 packages, by comparing the program inside each pair rather than the tarballs: there is no second build. Linux and macOS are byte-identical to their `-baseline` siblings, and the Windows pair differs in 1694 bytes of 179MB -- an Authenticode countersignature three seconds later and the header fields signing rewrites -- with the same size and the same AVX2 instruction counts. So the finding is empty at this pin, and empty is a property of this release rather than of the design: `refresh_software_pins.py` now compares the pair at every refresh and says so when they stop matching, which is the point at which the host scope would need a CPU dimension. The distinction the vendor keeps the names for is the fallback list, not a second program.", "musl_precondition": "**The vendor publishes musl builds and this provider declares `linux-musl` unsupported, and the reason is not the vendor's.** Measured 2026-08-31: `opencode-linux-x64-musl` and `opencode-linux-arm64-musl` exist and differ from their glibc siblings in bytes, so they are real second builds rather than names -- unlike the `-baseline` pairs recorded above.\n\nWhat blocks the row is this provider's own release matrix: six targets, `{x86_64,aarch64}-unknown-linux-gnu`, `{x86_64,aarch64}-apple-darwin` and `{x86_64,aarch64}-pc-windows-msvc`, and no musl one. A musl artifact row would name a host on which the executable that installs it cannot start. **The order is fixed: a musl provider build first, then the artifact rows** -- the reverse is a declaration ahead of a capability, which this estate has shipped once already.\n\nNot verified on this workstation, and the reason is the environment rather than the code: `cargo` resolves from `~/.local/bin` ahead of rustup, so `--target x86_64-unknown-linux-musl` fails with *can't find crate for `std`* even with the target installed for the toolchain this repository fixes. The acceptance test is one line in CI, where no such shadow exists: build the seven providers for `x86_64-unknown-linux-musl`, run `provider-info` on an Alpine image, and only then add the rows." }, "official_sources": { @@ -100,7 +100,7 @@ "permission": "allow" }, "evaluation": "last-matching-rule", - "evaluation_note": "**The last matching rule decides, and a configured permission map is appended after the product's own.** Exercised 2026-08-31 by running the pinned 1.18.25 binary against a temporary home: `debug agent build` renders the resolved list, and with no configuration it opens `* -> allow`, then `doom_loop` and `external_directory` ask, `question`, `plan_enter` and `plan_exit` deny, and `read` asks for `*.env` and `*.env.*`. Anything written into `permission` appears after all of them: the `full-auto` catch-all lands at index 14 and `baseline`'s `edit`/`bash` asks at 14 and 15. The shipped binary selects with `findLast`, and the product's own ordering says the same thing -- the default agent carries `question -> deny` at index 5 and `question -> allow` at 12, which is only meaningful if the later one wins.\n\n**This was recorded backwards until 2026-08-31.** The `full-auto` description said seven rules were the product's own and no configuration moved them. The measurement behind it asked whether those seven were still in the resolved list, and they are -- every one, still printed -- rather than which row decides. The probe in the evidence lane could not have contradicted it either: it ran `debug config`, which echoes the configuration back, so it answered *was our file read* while the sentence claimed *and it changes nothing*. It now runs `debug agent build` with an ordered marker." + "evaluation_note": "**The last matching rule decides, and a configured permission map is appended after the product's own.** Exercised 2026-08-31 by running the 1.18.25 binary against a temporary home: `debug agent build` renders the resolved list, and with no configuration it opens `* -> allow`, then `doom_loop` and `external_directory` ask, `question`, `plan_enter` and `plan_exit` deny, and `read` asks for `*.env` and `*.env.*`. Anything written into `permission` appears after all of them: the `full-auto` catch-all lands at index 14 and `baseline`'s `edit`/`bash` asks at 14 and 15. The shipped binary selects with `findLast`, and the product's own ordering says the same thing -- the default agent carries `question -> deny` at index 5 and `question -> allow` at 12, which is only meaningful if the later one wins.\n\n**This was recorded backwards until 2026-08-31.** The `full-auto` description said seven rules were the product's own and no configuration moved them. The measurement behind it asked whether those seven were still in the resolved list, and they are -- every one, still printed -- rather than which row decides. The probe in the evidence lane could not have contradicted it either: it ran `debug config`, which echoes the configuration back, so it answered *was our file read* while the sentence claimed *and it changes nothing*. It now runs `debug agent build` with an ordered marker." }, "source_verified_runtime_flags": [ "OPENCODE_DISABLE_AUTOUPDATE", @@ -109,7 +109,7 @@ "OPENCODE_DISABLE_PROJECT_CONFIG", "OPENCODE_DISABLE_SHARE" ], - "verified_at": "2026-08-29T10:56:16+00:00", + "verified_at": "2026-09-02T00:40:45+00:00", "native_surfaces": { "verified_at": "2026-08-31", "config_home": "~/.config/opencode", @@ -122,7 +122,7 @@ "shape": "file", "source": "https://opencode.ai/docs/rules", "evidence": "bytes", - "note": "second in the search order, after a project's own\n\n**Searched in the product's own pinned bytes on 2026-08-29 and not found, which argues nothing either way.** Fixed-string, anchored to this product's configuration home -- the bare leaf name is in every one of these binaries and proves nothing, so only the anchored form counts. An invented path was searched in the same run and was also absent, so the search discriminates.\n\nThis row stays `page` because **a path built by joining a directory to a name at runtime never appears as a literal**, and that is the shape of every remaining one. Moving it off `page` needs the product run against a target and asked what it resolved, not a deeper grep.\n\n**Off `page` on 2026-08-31, and the near-miss is the part worth keeping.** The pinned 1.18.25 binary resolves its global instruction set as `[join(Global.config, \"AGENTS.md\"), join(Global.home, \".claude\", \"CLAUDE.md\")]`, takes the **first** of those that exists and stops. The question was what `Global.config` is, and `debug paths` answers it wrongly: that command iterates the *static* path table, which is XDG-derived and prints `~/.config/opencode` even when `OPENCODE_CONFIG_DIR` is set. The `@opencode/Global` **service** is a different object and returns `config: OPENCODE_CONFIG_DIR ?? `, so a target's `AGENTS.md` is the one read.\n\nStopping at the command's output would have concluded this file is inert under provider launch -- which would have made `minimal`, whose whole content is this file, a posture that installs nothing. It is not. The reading was plausible, the command was real, and the object it prints is not the object the resolver uses.\n\n**And the second entry is another product's file.** With no `AGENTS.md` at the configuration home, this product reads another harness's global instruction file, `CLAUDE.md` under that product's own home in `$HOME`, as its own, unless `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` is set. Every posture here ships `AGENTS.md`, and it is first in the list, so ours wins wherever a setup is installed -- the fallback matters for a target this provider has not touched." + "note": "second in the search order, after a project's own\n\n**Searched in the product's own pinned bytes on 2026-08-29 and not found, which argues nothing either way.** Fixed-string, anchored to this product's configuration home -- the bare leaf name is in every one of these binaries and proves nothing, so only the anchored form counts. An invented path was searched in the same run and was also absent, so the search discriminates.\n\nThis row stays `page` because **a path built by joining a directory to a name at runtime never appears as a literal**, and that is the shape of every remaining one. Moving it off `page` needs the product run against a target and asked what it resolved, not a deeper grep.\n\n**Off `page` on 2026-08-31, and the near-miss is the part worth keeping.** The 1.18.25 binary resolves its global instruction set as `[join(Global.config, \"AGENTS.md\"), join(Global.home, \".claude\", \"CLAUDE.md\")]`, takes the **first** of those that exists and stops. The question was what `Global.config` is, and `debug paths` answers it wrongly: that command iterates the *static* path table, which is XDG-derived and prints `~/.config/opencode` even when `OPENCODE_CONFIG_DIR` is set. The `@opencode/Global` **service** is a different object and returns `config: OPENCODE_CONFIG_DIR ?? `, so a target's `AGENTS.md` is the one read.\n\nStopping at the command's output would have concluded this file is inert under provider launch -- which would have made `minimal`, whose whole content is this file, a posture that installs nothing. It is not. The reading was plausible, the command was real, and the object it prints is not the object the resolver uses.\n\n**And the second entry is another product's file.** With no `AGENTS.md` at the configuration home, this product reads another harness's global instruction file, `CLAUDE.md` under that product's own home in `$HOME`, as its own, unless `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` is set. Every posture here ships `AGENTS.md`, and it is first in the list, so ours wins wherever a setup is installed -- the fallback matters for a target this provider has not touched." }, { "path": "opencode.json", @@ -132,7 +132,7 @@ "shape": "file", "source": "https://opencode.ai/docs/config; route measured by running `debug config` against a temporary home", "evidence": "ran", - "note": "MCP servers live here under the mcp key; a key inside a file is not a projection surface, so mcp is not declared. The route was confirmed 2026-08-29 by running the pinned 1.18.25 bundle against a temporary configuration home: a file written at /opencode/opencode.json came back from `debug config`, and an invented key placed in the same file did not -- which is what separates a key the build reads from one it drops in silence, and is the control the run needed to prove anything.\n\n**A second spelling this product reads and this provider does not own.** Measured 2026-08-31 by running the pinned 1.18.25 binary against a temporary home: an `opencode.jsonc` placed beside our `opencode.json` in the same target is the one that takes effect -- `debug config` returned the JSONC file's value, not ours. The product's own candidate list joins the two in that order and the later one wins, so a target whose owned bytes are clean can be running configuration this provider never wrote. Owning the second spelling is not the answer, because a setup could then install two files that disagree, and deleting a file somebody else put there is not either. What is missing is that nothing here looks." + "note": "MCP servers live here under the mcp key; a key inside a file is not a projection surface, so mcp is not declared. The route was confirmed 2026-08-29 by running the 1.18.25 bundle against a temporary configuration home: a file written at /opencode/opencode.json came back from `debug config`, and an invented key placed in the same file did not -- which is what separates a key the build reads from one it drops in silence, and is the control the run needed to prove anything.\n\n**A second spelling this product reads and this provider does not own.** Measured 2026-08-31 by running the 1.18.25 binary against a temporary home: an `opencode.jsonc` placed beside our `opencode.json` in the same target is the one that takes effect -- `debug config` returned the JSONC file's value, not ours. The product's own candidate list joins the two in that order and the later one wins, so a target whose owned bytes are clean can be running configuration this provider never wrote. Owning the second spelling is not the answer, because a setup could then install two files that disagree, and deleting a file somebody else put there is not either. What is missing is that nothing here looks." }, { "path": "skills", @@ -180,7 +180,7 @@ "shape": "file", "source": "https://opencode.ai/docs/tui", "evidence": "page", - "note": "keybinds, theme, attention and sounds, deliberately separate from opencode.json, which the same documentation describes as server and runtime behaviour; the setting route stays opencode.json\n\n**Searched in the product's own pinned bytes on 2026-08-29 and not found, which argues nothing either way.** Fixed-string, anchored to this product's configuration home -- the bare leaf name is in every one of these binaries and proves nothing, so only the anchored form counts. An invented path was searched in the same run and was also absent, so the search discriminates.\n\nThis row stays `page` because **a path built by joining a directory to a name at runtime never appears as a literal**, and that is the shape of every remaining one. Moving it off `page` needs the product run against a target and asked what it resolved, not a deeper grep.\n\n**There is no `tui.jsonc`, and the symmetry was asserted before it was checked.** A review of this provider filed the JSONC alias for this file as a finding of equal standing with `opencode.json`'s. The pinned 1.18.25 binary carries `tui.json` eight times and the JSONC spelling not once. The alias is real for the main configuration and absent for this one; a neighbour's property is not this file's.\n\n**Still `page` after a pass at it on 2026-08-31, and saying so rather than promoting it on a neighbour's evidence.** What was established: the product *writes* this file. A configuration carrying `theme`, `keybinds` or `tui` that has no `tui.json` beside it gets one written at `join(dirname(), \"tui.json\")`, and the config files include the ones in a resource directory -- so a consumer bundle setting a theme can make the product create a file this provider owns. What was **not** established is the read path from a target: it was looked for in the pinned bytes and not found, and a single `debug config` run with `theme` set did not produce the file either, so the migration needs something a read-only command does not reach. Two things absent is not one thing proven." + "note": "keybinds, theme, attention and sounds, deliberately separate from opencode.json, which the same documentation describes as server and runtime behaviour; the setting route stays opencode.json\n\n**Searched in the product's own pinned bytes on 2026-08-29 and not found, which argues nothing either way.** Fixed-string, anchored to this product's configuration home -- the bare leaf name is in every one of these binaries and proves nothing, so only the anchored form counts. An invented path was searched in the same run and was also absent, so the search discriminates.\n\nThis row stays `page` because **a path built by joining a directory to a name at runtime never appears as a literal**, and that is the shape of every remaining one. Moving it off `page` needs the product run against a target and asked what it resolved, not a deeper grep.\n\n**There is no `tui.jsonc`, and the symmetry was asserted before it was checked.** A review of this provider filed the JSONC alias for this file as a finding of equal standing with `opencode.json`'s. The 1.18.25 binary carries `tui.json` eight times and the JSONC spelling not once. The alias is real for the main configuration and absent for this one; a neighbour's property is not this file's.\n\n**Still `page` after a pass at it on 2026-08-31, and saying so rather than promoting it on a neighbour's evidence.** What was established: the product *writes* this file. A configuration carrying `theme`, `keybinds` or `tui` that has no `tui.json` beside it gets one written at `join(dirname(), \"tui.json\")`, and the config files include the ones in a resource directory -- so a consumer bundle setting a theme can make the product create a file this provider owns. What was **not** established is the read path from a target: it was looked for in the pinned bytes and not found, and a single `debug config` run with `theme` set did not produce the file either, so the migration needs something a read-only command does not reach. Two things absent is not one thing proven." } ], "declined": [ @@ -217,38 +217,38 @@ }, { "path": "opencode-runtime-state", - "reason": "One row for what the product writes **outside its configuration home entirely**, because it writes to three other roots and none of them had a row.\n\nMeasured 2026-08-28 by running the pinned 1.18.25 binary in a clean `HOME` with every XDG variable cleared. A bare `--version` created:\n * `~/.local/share/opencode/{log,repos}`\n * `~/.local/state/opencode`\n * `~/.cache/opencode/bin`\n\nThe product is XDG-native and this is the rest of that specification: the config home this provider targets is one of four roots it uses, not the only one. Confirmed at the line -- its `Global` module reads `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `XDG_CACHE_HOME`, each falling back to the conventional directory under the home, and joins `\"opencode\"` to all four.\n\n**None of them gets a row of its own**, because every recorded path here is relative to the target and these are relative to roots this provider never evaluates against; the guard that enforces it refuses such a row, correctly. Recorded in this sentence instead, the same way antigravity's `~/.cache/ms-playwright-go` is, so a reader looking for everything the product writes does not stop at `~/.config/opencode` and get a wrong answer.\n\n**And it settles a search result that says otherwise.** Several pages claim opencode uses `%APPDATA%` on Windows; they are about third-party plugins rather than the vendor. The product's own resolver has **no `win32` branch at all**, so `~/.config/opencode` is correct on all three systems and this baseline's `config_home` stands.", - "source": "measured by running the pinned 1.18.25 binary in a clean HOME, and confirmed at the line in its own Global module, 2026-08-28" + "reason": "One row for what the product writes **outside its configuration home entirely**, because it writes to three other roots and none of them had a row.\n\nMeasured 2026-08-28 by running the 1.18.25 binary in a clean `HOME` with every XDG variable cleared. A bare `--version` created:\n * `~/.local/share/opencode/{log,repos}`\n * `~/.local/state/opencode`\n * `~/.cache/opencode/bin`\n\nThe product is XDG-native and this is the rest of that specification: the config home this provider targets is one of four roots it uses, not the only one. Confirmed at the line -- its `Global` module reads `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `XDG_CACHE_HOME`, each falling back to the conventional directory under the home, and joins `\"opencode\"` to all four.\n\n**None of them gets a row of its own**, because every recorded path here is relative to the target and these are relative to roots this provider never evaluates against; the guard that enforces it refuses such a row, correctly. Recorded in this sentence instead, the same way antigravity's `~/.cache/ms-playwright-go` is, so a reader looking for everything the product writes does not stop at `~/.config/opencode` and get a wrong answer.\n\n**And it settles a search result that says otherwise.** Several pages claim opencode uses `%APPDATA%` on Windows; they are about third-party plugins rather than the vendor. The product's own resolver has **no `win32` branch at all**, so `~/.config/opencode` is correct on all three systems and this baseline's `config_home` stands.", + "source": "measured by running the 1.18.25 binary in a clean HOME, and confirmed at the line in its own Global module, 2026-08-28" }, { "path": "agent", - "reason": "Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/agent(s)/.md` and the project row as `.opencode/agent/.md` or `.opencode/agents/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for.", - "source": "measured in the pinned 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings" + "reason": "Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/agent(s)/.md` and the project row as `.opencode/agent/.md` or `.opencode/agents/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for.", + "source": "measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings" }, { "path": "command", - "reason": "Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/command(s)/.md` and the project row as `.opencode/command/.md` or `.opencode/commands/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for.", - "source": "measured in the pinned 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings" + "reason": "Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/command(s)/.md` and the project row as `.opencode/command/.md` or `.opencode/commands/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for.", + "source": "measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings" }, { "path": "skill", - "reason": "Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/skill(s)//SKILL.md` and the project row as `.opencode/skill//SKILL.md` or `.opencode/skills//SKILL.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for.", - "source": "measured in the pinned 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings" + "reason": "Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/skill(s)//SKILL.md` and the project row as `.opencode/skill//SKILL.md` or `.opencode/skills//SKILL.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for.", + "source": "measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings" }, { "path": "plugin", - "reason": "Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/plugin(s)//` and the project row as `.opencode/plugin//` or `.opencode/plugins//`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for.", - "source": "measured in the pinned 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings" + "reason": "Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/plugin(s)//` and the project row as `.opencode/plugin//` or `.opencode/plugins//`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for.", + "source": "measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256 -- the product carries its own reference table and it names both spellings" }, { "path": "managed-config", - "reason": "Not a path in the target, and named without an extension for that reason: the managed configuration directory is a **system** path, one per operating system, and every recorded path here is relative to the target.\n\n * Linux — `/etc/opencode/`\n * macOS — `/Library/Application Support/opencode/`\n * Windows — `%ProgramData%\\\\opencode`\n\nAll three are in the pinned 1.18.25 bundle's own emitted source, in one switch: `case\"darwin\": return \"/Library/Application Support/opencode\"; case\"win32\": return join(process.env.ProgramData||\"C:\\\\ProgramData\",\"opencode\"); default: return \"/etc/opencode\"`. macOS additionally reads a managed plist from the `ai.opencode.managed` preference domain, deployable by MDM, which the same bundle carries as `parseManagedPlist` and `managedPreferences`.\n\n**It bears on the `full-auto` posture**, the same way the managed policy does on the harness next door. An administrator's `opencode.json` there is loaded at the highest priority tier and overrides everything, so this provider can install, verify and restore a permissive posture cleanly on a managed machine and change nothing about what the product actually permits. The setup is not wrong and the target is not wrong; a higher layer wins.\n\nRecorded and never touched: it needs root or Administrator to write, it is not under the configuration home this provider is given, and a provider that edited an organisation's policy would be doing the one thing this estate refuses everywhere else.\n\nThe user configuration home is **not** per-OS, which is why only this row is. The same bundle resolves it as `XDG_CONFIG_HOME || ~/.config` joined with the application name, with no platform branch at all.", - "source": "https://opencode.ai/docs/config; measured in the pinned 1.18.25 bundle, whose bytes match this baseline's own sha256" + "reason": "Not a path in the target, and named without an extension for that reason: the managed configuration directory is a **system** path, one per operating system, and every recorded path here is relative to the target.\n\n * Linux — `/etc/opencode/`\n * macOS — `/Library/Application Support/opencode/`\n * Windows — `%ProgramData%\\\\opencode`\n\nAll three are in the 1.18.25 bundle's own emitted source, in one switch: `case\"darwin\": return \"/Library/Application Support/opencode\"; case\"win32\": return join(process.env.ProgramData||\"C:\\\\ProgramData\",\"opencode\"); default: return \"/etc/opencode\"`. macOS additionally reads a managed plist from the `ai.opencode.managed` preference domain, deployable by MDM, which the same bundle carries as `parseManagedPlist` and `managedPreferences`.\n\n**It bears on the `full-auto` posture**, the same way the managed policy does on the harness next door. An administrator's `opencode.json` there is loaded at the highest priority tier and overrides everything, so this provider can install, verify and restore a permissive posture cleanly on a managed machine and change nothing about what the product actually permits. The setup is not wrong and the target is not wrong; a higher layer wins.\n\nRecorded and never touched: it needs root or Administrator to write, it is not under the configuration home this provider is given, and a provider that edited an organisation's policy would be doing the one thing this estate refuses everywhere else.\n\nThe user configuration home is **not** per-OS, which is why only this row is. The same bundle resolves it as `XDG_CONFIG_HOME || ~/.config` joined with the application name, with no platform branch at all.", + "source": "https://opencode.ai/docs/config; measured in the 1.18.25 bundle, whose bytes match this baseline's own sha256" }, { "path": "mcp_config.json", "reason": "MCP servers are the `mcp` key inside `opencode.json` -- `{\"mcp\": {\"\": {\"type\": \"local\", \"command\": [...]}}}` -- confirmed on the vendor's MCP page 2026-08-29 and in the product's own built-in `customize-opencode` skill. That file is owned here and written and restored whole, so MCP is covered by the `setting` kind. **A key inside a file is not a projection surface.** No separate MCP file exists under the config home.", - "source": "https://opencode.ai/docs/mcp-servers/; and the product's own built-in customize-opencode skill, read from the pinned 1.18.25 artifact" + "source": "https://opencode.ai/docs/mcp-servers/; and the product's own built-in customize-opencode skill, read from the 1.18.25 artifact" }, { "path": "hooks.json", @@ -277,7 +277,7 @@ "skill" ], "shape": "directory", - "source": "https://opencode.ai/docs/skills/, and measured by running the pinned 1.18.25 product with `debug skill` against a temporary HOME, 2026-08-29", + "source": "https://opencode.ai/docs/skills/, and measured by running the 1.18.25 product with `debug skill` against a temporary HOME, 2026-08-29", "evidence": "ran", "note": "**Run, not read.** The pinned `opencode-linux-x64-1.18.25.tgz` was fetched, its digest checked against the artifact table, and the binary driven with `opencode debug skill` -- a credential-free command that prints the resolved skill list as JSON. A `SKILL.md` at `$HOME/.agents/skills/nddev-user-root-probe/` is listed with its full path.\n\n**With a control.** A second skill at `$HOME/.agents-not-a-root/skills/` -- a sibling root no page names -- is absent from the same listing, so the product reads the documented root rather than scanning `$HOME` broadly.\n\nThe product corroborates it in its own words: its built-in `customize-opencode` skill tabulates *External skills (auto-loaded): `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md`*, and names `OPENCODE_DISABLE_EXTERNAL_SKILLS` and `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` as the switches that turn those two scans off.\n\nThe one root in this estate that belongs to a convention rather than to a product. `$HOME/.agents/skills` is a *sibling* of this product's configuration home, not a child, so nothing declared against this provider's own target can reach it -- that is what `user_root` exists for.\n\n**Owning a shared root, and the reason this record used to decline it.** Five of the seven products read this root, and the decline said: *a namespace is removed whole, so a second declaration would make either provider's remove take the other's skills.* That sentence was true when it was written and stopped being true when `written_paths` shipped -- `remove` under this scope takes the files this provider recorded writing and refuses rather than widening when it cannot read the record, and each harness carries its own state file, so they coexist under one root. The reason was not re-read when the thing it described changed.\n\nRelative to this scope's own root the path is `skills`, not `.agents/skills`: the root is what the scope names, and writing it into the path again would put the skills at `~/.agents/.agents/skills`." } @@ -294,89 +294,89 @@ "shape": "gzip-tar", "platforms": { "linux/arm64": { - "url": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.25.tgz", - "bytes": 59965131, - "sha256": "sha256:2b14bd75252cbaec62abd5b3df43da01c4ae521a7e62a2f577af7ea0edd7c7a1", + "url": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.26.tgz", + "bytes": 59947971, + "sha256": "sha256:5e0cc6c6c48d6629c8f5d3d5c9f9670e8dac7ba14d295801bb3f6a783a8f841b", "member": "package/bin/opencode" }, "linux/x86_64": { - "url": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.25.tgz", - "bytes": 60179907, - "sha256": "sha256:3e6d285607b6e9acd1f60ec350cc3954d7351d9dcad970ded390f7b733e34280", + "url": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.26.tgz", + "bytes": 60169535, + "sha256": "sha256:990d8b07111517a78ba779709ff8f438e0dcf2a7fb66d36df7507c8e93358f02", "member": "package/bin/opencode" }, "macos/arm64": { - "url": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.25.tgz", - "bytes": 45945992, - "sha256": "sha256:5a2ba8cdd01e8d9d3b3658cc8aeec27e22c81414a885bbe05af5958b022581c2", + "url": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.26.tgz", + "bytes": 45942652, + "sha256": "sha256:d9c09ba039dd62f983fc66c65777910f20eead2c4e30cbff888f26d640607e15", "member": "package/bin/opencode" }, "macos/x86_64": { - "url": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.25.tgz", - "bytes": 48128085, - "sha256": "sha256:f42ee1f37d6dce61501140357cadfc0c153224e1224dd0ef00fbb073ce538abb", + "url": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.26.tgz", + "bytes": 48118308, + "sha256": "sha256:dff2571b3ad3f04dff7f0555bf4e679615c1f70afb35258f139d22a491da57e3", "member": "package/bin/opencode" }, "windows/arm64": { - "url": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.25.tgz", - "bytes": 58410963, - "sha256": "sha256:33a0d88c0fd16cf93eb6302c2eeefd70c84400bf33c50cc5456993eb43c5cc3a", + "url": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.26.tgz", + "bytes": 58398040, + "sha256": "sha256:419799338b25d5e62a393136c61166ddf0e78229b784daf0a9fabfb0df66eb9f", "member": "package/bin/opencode.exe" }, "windows/x86_64": { - "url": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.25.tgz", - "bytes": 60101564, - "sha256": "sha256:07bcd049b7f1c7ba7184ab97240fb9cd63332fdbfa1d53d84dfbde0f010f4796", + "url": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.26.tgz", + "bytes": 60082922, + "sha256": "sha256:fca4106836f9ca9d9485d010a247d0d928eecfff972b9019ff522b6ba9885934", "member": "package/bin/opencode.exe" } }, - "version": "1.18.25", - "verified_at": "2026-08-29T10:56:16+00:00" + "version": "1.18.26", + "verified_at": "2026-09-02T00:40:45+00:00" }, - "setup_catalogue_digest": "sha256:6f26cf2c494e1d660d6fd3117d8448d0da7733ef43ac7200fb5ef5243145306e", + "setup_catalogue_digest": "sha256:2812d268ce40bb989667dcd8afb5632ada2aa73882608decbbed752731d399a9", "previous_software_artifacts": { "command": "opencode", "shape": "gzip-tar", "platforms": { "linux/arm64": { - "url": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.24.tgz", - "bytes": 59965342, - "sha256": "sha256:23c9aebc2e2860989b4513bd2dc42f4537f8168ee00625ee87ac7c36175afe97", + "url": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.25.tgz", + "bytes": 59965131, + "sha256": "sha256:2b14bd75252cbaec62abd5b3df43da01c4ae521a7e62a2f577af7ea0edd7c7a1", "member": "package/bin/opencode" }, "linux/x86_64": { - "url": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.24.tgz", - "bytes": 60179731, - "sha256": "sha256:3862620ef10f1f6c536e36a278a36a02aab8081953a9df620180544bc316aeb2", + "url": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.25.tgz", + "bytes": 60179907, + "sha256": "sha256:3e6d285607b6e9acd1f60ec350cc3954d7351d9dcad970ded390f7b733e34280", "member": "package/bin/opencode" }, "macos/arm64": { - "url": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.24.tgz", - "bytes": 45945938, - "sha256": "sha256:f148c5a5dee4469d60acd45c110f672e40a071932a0f0b16fdf0a44343d3a6d0", + "url": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.25.tgz", + "bytes": 45945992, + "sha256": "sha256:5a2ba8cdd01e8d9d3b3658cc8aeec27e22c81414a885bbe05af5958b022581c2", "member": "package/bin/opencode" }, "macos/x86_64": { - "url": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.24.tgz", - "bytes": 48128468, - "sha256": "sha256:359e9bda41ded7a46724934567a112826203831671804bf5975143d7b7cdee3e", + "url": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.25.tgz", + "bytes": 48128085, + "sha256": "sha256:f42ee1f37d6dce61501140357cadfc0c153224e1224dd0ef00fbb073ce538abb", "member": "package/bin/opencode" }, "windows/arm64": { - "url": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.24.tgz", - "bytes": 58410484, - "sha256": "sha256:7e0db5c40ecd51c44969627bdc6ea168b1bd1cb29dc89b2b364aa884590d896f", + "url": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.25.tgz", + "bytes": 58410963, + "sha256": "sha256:33a0d88c0fd16cf93eb6302c2eeefd70c84400bf33c50cc5456993eb43c5cc3a", "member": "package/bin/opencode.exe" }, "windows/x86_64": { - "url": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.24.tgz", - "bytes": 60100741, - "sha256": "sha256:a936a3a13d1e41b9f14ce74803414c14a07f7d8b4ce4e31e755920dcc7b5933e", + "url": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.25.tgz", + "bytes": 60101564, + "sha256": "sha256:07bcd049b7f1c7ba7184ab97240fb9cd63332fdbfa1d53d84dfbde0f010f4796", "member": "package/bin/opencode.exe" } }, - "version": "1.18.24", - "verified_at": "2026-08-28T04:45:01+00:00" + "version": "1.18.25", + "verified_at": "2026-08-29T10:56:16+00:00" }, - "source_verified_runtime_flags_note": "All five read out of the pinned 1.18.25 binary on 2026-08-31 -- the whole `OPENCODE_*` set is in its string table, and these are the five this provider has a reason to name. **Nothing in this repository read this block until now.** It is the same shape as the `windows` row that sat under `unsupported` for weeks while this provider installed Windows: a true-when-written list with no reader, which is the condition a stale fact needs. `native_declaration_names_the_switch_it_sets` now ties `updates_off_env` to this list, so the declaration and the measurement cannot drift apart in silence." + "source_verified_runtime_flags_note": "All five read out of the 1.18.25 binary on 2026-08-31 -- the whole `OPENCODE_*` set is in its string table, and these are the five this provider has a reason to name. **Nothing in this repository read this block until now.** It is the same shape as the `windows` row that sat under `unsupported` for weeks while this provider installed Windows: a true-when-written list with no reader, which is the condition a stale fact needs. `native_declaration_names_the_switch_it_sets` now ties `updates_off_env` to this list, so the declaration and the measurement cannot drift apart in silence." } diff --git a/setups/full-auto/setup.json b/setups/full-auto/setup.json index ffb2e4e..541b198 100644 --- a/setups/full-auto/setup.json +++ b/setups/full-auto/setup.json @@ -1,7 +1,7 @@ { "schema_version": 1, "id": "full-auto", - "description": "Full auto: every tool is allowed, by the catch-all the product documents -- and the honest half of that sentence is that this build already allows every tool without it. Measured against the pinned 1.18.25 bundle with `debug agent build` under a temporary home. The product decides by the **last** matching rule -- the shipped binary selects with `findLast` -- and a configured permission map is appended *after* the product's own rules rather than before them. With no configuration the resolved list for the default agent opens `* -> allow`, then `doom_loop` and `external_directory` ask, `question`, `plan_enter` and `plan_exit` deny, and `read` asks for `*.env` and `*.env.*` while allowing `*.env.example`. Writing `permission: {\"*\": \"allow\"}` puts a second `* -> allow` after every one of them, and under last-match-wins that copy is the one that decides: those prompts and denials do not happen. Naming the seven explicitly moves them the same way -- each appears again at the end carrying the action you wrote. So this file does not restate the product's own opening `* -> allow`: same rule text, opposite power, because position is the semantics here. The product's copy is first and therefore the weakest thing in the list; this one is last and therefore the strongest. **This description said the reverse until 2026-08-31**, that the seven were the product's own and no configuration moved them. The measurement behind that sentence asked whether the seven were still in the resolved list -- they are, every one, and they are still printed there -- instead of asking which row decides. Presence is not precedence, and a rule you can still see is not a rule that still governs. This build also reads no capability toggle beyond the permission map: `autoupdate`, `share`, `snapshot`, `watcher`, `experimental`, `instructions`, `formatter`, `lsp`, `small_model` and `disabled_providers` are the other keys it accepts, and `attribution`, `tui`, `keybinds`, `theme`, `reasoningEffort` and `textVerbosity` are dropped exactly as an invented key is -- which is how the first list was told from the second. `autoupdate` is deliberately not set on either side: this provider pins the version it installs, and a product that updates itself would move the bytes out from under the record of what was installed. That reason stood for three releases with nothing enforcing it. Provider launch now sets `OPENCODE_DISABLE_AUTOUPDATE=1`, which the pinned build reads as an unconditional gate on the automatic path; the configuration key is the alternative in the same condition and this provider's target is not where that key is read from. Neither stops a person typing `opencode upgrade`. `share` sends something outward rather than granting you a tool. This product documents no sandbox to turn off. This is a setup posture -- keys in this product's own configuration file. It is not an execution profile and it grants no environment: what it changes is what the product asks you, not what anyone is permitted to run. The key measurement behind this posture: `debug config` and `debug agent build` against the pinned 1.18.25 bundle under a temporary home, with an invented key as the control.", + "description": "Full auto: every tool is allowed, by the catch-all the product documents -- and the honest half of that sentence is that this build already allows every tool without it. Measured against the 1.18.25 bundle with `debug agent build` under a temporary home. The product decides by the **last** matching rule -- the shipped binary selects with `findLast` -- and a configured permission map is appended *after* the product's own rules rather than before them. With no configuration the resolved list for the default agent opens `* -> allow`, then `doom_loop` and `external_directory` ask, `question`, `plan_enter` and `plan_exit` deny, and `read` asks for `*.env` and `*.env.*` while allowing `*.env.example`. Writing `permission: {\"*\": \"allow\"}` puts a second `* -> allow` after every one of them, and under last-match-wins that copy is the one that decides: those prompts and denials do not happen. Naming the seven explicitly moves them the same way -- each appears again at the end carrying the action you wrote. So this file does not restate the product's own opening `* -> allow`: same rule text, opposite power, because position is the semantics here. The product's copy is first and therefore the weakest thing in the list; this one is last and therefore the strongest. **This description said the reverse until 2026-08-31**, that the seven were the product's own and no configuration moved them. The measurement behind that sentence asked whether the seven were still in the resolved list -- they are, every one, and they are still printed there -- instead of asking which row decides. Presence is not precedence, and a rule you can still see is not a rule that still governs. This build also reads no capability toggle beyond the permission map: `autoupdate`, `share`, `snapshot`, `watcher`, `experimental`, `instructions`, `formatter`, `lsp`, `small_model` and `disabled_providers` are the other keys it accepts, and `attribution`, `tui`, `keybinds`, `theme`, `reasoningEffort` and `textVerbosity` are dropped exactly as an invented key is -- which is how the first list was told from the second. `autoupdate` is deliberately not set on either side: this provider pins the version it installs, and a product that updates itself would move the bytes out from under the record of what was installed. That reason stood for three releases with nothing enforcing it. Provider launch now sets `OPENCODE_DISABLE_AUTOUPDATE=1`, which the pinned build reads as an unconditional gate on the automatic path; the configuration key is the alternative in the same condition and this provider's target is not where that key is read from. Neither stops a person typing `opencode upgrade`. `share` sends something outward rather than granting you a tool. This product documents no sandbox to turn off. This is a setup posture -- keys in this product's own configuration file. It is not an execution profile and it grants no environment: what it changes is what the product asks you, not what anyone is permitted to run. The key measurement behind this posture: `debug config` and `debug agent build` against the 1.18.25 bundle under a temporary home, with an invented key as the control.", "sources": [ "https://opencode.ai/docs/config", "https://opencode.ai/docs/permissions" diff --git a/setups/nddev-builder/home/skills/nddev-builder/references/authoring-agents.md b/setups/nddev-builder/home/skills/nddev-builder/references/authoring-agents.md index 3a776ab..66bbfd4 100644 --- a/setups/nddev-builder/home/skills/nddev-builder/references/authoring-agents.md +++ b/setups/nddev-builder/home/skills/nddev-builder/references/authoring-agents.md @@ -25,7 +25,7 @@ Generated by `tools/build_nddev_builder.py` from the vendor's own reference and ## What bites -- **A `name` in frontmatter replaces the one the filename gives, and it replaces it entirely.** Measured 2026-08-31 by running the pinned 1.18.25 binary against a temporary home: an agent file named for one thing and carrying `name:` for another is reachable only under the frontmatter name -- `debug agent` given the filename does not find it, and `agent list` prints the frontmatter name alone. This reference said the opposite until then, that there is no `name` field and the filename is the identity, and the sentence had a citation rather than a run behind it. +- **A `name` in frontmatter replaces the one the filename gives, and it replaces it entirely.** Measured 2026-08-31 by running the 1.18.25 binary against a temporary home: an agent file named for one thing and carrying `name:` for another is reachable only under the frontmatter name -- `debug agent` given the filename does not find it, and `agent list` prints the frontmatter name alone. This reference said the opposite until then, that there is no `name` field and the filename is the identity, and the sentence had a citation rather than a run behind it. - **So name it once.** The filename and a frontmatter `name` are two places to say the same thing, and when they disagree the file is reachable under a name its path does not show -- which is a component this provider owns and a person cannot find. Either leave `name` out or keep it equal to the filename. ## The same file on the other harnesses diff --git a/setups/nddev-builder/home/skills/nddev-builder/references/authoring-instructions.md b/setups/nddev-builder/home/skills/nddev-builder/references/authoring-instructions.md index 3d947c8..cedb2b9 100644 --- a/setups/nddev-builder/home/skills/nddev-builder/references/authoring-instructions.md +++ b/setups/nddev-builder/home/skills/nddev-builder/references/authoring-instructions.md @@ -17,7 +17,7 @@ second in the search order, after a project's own This row stays `page` because **a path built by joining a directory to a name at runtime never appears as a literal**, and that is the shape of every remaining one. Moving it off `page` needs the product run against a target and asked what it resolved, not a deeper grep. -**Off `page` on 2026-08-31, and the near-miss is the part worth keeping.** The pinned 1.18.25 binary resolves its global instruction set as `[join(Global.config, "AGENTS.md"), join(Global.home, ".claude", "CLAUDE.md")]`, takes the **first** of those that exists and stops. The question was what `Global.config` is, and `debug paths` answers it wrongly: that command iterates the *static* path table, which is XDG-derived and prints `~/.config/opencode` even when `OPENCODE_CONFIG_DIR` is set. The `@opencode/Global` **service** is a different object and returns `config: OPENCODE_CONFIG_DIR ?? `, so a target's `AGENTS.md` is the one read. +**Off `page` on 2026-08-31, and the near-miss is the part worth keeping.** The 1.18.25 binary resolves its global instruction set as `[join(Global.config, "AGENTS.md"), join(Global.home, ".claude", "CLAUDE.md")]`, takes the **first** of those that exists and stops. The question was what `Global.config` is, and `debug paths` answers it wrongly: that command iterates the *static* path table, which is XDG-derived and prints `~/.config/opencode` even when `OPENCODE_CONFIG_DIR` is set. The `@opencode/Global` **service** is a different object and returns `config: OPENCODE_CONFIG_DIR ?? `, so a target's `AGENTS.md` is the one read. Stopping at the command's output would have concluded this file is inert under provider launch -- which would have made `minimal`, whose whole content is this file, a posture that installs nothing. It is not. The reading was plausible, the command was real, and the object it prints is not the object the resolver uses. diff --git a/setups/nddev-builder/home/skills/nddev-builder/references/surfaces.md b/setups/nddev-builder/home/skills/nddev-builder/references/surfaces.md index 6a8437b..3b0f698 100644 --- a/setups/nddev-builder/home/skills/nddev-builder/references/surfaces.md +++ b/setups/nddev-builder/home/skills/nddev-builder/references/surfaces.md @@ -49,7 +49,7 @@ above -- writing the root into the path again would nest it twice. | path | routes | shape | decided by | exercised by | | --- | --- | --- | --- | --- | -| `skills` | skill | directory | | **ran it** | +| `skills` | skill | directory | | **ran it** | **Under a scope the namespace is the permission and the recorded files are the inventory.** A root like this one is read by several @@ -68,10 +68,10 @@ whole, which would take or revert a neighbour's work. - **`.gitignore`** — The product writes this into its configuration home on first run, listing `node_modules`, `package.json`, `package-lock.json`, `bun.lock` and itself -- it treats the home as a place a package manager might run. Measured 2026-08-28 by launching the product through this provider. Not owned: nothing here projects a `.gitignore`, and a file the product rewrites on its own schedule is not a surface a setup can promise to restore. - **`$HOME/.claude/skills`** — OpenCode also reads Claude Code's skills directory for compatibility -- `.claude/skills//SKILL.md` is a path literal in the pinned binary, and the vendor lists it as *Global Claude-compatible*. Another product's home, never this provider's to own, and recorded because claude-setup-system owns `skills` there. - **`opencode-runtime-state`** — One row for what the product writes **outside its configuration home entirely**, because it writes to three other roots and none of them had a row. -- **`agent`** — Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/agent(s)/.md` and the project row as `.opencode/agent/.md` or `.opencode/agents/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. -- **`command`** — Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/command(s)/.md` and the project row as `.opencode/command/.md` or `.opencode/commands/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. -- **`skill`** — Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/skill(s)//SKILL.md` and the project row as `.opencode/skill//SKILL.md` or `.opencode/skills//SKILL.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. -- **`plugin`** — Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the pinned 1.18.25 binary, writes the global row as `~/.config/opencode/plugin(s)//` and the project row as `.opencode/plugin//` or `.opencode/plugins//`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. +- **`agent`** — Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/agent(s)/.md` and the project row as `.opencode/agent/.md` or `.opencode/agents/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. +- **`command`** — Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/command(s)/.md` and the project row as `.opencode/command/.md` or `.opencode/commands/.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. +- **`skill`** — Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/skill(s)//SKILL.md` and the project row as `.opencode/skill//SKILL.md` or `.opencode/skills//SKILL.md`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. +- **`plugin`** — Opencode accepts the singular and the plural spelling of this directory. Its own embedded reference, carried in the 1.18.25 binary, writes the global row as `~/.config/opencode/plugin(s)//` and the project row as `.opencode/plugin//` or `.opencode/plugins//`. This provider writes and owns the plural only. Owning both would let one target hold two definitions of the same component that disagree, with the product reading one and this provider reporting the other, and which of the two wins where both exist is not documented -- so a target holding the singular is reported rather than resolved. Recorded here because without a row the next reader repeats the search, and because a directory the product reads and this provider does not own is exactly what this block is for. - **`managed-config`** — Not a path in the target, and named without an extension for that reason: the managed configuration directory is a **system** path, one per operating system, and every recorded path here is relative to the target. - **`mcp_config.json`** — MCP servers are the `mcp` key inside `opencode.json` -- `{"mcp": {"": {"type": "local", "command": [...]}}}` -- confirmed on the vendor's MCP page 2026-08-29 and in the product's own built-in `customize-opencode` skill. That file is owned here and written and restored whole, so MCP is covered by the `setting` kind. **A key inside a file is not a projection surface.** No separate MCP file exists under the config home. - **`hooks.json`** — **Hooks are functions a plugin module exports**, not a file. The vendor's plugin page, read 2026-08-29: *"A plugin is a JavaScript/TypeScript module that exports one or more plugin functions. Each function receives a context object and returns a hooks object."* The names -- `tool.execute.before`, `session.created`, `permission.asked` and the rest -- are keys of that returned object. There is no `hooks.json` and no `hooks` key in `opencode.json`, so a hook reaches this product through `plugins/`, which is owned and routes `plugin`.