From 775ae82e6999fc249288c54a2a64faa5bbfbea3e Mon Sep 17 00:00:00 2001 From: bitgorust Date: Tue, 18 Aug 2026 02:45:41 +0200 Subject: [PATCH 1/8] docs: propose native OpenCode lifecycle --- .../opencode-lifecycle-results/change.json | 24 ++++ .../opencode-lifecycle-results/delta.md | 66 +++++++++++ .../opencode-lifecycle-results/evidence.md | 23 ++++ .../opencode-lifecycle-results/proposal.md | 38 +++++++ .../opencode-lifecycle-results/tasks.md | 10 ++ .../opencode-permission-contracts/change.json | 21 ++++ .../opencode-permission-contracts/delta.md | 45 ++++++++ .../opencode-permission-contracts/design.md | 53 +++++++++ .../opencode-permission-contracts/evidence.md | 17 +++ .../opencode-permission-contracts/proposal.md | 35 ++++++ .../opencode-permission-contracts/tasks.md | 9 ++ .../opencode-skill-distribution/change.json | 21 ++++ .../opencode-skill-distribution/delta.md | 65 +++++++++++ .../opencode-skill-distribution/evidence.md | 23 ++++ .../opencode-skill-distribution/proposal.md | 37 ++++++ .../opencode-skill-distribution/tasks.md | 9 ++ .../packed-opencode-host-matrix/change.json | 21 ++++ .../packed-opencode-host-matrix/delta.md | 106 ++++++++++++++++++ .../packed-opencode-host-matrix/evidence.md | 35 ++++++ .../packed-opencode-host-matrix/proposal.md | 39 +++++++ .../packed-opencode-host-matrix/tasks.md | 10 ++ 21 files changed, 707 insertions(+) create mode 100644 specs/changes/opencode-lifecycle-results/change.json create mode 100644 specs/changes/opencode-lifecycle-results/delta.md create mode 100644 specs/changes/opencode-lifecycle-results/evidence.md create mode 100644 specs/changes/opencode-lifecycle-results/proposal.md create mode 100644 specs/changes/opencode-lifecycle-results/tasks.md create mode 100644 specs/changes/opencode-permission-contracts/change.json create mode 100644 specs/changes/opencode-permission-contracts/delta.md create mode 100644 specs/changes/opencode-permission-contracts/design.md create mode 100644 specs/changes/opencode-permission-contracts/evidence.md create mode 100644 specs/changes/opencode-permission-contracts/proposal.md create mode 100644 specs/changes/opencode-permission-contracts/tasks.md create mode 100644 specs/changes/opencode-skill-distribution/change.json create mode 100644 specs/changes/opencode-skill-distribution/delta.md create mode 100644 specs/changes/opencode-skill-distribution/evidence.md create mode 100644 specs/changes/opencode-skill-distribution/proposal.md create mode 100644 specs/changes/opencode-skill-distribution/tasks.md create mode 100644 specs/changes/packed-opencode-host-matrix/change.json create mode 100644 specs/changes/packed-opencode-host-matrix/delta.md create mode 100644 specs/changes/packed-opencode-host-matrix/evidence.md create mode 100644 specs/changes/packed-opencode-host-matrix/proposal.md create mode 100644 specs/changes/packed-opencode-host-matrix/tasks.md diff --git a/specs/changes/opencode-lifecycle-results/change.json b/specs/changes/opencode-lifecycle-results/change.json new file mode 100644 index 0000000..7b90c68 --- /dev/null +++ b/specs/changes/opencode-lifecycle-results/change.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "id": "opencode-lifecycle-results", + "title": "Expose bounded lifecycle results and reopen", + "lane": "standard", + "status": "draft", + "affectedRequirements": ["UX-01", "LIFE-06", "COMPAT-05"], + "currentSpecs": [ + "specs/current/artifact-lifecycle.spec.md", + "specs/current/opencode-integration.spec.md" + ], + "currentSpecsUpdated": false, + "approval": { + "by": "", + "at": "" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-18", + "archivedAt": null +} diff --git a/specs/changes/opencode-lifecycle-results/delta.md b/specs/changes/opencode-lifecycle-results/delta.md new file mode 100644 index 0000000..a0848c4 --- /dev/null +++ b/specs/changes/opencode-lifecycle-results/delta.md @@ -0,0 +1,66 @@ +# Specification delta: Expose bounded lifecycle results and reopen + +## MODIFIED + +### Requirement: UX-01 + +Plugin lifecycle success and failure results provide a concise model-readable summary plus a +versioned bounded metadata envelope containing the exact artifact identity, revision, path/URL, +visibility/capability, status, and next safe action where applicable. + +#### Scenario: Normal behavior +- **Given:** a create, update, read, restore, archive, import/export, or reopen succeeds +- **When:** the tool returns +- **Then:** its short output and structured metadata identify the resulting artifact state + +#### Scenario: Failure or refusal +- **Given:** validation, permission, stale state, missing reference, or launch fails +- **When:** the tool returns +- **Then:** it names the typed layer, unchanged state, and bounded next action without raw secrets + +#### Scenario: Relevant boundary +- **Given:** content exceeds the result-envelope inline limit +- **When:** read or conflict output is returned +- **Then:** metadata remains bounded and points to exact pinned content with a truncated summary + +### Requirement: LIFE-06 + +The stable plugin lifecycle tool adds exact-reference `reopen` to list, read/status, restore, +archive/unarchive, import, and export. A config-injected `/artifact-reopen` command uses that +operation; `latest --open` remains the standalone fallback. + +#### Scenario: Normal behavior +- **Given:** an exact active ID, slug, contained path, or registered URL +- **When:** reopen is requested +- **Then:** the selected stable local path or registered URL opens and the exact identity is returned + +#### Scenario: Failure or refusal +- **Given:** the reference is missing, ambiguous, archived, escaping, or unregistered +- **When:** reopen is requested +- **Then:** nothing opens and an actionable exact-reference error is returned + +#### Scenario: Relevant boundary +- **Given:** the stable host cannot accept the injected prompt command +- **When:** plugin configuration loads +- **Then:** tools still load and the documented CLI fallback remains available + +### Requirement: COMPAT-05 + +The new `reopen` operation and version-1 result envelope are additive. Existing tool IDs, +argument spellings, JSON text content, and CLI fallback remain accepted for at least the +documented deprecation window. + +#### Scenario: Normal behavior +- **Given:** a caller consumes the new result metadata +- **When:** a lifecycle operation completes +- **Then:** schema version and bounded stable fields are present alongside readable output + +#### Scenario: Failure or refusal +- **Given:** an implementation would remove or reinterpret an existing public field +- **When:** compatibility tests run +- **Then:** the change is refused until a migration and deprecation path exists + +#### Scenario: Relevant boundary +- **Given:** an older caller ignores metadata and reads only returned text +- **When:** the same existing operation runs +- **Then:** it still receives an understandable backward-compatible summary diff --git a/specs/changes/opencode-lifecycle-results/evidence.md b/specs/changes/opencode-lifecycle-results/evidence.md new file mode 100644 index 0000000..c4105c7 --- /dev/null +++ b/specs/changes/opencode-lifecycle-results/evidence.md @@ -0,0 +1,23 @@ +# Evidence: Expose bounded lifecycle results and reopen + +Add one section for every affected requirement ID. Link syntax is +`[@test](path)`, `[@manual](path)`, or `[@model](path)` and targets must exist before archive. +Do not hide failed or excluded results. + +## Requirement: UX-01 +- Validation: users and models see exact identity, revision, capability, and next action. +- Verification: planned bounded envelope and clean-host reopen command observation. +- Result: pending implementation. +- Evidence: [@test](test/plugin.test.ts) + +## Requirement: LIFE-06 +- Validation: reopen joins the existing list/read/status/archive lifecycle surface. +- Verification: planned exact-reference launcher and no-open refusal tests. +- Result: pending implementation. +- Evidence: [@test](test/artifact-lifecycle.test.ts) + +## Requirement: COMPAT-05 +- Validation: old callers retain accepted arguments and actionable deprecation output. +- Verification: planned old/new contract fixtures and packed-host schema snapshot. +- Result: pending implementation. +- Evidence: [@test](test/plugin.test.ts) diff --git a/specs/changes/opencode-lifecycle-results/proposal.md b/specs/changes/opencode-lifecycle-results/proposal.md new file mode 100644 index 0000000..25a50ec --- /dev/null +++ b/specs/changes/opencode-lifecycle-results/proposal.md @@ -0,0 +1,38 @@ +# Proposal: Expose bounded lifecycle results and reopen + +## Outcome + +Artifact tool calls return a short model-readable summary plus bounded structured metadata, +and users can reopen an exact artifact through a stable tool operation and an injected +`/artifact-reopen` command. The CLI `latest --open` remains the service-independent fallback. + +## Context + +The stable OpenCode 1.18.18 tool contract supports `{title, output, metadata, attachments}` and +the config hook supports custom prompt commands. Current tools mostly return ad hoc strings or +JSON text, which makes successful identity/revision/visibility results harder for models and +users to consume. Reopening is available only through the standalone CLI. + +## Scope + +- In scope: a versioned bounded result envelope in stable tool metadata, concise text summaries, + consistent typed failures, `artifact_lifecycle` reopen-by-exact-reference, a stable config- + injected `/artifact-reopen` prompt command, deprecation documentation, and host tests. +- Out of scope: V2 plugin entrypoints, TUI-private hooks, overriding built-in keybindings, + removing any existing argument/result spelling, arbitrary file opening, or returning full + large HTML in metadata. + +## Risks and rollback + +- Risk: changing returned text can break agents that parse prose, and opening a fuzzy or stale + path can show the wrong artifact. +- Rollback: preserve existing text fields/argument spellings for the deprecation window, omit + the injected command if the stable config hook rejects it, and retain `latest --open`. + +## Validation plan + +Contract tests cap every output/metadata field, retain old input spellings, and prove exact +reference resolution. A clean packed host discovers the new operation, executes structured +list/status/read/publish paths, and sees `/artifact-reopen`; a launcher fake verifies the exact +selected path while invalid/ambiguous references open nothing. This packet changes public +plugin tool arguments/results and therefore requires explicit approval before implementation. diff --git a/specs/changes/opencode-lifecycle-results/tasks.md b/specs/changes/opencode-lifecycle-results/tasks.md new file mode 100644 index 0000000..c04de95 --- /dev/null +++ b/specs/changes/opencode-lifecycle-results/tasks.md @@ -0,0 +1,10 @@ +# Tasks: Expose bounded lifecycle results and reopen + +- [ ] Confirm proposal validation and human approval. +- [ ] Freeze public input/result compatibility fixtures and bounded envelope limits. +- [ ] Return concise output with versioned structured metadata and typed failures. +- [ ] Add exact-reference `reopen` to `artifact_lifecycle` with an injectable launcher. +- [ ] Inject and verify `/artifact-reopen` only through the supported stable config hook. +- [ ] Test old spellings, large results, invalid references, and CLI fallback behavior. +- [ ] Record host evidence and update lifecycle/OpenCode current specs. +- [ ] Run repository validation and archive the packet. diff --git a/specs/changes/opencode-permission-contracts/change.json b/specs/changes/opencode-permission-contracts/change.json new file mode 100644 index 0000000..bf9310f --- /dev/null +++ b/specs/changes/opencode-permission-contracts/change.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "opencode-permission-contracts", + "title": "Separate OpenCode artifact permissions", + "lane": "high-risk", + "status": "draft", + "affectedRequirements": ["UX-03", "OC-06"], + "currentSpecs": ["specs/current/opencode-integration.spec.md"], + "currentSpecsUpdated": false, + "approval": { + "by": "", + "at": "" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-18", + "archivedAt": null +} diff --git a/specs/changes/opencode-permission-contracts/delta.md b/specs/changes/opencode-permission-contracts/delta.md new file mode 100644 index 0000000..dad5575 --- /dev/null +++ b/specs/changes/opencode-permission-contracts/delta.md @@ -0,0 +1,45 @@ +# Specification delta: Separate OpenCode artifact permissions + +## MODIFIED + +### Requirement: UX-03 + +Local artifact writes, datasource authority, provider deployment, and public audience expansion +use separate explicit scoped checkpoints. Repeated approval cannot broaden from one capability, +artifact, target, or visibility to another. + +#### Scenario: Normal behavior +- **Given:** publication requests local write plus selected elevated capabilities +- **When:** validation and secret scanning pass +- **Then:** OpenCode asks for each requested scope with exact bounded metadata before mutation + +#### Scenario: Failure or refusal +- **Given:** any required permission is denied, aborted, malformed, or unavailable +- **When:** the invocation runs +- **Then:** no filesystem/provider mutation occurs and the result names the denied layer and next action + +#### Scenario: Relevant boundary +- **Given:** a prior local-write permission is remembered +- **When:** a later call adds datasource, deploy, or public-audience scope +- **Then:** the remembered grant does not authorize any added capability + +### Requirement: OC-06 + +Stable OpenCode policy can independently set `allow`, `ask`, or `deny` for +`artifact_publish`, `artifact_datasource`, `artifact_deploy`, and `artifact_audience`. +Deployment metadata distinguishes artifact, target, capability, and visibility without secrets. + +#### Scenario: Normal behavior +- **Given:** explicit permission rules for each artifact resource +- **When:** the stable host executes a matching publish request +- **Then:** each allow/ask decision is honored in deterministic order and audited by exact scope + +#### Scenario: Failure or refusal +- **Given:** one resource is explicitly denied +- **When:** normal or auto mode executes the request +- **Then:** the deny remains effective and no later resource or side effect is reached + +#### Scenario: Relevant boundary +- **Given:** only a local portable file is requested +- **When:** publication runs +- **Then:** no datasource, deploy, or audience permission is requested diff --git a/specs/changes/opencode-permission-contracts/design.md b/specs/changes/opencode-permission-contracts/design.md new file mode 100644 index 0000000..bde4f2b --- /dev/null +++ b/specs/changes/opencode-permission-contracts/design.md @@ -0,0 +1,53 @@ +# Design: Separate OpenCode artifact permissions + +Required for high-risk changes. + +## Context and constraints + +Preflight and final-byte secret scanning must finish before authority is requested. Local +publication, datasource registration/execution authority, provider deployment, and increasing +the audience to public-static are distinct side effects. Existing `artifact_publish` callers +must remain source-compatible, but their old single approval cannot authorize new scopes. + +## Chosen design + +Model one invocation as a monotonic state machine: + +`validated -> local-approved -> datasource-approved? -> deploy-approved? -> audience-approved? -> commit` + +Use `artifact_publish`, `artifact_datasource`, `artifact_deploy`, and `artifact_audience` as +separate resources. Ask only for requested capabilities, use exact slug/source/target/ +visibility patterns, set `always: []` for elevated scopes, and include bounded non-secret +metadata. Resolve every required approval before the first write or runner/provider call. A +denial throws a bounded typed result naming the denied layer and confirming no mutation. + +## Alternatives + +A single composite prompt is simpler but cannot be independently configured or safely +remembered. Separate tools would make authority obvious but would break the documented one-call +publish workflow and add partial orchestration states. Asking after local publication preserves +today's order but violates the no-partial-mutation refusal contract. + +## Trust, privacy, and failure boundaries + +Permission patterns never contain Markdown, credentials, shell output, or provider secrets. +Datasource metadata carries registered names and executable basenames, not arguments. Provider +and audience asks identify capability, target, and visibility. All denials, aborts, malformed +rules, and missing host support fail before writes/network calls; exact explicit `deny` remains +effective under auto mode. + +## Migration, rollout, and rollback + +Add resources without reinterpreting saved `artifact_publish` grants. Existing callers keep +their arguments, but datasource/deploy requests now require additional approval. Roll out only +after stable-host allow/ask/deny/auto tests pass. Rollback disables elevated options rather than +folding them back under the local-write grant. + +## Formal-method decision + +- Decision: bounded state-machine/property model. +- Property and rationale: for every requested-scope subset and allow/deny transition, no write + or external call occurs unless all required permissions precede it; auto mode never overrides + an explicit deny. +- Model/evidence path: planned `test/model/opencode-permission-model.ts` plus integration tests + with injected asks, writers, and provider runners. diff --git a/specs/changes/opencode-permission-contracts/evidence.md b/specs/changes/opencode-permission-contracts/evidence.md new file mode 100644 index 0000000..acf385e --- /dev/null +++ b/specs/changes/opencode-permission-contracts/evidence.md @@ -0,0 +1,17 @@ +# Evidence: Separate OpenCode artifact permissions + +Add one section for every affected requirement ID. Link syntax is +`[@test](path)`, `[@manual](path)`, or `[@model](path)` and targets must exist before archive. +Do not hide failed or excluded results. + +## Requirement: UX-03 +- Validation: each audience/datasource/side-effect boundary is separately understandable. +- Verification: planned transition model plus denial/no-mutation integration tests. +- Result: pending implementation. +- Evidence: [@test](test/plugin.test.ts) + +## Requirement: OC-06 +- Validation: stable OpenCode permission policy can control each artifact authority. +- Verification: planned packed-host allow/ask/deny/auto matrix and exact prompt trace. +- Result: pending implementation. +- Evidence: [@manual](docs/evidence/opencode-host-verification.md) diff --git a/specs/changes/opencode-permission-contracts/proposal.md b/specs/changes/opencode-permission-contracts/proposal.md new file mode 100644 index 0000000..9e69091 --- /dev/null +++ b/specs/changes/opencode-permission-contracts/proposal.md @@ -0,0 +1,35 @@ +# Proposal: Separate OpenCode artifact permissions + +## Outcome + +OpenCode users can independently allow, ask, or deny local artifact writes, datasource +authority, public deployment, and audience expansion. A refusal fails closed before any +filesystem or provider mutation and reports what remained unchanged. + +## Context + +`artifact_publish` currently asks once under `artifact_publish`, then may also register shell +datasources and deploy to a public target. That one approval can therefore authorize materially +different capabilities and audience. Stable OpenCode supports named `ctx.ask` permission +resources plus `allow`, `ask`, `deny`, and auto mode, so these authorities can be separated +without adopting the V2 beta API. + +## Scope + +- In scope: four permission resources, exact bounded patterns/metadata, prompt ordering, + denial/no-write/no-provider guarantees, auto-mode behavior, and real stable-host tests. +- Out of scope: executing viewer-supplied commands, remembering broader grants, changing + provider credentials, deploying a real site, or changing hosted identity/authorization. + +## Risks and rollback + +- Risk: asking too late can leave a local write after a deploy refusal; asking too broadly can + turn one remembered choice into persistent datasource or public-audience authority. +- Rollback: retain only local publication behind its existing exact permission and disable + datasource/deploy/audience options until the stable host proves each separated resource. + +## Validation plan + +Unit tests record exact ask order and metadata, then inject allow/deny at every transition and +assert no unauthorized runner or write. Packed OpenCode tests cover allow, ask, explicit deny, +and auto mode using the official [permission contract](https://opencode.ai/docs/permissions/). diff --git a/specs/changes/opencode-permission-contracts/tasks.md b/specs/changes/opencode-permission-contracts/tasks.md new file mode 100644 index 0000000..46b13f9 --- /dev/null +++ b/specs/changes/opencode-permission-contracts/tasks.md @@ -0,0 +1,9 @@ +# Tasks: Separate OpenCode artifact permissions + +- [ ] Confirm proposal validation and human approval. +- [ ] Add a permission state/property model covering every requested-scope subset and denial. +- [ ] Add injectable no-write/no-runner tests for ask order, abort, and bounded metadata. +- [ ] Implement separate local-write, datasource, deploy, and audience resources. +- [ ] Verify stable-host allow/ask/deny and auto mode without a real deployment. +- [ ] Record exact permission traces and update `specs/current/opencode-integration.spec.md`. +- [ ] Run repository validation and archive the packet. diff --git a/specs/changes/opencode-skill-distribution/change.json b/specs/changes/opencode-skill-distribution/change.json new file mode 100644 index 0000000..2d4c155 --- /dev/null +++ b/specs/changes/opencode-skill-distribution/change.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "opencode-skill-distribution", + "title": "Install the artifact skill through official discovery", + "lane": "standard", + "status": "draft", + "affectedRequirements": ["OC-05", "DIST-01", "DIST-02"], + "currentSpecs": ["specs/current/opencode-integration.spec.md"], + "currentSpecsUpdated": false, + "approval": { + "by": "", + "at": "" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-18", + "archivedAt": null +} diff --git a/specs/changes/opencode-skill-distribution/delta.md b/specs/changes/opencode-skill-distribution/delta.md new file mode 100644 index 0000000..18df616 --- /dev/null +++ b/specs/changes/opencode-skill-distribution/delta.md @@ -0,0 +1,65 @@ +# Specification delta: Install the artifact skill through official discovery + +## MODIFIED + +### Requirement: OC-05 + +The packed `artifact-pages` skill has valid official frontmatter and an explicit installer for +project `.opencode/skills/artifact-pages` or global OpenCode configuration skill discovery. It +is advertised and loaded on demand subject to native skill permissions; proactive plugin mode +remains a separate explicit option. + +#### Scenario: Normal behavior +- **Given:** a clean packed install and selected project or global scope +- **When:** the user runs the skill installer and starts stable OpenCode +- **Then:** the native skill tool advertises `artifact-pages` and can load its body/references + +#### Scenario: Failure or refusal +- **Given:** the destination differs, is symlinked, escapes scope, or is not writable +- **When:** installation runs without exact force authorization +- **Then:** it refuses before replacement and reports the unchanged path and recovery action + +#### Scenario: Relevant boundary +- **Given:** the identical complete skill is already installed +- **When:** installation repeats +- **Then:** it is an idempotent no-op and does not duplicate or broaden skill permissions + +### Requirement: DIST-01 + +The exact npm tarball includes the skill body, required references, and installer source/runtime; +the installer resolves only those packed files and never depends on repository-only paths. + +#### Scenario: Normal behavior +- **Given:** the generated tarball installed in an isolated prefix +- **When:** skill installation runs +- **Then:** every copied byte comes from the reviewed packed skill inventory + +#### Scenario: Failure or refusal +- **Given:** a required packed skill file is missing or changed during inspection +- **When:** installation runs +- **Then:** no partial destination becomes selected and the command fails actionably + +#### Scenario: Relevant boundary +- **Given:** the source package tree is removed after installation +- **When:** OpenCode loads the copied skill +- **Then:** the installed skill and references remain available from the official destination + +### Requirement: DIST-02 + +Registry, official OpenCode plugin configuration, and local-development documentation each name +the explicit skill-install step, destination, safe collision behavior, and manual removal path. + +#### Scenario: Normal behavior +- **Given:** a user follows only the packed-package README path +- **When:** they install the plugin and project-scoped skill +- **Then:** tools and native on-demand skill discovery work without a checkout + +#### Scenario: Failure or refusal +- **Given:** an install command cannot safely finish +- **When:** it reports failure +- **Then:** documentation identifies the failing layer and retry/removal path without deleting user data + +#### Scenario: Relevant boundary +- **Given:** a contributor uses a local checkout +- **When:** they choose the local-development workflow +- **Then:** documentation distinguishes it from registry and packed-host evidence diff --git a/specs/changes/opencode-skill-distribution/evidence.md b/specs/changes/opencode-skill-distribution/evidence.md new file mode 100644 index 0000000..4fe599a --- /dev/null +++ b/specs/changes/opencode-skill-distribution/evidence.md @@ -0,0 +1,23 @@ +# Evidence: Install the artifact skill through official discovery + +Add one section for every affected requirement ID. Link syntax is +`[@test](path)`, `[@manual](path)`, or `[@model](path)` and targets must exist before archive. +Do not hide failed or excluded results. + +## Requirement: OC-05 +- Validation: the native stable host advertises and loads the installed skill on demand. +- Verification: planned frontmatter, permission, discovery, and load observation. +- Result: pending implementation. +- Evidence: [@manual](docs/evidence/opencode-host-verification.md) + +## Requirement: DIST-01 +- Validation: the exact tarball carries every file the explicit installer needs. +- Verification: planned packed-file and clean-install assertions. +- Result: pending implementation. +- Evidence: [@test](.github/workflows/ci.yml) + +## Requirement: DIST-02 +- Validation: project/global official paths work from clean state and fail safely on collision. +- Verification: planned installer matrix plus README-only clean-host flow. +- Result: pending implementation. +- Evidence: [@manual](README.md) diff --git a/specs/changes/opencode-skill-distribution/proposal.md b/specs/changes/opencode-skill-distribution/proposal.md new file mode 100644 index 0000000..bffb153 --- /dev/null +++ b/specs/changes/opencode-skill-distribution/proposal.md @@ -0,0 +1,37 @@ +# Proposal: Install the artifact skill through official discovery + +## Outcome + +After installing the packed plugin, users can explicitly install the bundled `artifact-pages` +skill into an official project or global OpenCode skill directory. A clean host advertises and +loads it on demand without proactive prompt injection or a repository checkout. + +## Context + +The tarball contains `skills/artifact-pages`, but stable OpenCode discovers project skills from +`.opencode/skills` and global skills from `~/.config/opencode/skills`; an arbitrary directory +inside an npm package is not advertised automatically. The current README offers a generic +`.agents/skills` copy command, with no idempotence, collision, or packed-host test. + +## Scope + +- In scope: `opencode-artifacts skill install --project|--global`, exact official destinations, + contained bundled files, atomic/idempotent copy, collision refusal and explicit force, + uninstall instructions, clean packed-host advertisement/load checks, and frontmatter tests. +- Out of scope: postinstall scripts, silent home-directory writes, default proactive injection, + changing skill permissions, V2 skill registration, or deleting user-modified skill files. + +## Risks and rollback + +- Risk: an installer can overwrite a customized skill or place files where the host never + discovers them. +- Rollback: make installation explicit, compare the complete destination before no-op, refuse + differing content unless `--force` names the exact directory, and document manual removal. + +## Validation plan + +Filesystem tests cover project/global destinations, traversal/symlink/collision/atomicity, and +packed-byte source resolution. The packed-host matrix installs the skill into an empty official +directory, confirms it appears in the stable native `skill` tool description, loads it, and +records exact host/package versions. The discovery contract follows the official +[Agent Skills documentation](https://opencode.ai/docs/skills/). diff --git a/specs/changes/opencode-skill-distribution/tasks.md b/specs/changes/opencode-skill-distribution/tasks.md new file mode 100644 index 0000000..51eb717 --- /dev/null +++ b/specs/changes/opencode-skill-distribution/tasks.md @@ -0,0 +1,9 @@ +# Tasks: Install the artifact skill through official discovery + +- [ ] Confirm proposal validation and human approval. +- [ ] Add contained installer tests for project/global, idempotence, collision, force, and symlinks. +- [ ] Implement explicit atomic skill installation from packed bytes into official directories. +- [ ] Document install, safe manual removal, proactive-mode distinction, and failure recovery. +- [ ] Verify native stable-host advertisement and on-demand load from a clean packed install. +- [ ] Record exact evidence and update `specs/current/opencode-integration.spec.md`. +- [ ] Run repository validation and archive the packet. diff --git a/specs/changes/packed-opencode-host-matrix/change.json b/specs/changes/packed-opencode-host-matrix/change.json new file mode 100644 index 0000000..bbdd20d --- /dev/null +++ b/specs/changes/packed-opencode-host-matrix/change.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "packed-opencode-host-matrix", + "title": "Verify packed OpenCode host compatibility", + "lane": "standard", + "status": "draft", + "affectedRequirements": ["OC-01", "OC-02", "OC-03", "OC-04", "QUAL-03"], + "currentSpecs": ["specs/current/opencode-integration.spec.md"], + "currentSpecsUpdated": false, + "approval": { + "by": "", + "at": "" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-18", + "archivedAt": null +} diff --git a/specs/changes/packed-opencode-host-matrix/delta.md b/specs/changes/packed-opencode-host-matrix/delta.md new file mode 100644 index 0000000..d3dc107 --- /dev/null +++ b/specs/changes/packed-opencode-host-matrix/delta.md @@ -0,0 +1,106 @@ +# Specification delta: Verify packed OpenCode host compatibility + +## MODIFIED + +### Requirement: OC-01 + +The exact packed npm package loads through the stable OpenCode plugin API and exposes every +documented artifact tool with the shipped argument schema. + +#### Scenario: Normal behavior +- **Given:** the candidate tarball installed into a clean stable OpenCode host +- **When:** the live tool discovery endpoint is queried +- **Then:** all documented artifact tool IDs and schemas are present + +#### Scenario: Failure or refusal +- **Given:** the package or a runtime dependency cannot load +- **When:** host discovery runs +- **Then:** the cell fails with bounded host/package logs and cannot become compatibility evidence + +#### Scenario: Relevant boundary +- **Given:** the worktree and developer `node_modules` are unavailable +- **When:** the packed plugin loads +- **Then:** every runtime import resolves only from installed tarball dependencies + +### Requirement: OC-02 + +The stable host supports the official `opencode plugin opencode-artifacts` install, direct +`plugin` array configuration, and an explicit local development path, with each route documented +according to its actual cache and dependency behavior. + +#### Scenario: Normal behavior +- **Given:** empty project and OpenCode configuration roots +- **When:** the official plugin command installs the package +- **Then:** configuration is updated and the installed plugin is discoverable after restart + +#### Scenario: Failure or refusal +- **Given:** install, cache, or config mutation fails +- **When:** OpenCode starts +- **Then:** the user sees the failing layer and a clean retry path without a claimed pass + +#### Scenario: Relevant boundary +- **Given:** a contributor selects a local `file:` package +- **When:** the development workflow runs +- **Then:** it uses the built checkout explicitly and is not reported as packed or registry evidence + +### Requirement: OC-03 + +CI builds one npm tarball and tests those exact bytes against the current stable OpenCode host; +workspace-only tests remain necessary but insufficient. + +#### Scenario: Normal behavior +- **Given:** a pull request candidate +- **When:** the packed-host CI job runs +- **Then:** it installs the generated tarball into clean roots and retains discovery/smoke output + +#### Scenario: Failure or refusal +- **Given:** any pack, install, startup, discovery, or smoke step fails +- **When:** CI reconciles the job +- **Then:** the required check fails and preserves the responsible layer's bounded log + +#### Scenario: Relevant boundary +- **Given:** the registry has a newer OpenCode release than the pinned observation +- **When:** CI resolves the current cell +- **Then:** it records the exact resolved version and does not reuse older evidence for it + +### Requirement: OC-04 + +The compatibility policy names exact current and oldest-tested stable OpenCode releases and +promotes only releases that passed identical packed-host coverage. Initially those cells may be +the same exact version. + +#### Scenario: Normal behavior +- **Given:** OpenCode 1.18.18 passes the complete packed-host cell +- **When:** compatibility status is published +- **Then:** it is recorded as the initial exact current and oldest-tested observation + +#### Scenario: Failure or refusal +- **Given:** a claimed host version lacks or fails equivalent evidence +- **When:** policy validation runs +- **Then:** that version remains unverified or unsupported rather than entering the peer claim + +#### Scenario: Relevant boundary +- **Given:** a V2 beta or development tag is available +- **When:** stable compatibility is calculated +- **Then:** the tag is excluded and no beta entrypoint silently replaces stable behavior + +### Requirement: QUAL-03 + +Packed-plugin acceptance uses a clean config/data/cache/state environment, an exact package +digest and host version, live health/tool discovery, schema comparison, and safe non-mutating +tool smoke for the initial current/oldest stable matrix. + +#### Scenario: Normal behavior +- **Given:** the exact candidate tarball and stable host versions +- **When:** the matrix executes +- **Then:** every cell retains config, versions, digest, health, schemas, smoke results, and logs + +#### Scenario: Failure or refusal +- **Given:** a cell is skipped, excluded, flaky, or fails +- **When:** evidence is written +- **Then:** its disposition is visible and the matrix does not report complete + +#### Scenario: Relevant boundary +- **Given:** current and oldest-supported intentionally resolve to one initial version +- **When:** the gate runs +- **Then:** it executes one deduplicated exact cell and states that no broader host range is proven diff --git a/specs/changes/packed-opencode-host-matrix/evidence.md b/specs/changes/packed-opencode-host-matrix/evidence.md new file mode 100644 index 0000000..098f2d0 --- /dev/null +++ b/specs/changes/packed-opencode-host-matrix/evidence.md @@ -0,0 +1,35 @@ +# Evidence: Verify packed OpenCode host compatibility + +Add one section for every affected requirement ID. Link syntax is +`[@test](path)`, `[@manual](path)`, or `[@model](path)` and targets must exist before archive. +Do not hide failed or excluded results. + +## Requirement: OC-01 +- Validation: clean-host discovery exposes the tools a user installed. +- Verification: planned packed stable-host discovery and schema assertions. +- Result: pending implementation. +- Evidence: [@manual](docs/evidence/opencode-host-verification.md) + +## Requirement: OC-02 +- Validation: both official install entry paths produce a loadable plugin. +- Verification: planned isolated CLI-install and direct-config cells. +- Result: pending implementation. +- Evidence: [@manual](docs/evidence/opencode-host-verification.md) + +## Requirement: OC-03 +- Validation: CI tests shipped bytes rather than workspace resolution. +- Verification: planned exact-tarball workflow and retained output. +- Result: pending implementation. +- Evidence: [@test](.github/workflows/ci.yml) + +## Requirement: OC-04 +- Validation: compatibility claims name only identically tested host versions. +- Verification: planned exact current/oldest resolver and policy assertions. +- Result: pending implementation. +- Evidence: [@test](test/governance-policy.test.ts) + +## Requirement: QUAL-03 +- Validation: the matrix exercises the real stable host boundary users receive. +- Verification: planned clean config/cache, discovery, schema, and smoke trace. +- Result: pending implementation. +- Evidence: [@manual](docs/evidence/opencode-host-verification.md) diff --git a/specs/changes/packed-opencode-host-matrix/proposal.md b/specs/changes/packed-opencode-host-matrix/proposal.md new file mode 100644 index 0000000..de17b9d --- /dev/null +++ b/specs/changes/packed-opencode-host-matrix/proposal.md @@ -0,0 +1,39 @@ +# Proposal: Verify packed OpenCode host compatibility + +## Outcome + +Users can install the exact packed package through the stable OpenCode flow and see every +documented tool on a clean host. CI proves the initial compatibility floor against the exact +current stable host rather than inferring it from workspace unit tests or a peer range. + +## Context + +The repository has a dated OpenCode 1.18.18 checkout/package observation, but CI does not load +the tarball into a clean OpenCode cache or execute the live discovery surface. On 2026-08-18, +the registry also reports `opencode-ai@1.18.18` and `@opencode-ai/plugin@1.18.18` as current. +The support policy deliberately treats that one exact release as both the initial current and +oldest-tested candidate until a second release receives identical coverage. + +## Scope + +- In scope: exact tarball pack/install, `opencode plugin` and config-array paths, clean config/ + data/cache/state roots, stable headless health and tool discovery, safe read-only smoke, + current/oldest version resolution, peer/engine claim narrowing, and retained CI artifacts. +- Out of scope: publishing to npm, adding a V2 beta adapter, claiming OS/browser support, + exercising provider models, paid inference, or treating a moving `latest` label as evidence. + +## Risks and rollback + +- Risk: network or host-release churn can make CI flaky, while an overly broad peer range can + imply compatibility not actually tested. +- Rollback: keep the stable adapter, pin the observed host cell, narrow the compatibility + claim, and surface a failed/unverified matrix cell; never replace a failed host probe with a + workspace-only pass. + +## Validation plan + +A hermetic harness builds one tarball, installs that file into empty roots, starts the exact +stable host, verifies health and documented tool IDs/schemas, and performs non-mutating +list/status probes. CI retains package digest, host version, config, discovery output, logs, +and explicit failures. The official stable [CLI](https://opencode.ai/docs/cli/) and +[plugin configuration](https://opencode.ai/docs/plugins/) contracts are the source boundary. diff --git a/specs/changes/packed-opencode-host-matrix/tasks.md b/specs/changes/packed-opencode-host-matrix/tasks.md new file mode 100644 index 0000000..627b5f8 --- /dev/null +++ b/specs/changes/packed-opencode-host-matrix/tasks.md @@ -0,0 +1,10 @@ +# Tasks: Verify packed OpenCode host compatibility + +- [ ] Confirm proposal validation and human approval. +- [ ] Add a deterministic packed-host harness with bounded logs and injectable host commands. +- [ ] Exercise exact tarball CLI-install and config-array discovery in clean roots. +- [ ] Assert tool IDs/schemas and safe read-only lifecycle smoke on the exact stable host. +- [ ] Narrow compatibility metadata/policy to the exact current and oldest-tested releases. +- [ ] Add the packed-host cell to CI and retain exact package/host/config/output evidence. +- [ ] Record failures/exclusions and update `specs/current/opencode-integration.spec.md`. +- [ ] Run repository validation and archive the packet. From eab3025a01a69253ea0e30b36d99d766478f00d7 Mon Sep 17 00:00:00 2001 From: bitgorust Date: Tue, 18 Aug 2026 03:49:25 +0200 Subject: [PATCH 2/8] docs: approve native OpenCode lifecycle --- specs/changes/opencode-lifecycle-results/change.json | 6 +++--- specs/changes/opencode-permission-contracts/change.json | 6 +++--- specs/changes/opencode-skill-distribution/change.json | 6 +++--- specs/changes/packed-opencode-host-matrix/change.json | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/specs/changes/opencode-lifecycle-results/change.json b/specs/changes/opencode-lifecycle-results/change.json index 7b90c68..1aaa923 100644 --- a/specs/changes/opencode-lifecycle-results/change.json +++ b/specs/changes/opencode-lifecycle-results/change.json @@ -3,7 +3,7 @@ "id": "opencode-lifecycle-results", "title": "Expose bounded lifecycle results and reopen", "lane": "standard", - "status": "draft", + "status": "approved", "affectedRequirements": ["UX-01", "LIFE-06", "COMPAT-05"], "currentSpecs": [ "specs/current/artifact-lifecycle.spec.md", @@ -11,8 +11,8 @@ ], "currentSpecsUpdated": false, "approval": { - "by": "", - "at": "" + "by": "aaron.zeng", + "at": "2026-08-18T01:48:52Z" }, "withdrawal": { "by": "", diff --git a/specs/changes/opencode-permission-contracts/change.json b/specs/changes/opencode-permission-contracts/change.json index bf9310f..d9529a6 100644 --- a/specs/changes/opencode-permission-contracts/change.json +++ b/specs/changes/opencode-permission-contracts/change.json @@ -3,13 +3,13 @@ "id": "opencode-permission-contracts", "title": "Separate OpenCode artifact permissions", "lane": "high-risk", - "status": "draft", + "status": "approved", "affectedRequirements": ["UX-03", "OC-06"], "currentSpecs": ["specs/current/opencode-integration.spec.md"], "currentSpecsUpdated": false, "approval": { - "by": "", - "at": "" + "by": "aaron.zeng", + "at": "2026-08-18T01:48:52Z" }, "withdrawal": { "by": "", diff --git a/specs/changes/opencode-skill-distribution/change.json b/specs/changes/opencode-skill-distribution/change.json index 2d4c155..7e049e8 100644 --- a/specs/changes/opencode-skill-distribution/change.json +++ b/specs/changes/opencode-skill-distribution/change.json @@ -3,13 +3,13 @@ "id": "opencode-skill-distribution", "title": "Install the artifact skill through official discovery", "lane": "standard", - "status": "draft", + "status": "approved", "affectedRequirements": ["OC-05", "DIST-01", "DIST-02"], "currentSpecs": ["specs/current/opencode-integration.spec.md"], "currentSpecsUpdated": false, "approval": { - "by": "", - "at": "" + "by": "aaron.zeng", + "at": "2026-08-18T01:48:52Z" }, "withdrawal": { "by": "", diff --git a/specs/changes/packed-opencode-host-matrix/change.json b/specs/changes/packed-opencode-host-matrix/change.json index bbdd20d..810a8de 100644 --- a/specs/changes/packed-opencode-host-matrix/change.json +++ b/specs/changes/packed-opencode-host-matrix/change.json @@ -3,13 +3,13 @@ "id": "packed-opencode-host-matrix", "title": "Verify packed OpenCode host compatibility", "lane": "standard", - "status": "draft", + "status": "approved", "affectedRequirements": ["OC-01", "OC-02", "OC-03", "OC-04", "QUAL-03"], "currentSpecs": ["specs/current/opencode-integration.spec.md"], "currentSpecsUpdated": false, "approval": { - "by": "", - "at": "" + "by": "aaron.zeng", + "at": "2026-08-18T01:48:52Z" }, "withdrawal": { "by": "", From f7364834e4db08fc3e12abd4e8b261a1f784ab91 Mon Sep 17 00:00:00 2001 From: bitgorust Date: Tue, 18 Aug 2026 04:18:47 +0200 Subject: [PATCH 3/8] test: verify packed OpenCode hosts --- .github/workflows/ci.yml | 1 + docs/evidence/opencode-host-verification.md | 50 +++ docs/governance-policy.json | 14 +- docs/support-policy.md | 12 +- package-lock.json | 4 +- package.json | 4 +- scripts/opencode-host-matrix.ts | 412 ++++++++++++++++++ .../packed-opencode-host-matrix/change.json | 2 +- .../packed-opencode-host-matrix/evidence.md | 27 +- .../packed-opencode-host-matrix/tasks.md | 14 +- specs/current/opencode-integration.spec.md | 31 ++ test/governance-policy.test.ts | 15 +- test/opencode-host-matrix.test.ts | 49 +++ 13 files changed, 607 insertions(+), 28 deletions(-) create mode 100644 scripts/opencode-host-matrix.ts create mode 100644 specs/current/opencode-integration.spec.md create mode 100644 test/opencode-host-matrix.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 519f271..017b61b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: npm audit --json > release-evidence/audit.json npm audit signatures > release-evidence/signatures.txt node scripts/release-integrity.ts licenses package-lock.json docs/license-dispositions.json > release-evidence/licenses.json + node scripts/opencode-host-matrix.ts --pack-json release-evidence/pack.json --output release-evidence/opencode-host-matrix.json sha256sum release-evidence/* opencode-artifacts-*.tgz - uses: actions/upload-artifact@v4 with: diff --git a/docs/evidence/opencode-host-verification.md b/docs/evidence/opencode-host-verification.md index 203cda5..c22c35f 100644 --- a/docs/evidence/opencode-host-verification.md +++ b/docs/evidence/opencode-host-verification.md @@ -73,3 +73,53 @@ migration exception: old Workers continue using the historical shared `ARTIFACTS namespace, while the next release creates a worker-scoped namespace and does not automatically copy mutable decisions, comments, or mini-DB documents. See [`docs/hosted-cloudflare.md`](../hosted-cloudflare.md). + +## Exact packed stable-host matrix — 2026-08-18 + +Host: Ubuntu 24.04.4 LTS, Linux 6.8.0-137-generic x86_64. Node: 24.19.0. +OpenCode stable current/oldest-tested: exact 1.18.18. V2 beta was excluded. No model provider +or inference endpoint was called. + +The candidate was built and packed once, then installed from +`opencode-artifacts-0.15.0.tgz` into an empty dependency prefix with lifecycle scripts +disabled. The initial verified candidate SHA-256 was +`901a1ee6439dd97a0142d63aacecdebd67d2660f4a8ad37a96c559e42a4dd5cf`; Goal 4's final +gate reruns this command and replaces the digest if later packet work changes shipped bytes. + +The first attempted shortcut—placing the `.tgz` URL directly in the `plugin` array or passing +the tarball path directly to `opencode plugin`—was rejected as evidence. Stable OpenCode treats +the first as a module URL and resolves the second as the tarball's parent package directory; +it does not unpack that input. The successful candidate procedure therefore uses `npm install` +to unpack the exact tarball into an empty prefix, then passes the resulting package-directory +`file:` URL to the official plugin command. This is distinct from the checkout-based local +development route. + +`scripts/opencode-host-matrix.ts` executed two clean routes with separate project and XDG +config/data/cache/state roots: + +1. the official `opencode plugin file:///…/node_modules/opencode-artifacts` command, followed + by restart from the configuration it wrote; and +2. direct `plugin` array configuration of that same extracted exact package. + +Both loopback servers returned `{"healthy":true,"version":"1.18.18"}` and exposed these +candidate tools through live discovery and full JSON-schema comparison: + +```text +artifact_publish +artifact_lifecycle +artifact_db +artifact_state +artifact_comments +``` + +A direct execution of the packed plugin's `artifact_lifecycle list` returned schema version 1 +with an empty artifact list and left the prepared manifest byte-for-byte unchanged. This +exercises safe shipped tool code without asking a provider model to select a tool. The matrix +recorded one deduplicated version cell because current stable and oldest-tested are identical; +it explicitly sets `broaderRangeProven: false`. + +The local JSON result is `/tmp/goal4-newpack/opencode-host-matrix.json`; CI generates the same +record as `release-evidence/opencode-host-matrix.json` and retains it with the exact tarball. +The `/tmp` path is transient and is not itself release evidence. Bare registry-coordinate +verification of future candidate bytes remains a post-publication gate; the dated published- +package probe above covers the already published route only. diff --git a/docs/governance-policy.json b/docs/governance-policy.json index 34da67e..f1897e3 100644 --- a/docs/governance-policy.json +++ b/docs/governance-policy.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "policyVersion": 1, - "reviewedAt": "2026-08-16", + "reviewedAt": "2026-08-18", "owners": { "security": "Security maintainer", "privacy": "Privacy maintainer", @@ -37,6 +37,18 @@ "testedAt": "2026-08-16", "evidence": "docs/evidence/governance/ubuntu-packed-observation-2026-08-16.md" }, + { + "id": "packed-opencode-stable", + "class": "tested", + "status": "tested", + "os": "Ubuntu 24.04.4 LTS, Linux 6.8.0-137-generic, x86_64", + "node": "24.19.0", + "opencode": "current stable and oldest-tested 1.18.18 (one deduplicated exact cell)", + "browser": "not exercised; native host boundary only", + "scope": "Exact candidate tarball install, official CLI config mutation, direct config-array load, live health/tool schema discovery, and non-mutating packed lifecycle list without provider inference", + "testedAt": "2026-08-18", + "evidence": "docs/evidence/opencode-host-verification.md" + }, { "id": "ubuntu-lts-desktop", "class": "target", diff --git a/docs/support-policy.md b/docs/support-policy.md index dcefeb3..80d2878 100644 --- a/docs/support-policy.md +++ b/docs/support-policy.md @@ -1,6 +1,6 @@ # Support and release policy -Policy version: 1. Last reviewed: 2026-08-16. +Policy version: 1. Last reviewed: 2026-08-18. This document owns the Phase 0 D-01 platform decision and D-06 release/supply-chain decision. The machine-readable status record is [`governance-policy.json`](governance-policy.json). @@ -35,9 +35,13 @@ contains the exact OS, Node, OpenCode, browser/device, packed package digest, te result. Failed evidence makes a cell unsupported; missing or stale evidence keeps it unverified. Family resemblance and CI configuration do not promote a cell. -The initial current and oldest-supported OpenCode versions are the same exact stable 1.x -version once a complete cell passes. A second host version is not claimed until it receives -the same packed-host coverage. Re-run a cell when its OS/browser generation changes, the Node +The initial current and oldest-tested OpenCode versions are both exact `1.18.18`. On +2026-08-18, one deduplicated packed-host cell passed the official CLI configuration mutation, +direct config-array loading, live health/tool-schema discovery, and a non-mutating lifecycle +smoke under clean roots. This is host compatibility evidence, not a supported desktop/browser +claim. The peer SDK dependency is therefore exact `@opencode-ai/plugin@1.18.18`; no broader +1.x or V2 beta range is claimed. A second host version is not claimed until it receives the +same packed-host coverage. Re-run a cell when its OS/browser generation changes, the Node or OpenCode support line changes, a relevant runtime/renderer dependency changes, or a defect shows the evidence no longer represents users. diff --git a/package-lock.json b/package-lock.json index d37f49e..3901cf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "opencode-artifacts": "dist/cli.js" }, "devDependencies": { - "@opencode-ai/plugin": "^1.0.0", + "@opencode-ai/plugin": "1.18.18", "@types/markdown-it": "^14.1.2", "@types/node": "^24.0.0", "typescript": "^5.8.0" @@ -29,7 +29,7 @@ "node": ">=24" }, "peerDependencies": { - "@opencode-ai/plugin": ">=1.0.0" + "@opencode-ai/plugin": "1.18.18" } }, "node_modules/@ai-sdk/provider": { diff --git a/package.json b/package.json index eea6bda..0ef00fb 100644 --- a/package.json +++ b/package.json @@ -55,10 +55,10 @@ "vega-lite": "^6.4.3" }, "peerDependencies": { - "@opencode-ai/plugin": ">=1.0.0" + "@opencode-ai/plugin": "1.18.18" }, "devDependencies": { - "@opencode-ai/plugin": "^1.0.0", + "@opencode-ai/plugin": "1.18.18", "@types/markdown-it": "^14.1.2", "@types/node": "^24.0.0", "typescript": "^5.8.0" diff --git a/scripts/opencode-host-matrix.ts b/scripts/opencode-host-matrix.ts new file mode 100644 index 0000000..d07e574 --- /dev/null +++ b/scripts/opencode-host-matrix.ts @@ -0,0 +1,412 @@ +#!/usr/bin/env node + +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const OLDEST_TESTED_OPENCODE_VERSION = "1.18.18"; +export const MAX_LOG_BYTES = 64 * 1024; +export const ARTIFACT_TOOL_CONTRACT = { + artifact_publish: ["markdown", "title", "open", "version", "format", "expectedHash", "artifact", "expectedRevision", "force", "dataSources", "deploy", "repo", "target", "workerName"], + artifact_lifecycle: ["op", "artifact", "revision", "expectedRevision", "token", "slug", "path"], + artifact_db: ["slug", "collection", "op", "id", "doc", "q", "expectedRevision", "expectedDocumentHash", "createOnly", "operationId"], + artifact_state: ["slug"], + artifact_comments: ["slug", "resolveId", "digest", "expectedRevision", "expectedHash", "operationId"], +} as const; + +interface CommandResult { + command: string[]; + exitCode: number; + output: string; +} + +interface ToolDescription { + id: string; + parameters?: { + properties?: Record; + required?: unknown; + }; +} + +interface ServerResult { + route: "cli-install" | "config-array"; + hostVersion: string; + health: unknown; + toolIds: string[]; + tools: ToolDescription[]; + logs: string; + config: unknown; +} + +export interface MatrixEvidence { + schemaVersion: 1; + generatedAt: string; + candidate: { + filename: string; + sha256: string; + packageVersion: string; + }; + compatibility: { + currentStable: string; + oldestTested: string; + executedVersions: string[]; + deduplicated: boolean; + broaderRangeProven: false; + v2BetaExcluded: true; + }; + environment: { + node: string; + platform: string; + architecture: string; + cleanRoots: string[]; + providerInference: false; + }; + install: { + package: CommandResult; + currentResolution: CommandResult; + hosts: Array<{ version: string; command: CommandResult }>; + cliPlugins: Array<{ version: string; command: CommandResult }>; + }; + routes: ServerResult[]; + smoke: { + tool: "artifact_lifecycle"; + operation: "list"; + result: string; + filesystemUnchanged: boolean; + executionBoundary: "exact-packed-module"; + }; + result: "pass"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function boundedLog(previous: string, chunk: string, limit = MAX_LOG_BYTES): string { + const combined = previous + chunk; + if (Buffer.byteLength(combined, "utf8") <= limit) return combined; + const marker = "\n[earlier output truncated]\n"; + const keep = Math.max(0, limit - Buffer.byteLength(marker, "utf8")); + return marker + Buffer.from(combined, "utf8").subarray(-keep).toString("utf8"); +} + +export function exactStableMatrix(currentStable: string, oldestTested: string): { + versions: string[]; + deduplicated: boolean; +} { + const versions = [...new Set([currentStable, oldestTested])]; + return { versions, deduplicated: versions.length === 1 }; +} + +export function parseServerUrl(output: string): string | undefined { + return output.match(/opencode server listening on (http:\/\/127\.0\.0\.1:\d+)/)?.[1]; +} + +export function assertArtifactToolContract(ids: unknown, tools: unknown): asserts tools is ToolDescription[] { + if (!Array.isArray(ids) || !ids.every((item) => typeof item === "string")) { + throw new Error("host tool-ID response is not a string array"); + } + if (!Array.isArray(tools)) throw new Error("host tool-schema response is not an array"); + const byId = new Map(); + for (const value of tools) { + if (isRecord(value) && typeof value["id"] === "string") byId.set(value["id"], value as unknown as ToolDescription); + } + for (const [id, properties] of Object.entries(ARTIFACT_TOOL_CONTRACT)) { + if (!ids.includes(id)) throw new Error(`host discovery is missing ${id}`); + const tool = byId.get(id); + if (!tool) throw new Error(`host schemas are missing ${id}`); + const actual = tool.parameters?.properties; + if (!isRecord(actual)) throw new Error(`${id} parameters.properties is missing`); + for (const property of properties) { + if (!(property in actual)) throw new Error(`${id} schema is missing ${property}`); + } + } +} + +async function runCommand( + command: string, + args: string[], + options: { cwd: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }, +): Promise { + const timeoutMs = options.timeoutMs ?? 180_000; + return await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + const append = (chunk: Buffer): void => { output = boundedLog(output, chunk.toString("utf8")); }; + child.stdout.on("data", append); + child.stderr.on("data", append); + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`${command} timed out after ${timeoutMs}ms\n${output}`)); + }, timeoutMs); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + clearTimeout(timer); + const result = { command: [command, ...args], exitCode: code ?? -1, output }; + if (code !== 0) { + reject(new Error(`${command} exited ${String(code)}\n${output}`)); + return; + } + resolvePromise(result); + }); + }); +} + +function cleanEnvironment(root: string): NodeJS.ProcessEnv { + return { + XDG_CONFIG_HOME: join(root, "config"), + XDG_DATA_HOME: join(root, "data"), + XDG_CACHE_HOME: join(root, "cache"), + XDG_STATE_HOME: join(root, "state"), + }; +} + +async function fetchJson(url: string, timeoutMs = 120_000): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}: ${(await response.text()).slice(0, 4096)}`); + return await response.json() as unknown; +} + +async function waitForServer( + child: ChildProcessWithoutNullStreams, + initialLogs = "", + timeoutMs = 120_000, +): Promise<{ url: string; logs: () => string }> { + let logs = initialLogs; + return await new Promise((resolvePromise, reject) => { + let settled = false; + const append = (chunk: Buffer): void => { + logs = boundedLog(logs, chunk.toString("utf8")); + const url = parseServerUrl(logs); + if (!settled && url) { + settled = true; + clearTimeout(timer); + resolvePromise({ url, logs: () => logs }); + } + }; + child.stdout.on("data", append); + child.stderr.on("data", append); + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + reject(new Error(`OpenCode did not start within ${timeoutMs}ms\n${logs}`)); + }, timeoutMs); + child.on("exit", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new Error(`OpenCode exited ${String(code)} before startup\n${logs}`)); + }); + }); +} + +async function stopServer(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + await new Promise((resolvePromise) => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill("SIGKILL"); + resolvePromise(); + }, 5_000); + child.once("exit", () => { + clearTimeout(timer); + resolvePromise(); + }); + }); +} + +async function probeRoute(input: { + route: ServerResult["route"]; + hostVersion: string; + hostBinary: string; + project: string; + envRoot: string; + pluginUrl: string; + config: unknown; +}): Promise { + const env = { + ...cleanEnvironment(input.envRoot), + ...(input.route === "config-array" ? { OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: [input.pluginUrl] }) } : {}), + }; + await Promise.all(Object.values(cleanEnvironment(input.envRoot)).map((path) => mkdir(path, { recursive: true }))); + const child = spawn(input.hostBinary, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { + cwd: input.project, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + const server = await waitForServer(child); + try { + const health = await fetchJson(`${server.url}/global/health`); + if (!isRecord(health) || health["healthy"] !== true || health["version"] !== input.hostVersion) { + throw new Error(`unexpected health response: ${JSON.stringify(health)}`); + } + const ids = await fetchJson(`${server.url}/experimental/tool/ids`); + const tools = await fetchJson(`${server.url}/experimental/tool?provider=opencode&model=big-pickle`); + assertArtifactToolContract(ids, tools); + return { + route: input.route, + hostVersion: input.hostVersion, + health, + toolIds: ids as string[], + tools: (tools as ToolDescription[]).filter((item) => item.id.startsWith("artifact_")), + logs: server.logs(), + config: input.config, + }; + } finally { + await stopServer(child); + } +} + +async function readOnlyPackedSmoke(pluginDirectory: string, project: string): Promise { + const pluginModule = await import(pathToFileURL(join(pluginDirectory, "dist", "plugin.js")).href); + const schemaModule = await import(pathToFileURL(join(pluginDirectory, "dist", "artifact-schema.js")).href); + if (typeof pluginModule.default !== "function" || typeof schemaModule.emptyArtifactManifestV2 !== "function") { + throw new Error("packed plugin lifecycle exports are unavailable"); + } + const hooks = await pluginModule.default({}); + const lifecycle = hooks?.tool?.artifact_lifecycle; + if (!lifecycle || typeof lifecycle.execute !== "function") throw new Error("packed artifact_lifecycle tool is unavailable"); + const artifactRoot = join(project, ".opencode", "artifacts"); + await mkdir(artifactRoot, { recursive: true }); + const manifestPath = join(artifactRoot, "manifest.json"); + const manifest = `${JSON.stringify(schemaModule.emptyArtifactManifestV2(), null, 2)}\n`; + await writeFile(manifestPath, manifest, "utf8"); + const result = String(await lifecycle.execute({ op: "list" }, { + sessionID: "packed-host-smoke", + messageID: "packed-host-smoke", + agent: "compatibility", + directory: project, + worktree: project, + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => { throw new Error("read-only smoke requested permission"); }, + })); + const after = await readFile(manifestPath, "utf8"); + if (!result.includes('"artifacts": []')) throw new Error(`unexpected lifecycle list smoke: ${result}`); + if (after !== manifest) throw new Error("read-only lifecycle smoke changed the manifest"); + return { + tool: "artifact_lifecycle", + operation: "list", + result, + filesystemUnchanged: true, + executionBoundary: "exact-packed-module", + }; +} + +function requiredArgument(name: string): string { + const index = process.argv.indexOf(name); + const value = index === -1 ? undefined : process.argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} + +async function requestedTarball(): Promise { + const directIndex = process.argv.indexOf("--tarball"); + const direct = directIndex === -1 ? undefined : process.argv[directIndex + 1]; + if (direct && !direct.startsWith("--")) return direct; + const packJson = requiredArgument("--pack-json"); + const parsed = JSON.parse(await readFile(resolve(packJson), "utf8")) as unknown; + if (!Array.isArray(parsed) || !isRecord(parsed[0]) || typeof parsed[0]["filename"] !== "string") { + throw new Error("--pack-json must contain npm pack JSON with a filename"); + } + return parsed[0]["filename"]; +} + +export async function runMatrix(tarballInput: string, outputInput: string): Promise { + const tarball = resolve(tarballInput); + const output = resolve(outputInput); + const work = await mkdtemp(join(tmpdir(), "opencode-host-matrix-")); + try { + const packageRoot = join(work, "package"); + await mkdir(packageRoot, { recursive: true }); + const packageInstall = await runCommand("npm", ["install", "--prefix", packageRoot, "--ignore-scripts", "--no-audit", "--no-fund", tarball], { cwd: work }); + const currentResolution = await runCommand("npm", ["view", "opencode-ai", "version", "--json"], { cwd: work }); + const currentStable = JSON.parse(currentResolution.output.trim()) as unknown; + if (typeof currentStable !== "string" || !/^\d+\.\d+\.\d+$/.test(currentStable)) { + throw new Error(`npm resolved a non-stable OpenCode version: ${currentResolution.output}`); + } + const pluginDirectory = join(packageRoot, "node_modules", "opencode-artifacts"); + const manifest = JSON.parse(await readFile(join(pluginDirectory, "package.json"), "utf8")) as { version?: unknown }; + if (typeof manifest.version !== "string") throw new Error("packed package version is missing"); + const pluginUrl = pathToFileURL(pluginDirectory).href; + const matrix = exactStableMatrix(currentStable, OLDEST_TESTED_OPENCODE_VERSION); + const hosts: Array<{ version: string; command: CommandResult }> = []; + const cliPlugins: Array<{ version: string; command: CommandResult }> = []; + const routes: ServerResult[] = []; + for (const version of matrix.versions) { + const versionKey = version.replaceAll(".", "-"); + const hostRoot = join(work, `host-${versionKey}`); + const cliProject = join(work, `cli-project-${versionKey}`); + const configProject = join(work, `config-project-${versionKey}`); + await Promise.all([hostRoot, cliProject, configProject].map((path) => mkdir(path, { recursive: true }))); + const hostInstall = await runCommand("npm", ["install", "--prefix", hostRoot, "--no-audit", "--no-fund", `opencode-ai@${version}`], { cwd: work }); + hosts.push({ version, command: hostInstall }); + const hostBinary = join(hostRoot, "node_modules", ".bin", process.platform === "win32" ? "opencode.cmd" : "opencode"); + const cliEnvRoot = join(work, `cli-env-${versionKey}`); + await Promise.all(Object.values(cleanEnvironment(cliEnvRoot)).map((path) => mkdir(path, { recursive: true }))); + const cliPlugin = await runCommand(hostBinary, ["plugin", pluginUrl], { cwd: cliProject, env: cleanEnvironment(cliEnvRoot) }); + cliPlugins.push({ version, command: cliPlugin }); + const cliConfigPath = join(cliProject, ".opencode", "opencode.json"); + const cliConfig = JSON.parse(await readFile(cliConfigPath, "utf8")) as unknown; + routes.push( + await probeRoute({ route: "cli-install", hostVersion: version, hostBinary, project: cliProject, envRoot: cliEnvRoot, pluginUrl, config: cliConfig }), + await probeRoute({ route: "config-array", hostVersion: version, hostBinary, project: configProject, envRoot: join(work, `config-env-${versionKey}`), pluginUrl, config: { plugin: [pluginUrl] } }), + ); + } + const smoke = await readOnlyPackedSmoke(pluginDirectory, join(work, "smoke-project")); + const evidence: MatrixEvidence = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + candidate: { + filename: basename(tarball), + sha256: createHash("sha256").update(await readFile(tarball)).digest("hex"), + packageVersion: manifest.version, + }, + compatibility: { + currentStable, + oldestTested: OLDEST_TESTED_OPENCODE_VERSION, + executedVersions: matrix.versions, + deduplicated: matrix.deduplicated, + broaderRangeProven: false, + v2BetaExcluded: true, + }, + environment: { + node: process.version, + platform: process.platform, + architecture: process.arch, + cleanRoots: ["config", "data", "cache", "state"], + providerInference: false, + }, + install: { package: packageInstall, currentResolution, hosts, cliPlugins }, + routes, + smoke, + result: "pass", + }; + await mkdir(resolve(output, ".."), { recursive: true }); + await writeFile(output, `${JSON.stringify(evidence, null, 2)}\n`, "utf8"); + return evidence; + } finally { + await rm(work, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const evidence = await runMatrix(await requestedTarball(), requiredArgument("--output")); + console.log(`packed OpenCode host matrix: ${evidence.result} (${evidence.compatibility.executedVersions.join(", ")})`); + } catch (error) { + console.error(error instanceof Error ? error.stack ?? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/specs/changes/packed-opencode-host-matrix/change.json b/specs/changes/packed-opencode-host-matrix/change.json index 810a8de..4a979f7 100644 --- a/specs/changes/packed-opencode-host-matrix/change.json +++ b/specs/changes/packed-opencode-host-matrix/change.json @@ -6,7 +6,7 @@ "status": "approved", "affectedRequirements": ["OC-01", "OC-02", "OC-03", "OC-04", "QUAL-03"], "currentSpecs": ["specs/current/opencode-integration.spec.md"], - "currentSpecsUpdated": false, + "currentSpecsUpdated": true, "approval": { "by": "aaron.zeng", "at": "2026-08-18T01:48:52Z" diff --git a/specs/changes/packed-opencode-host-matrix/evidence.md b/specs/changes/packed-opencode-host-matrix/evidence.md index 098f2d0..94b1ecd 100644 --- a/specs/changes/packed-opencode-host-matrix/evidence.md +++ b/specs/changes/packed-opencode-host-matrix/evidence.md @@ -6,30 +6,37 @@ Do not hide failed or excluded results. ## Requirement: OC-01 - Validation: clean-host discovery exposes the tools a user installed. -- Verification: planned packed stable-host discovery and schema assertions. -- Result: pending implementation. +- Verification: exact 0.15.0 candidate tarball installed into clean roots; stable 1.18.18 live + discovery returned all five tools and their documented schemas through both entry routes. +- Result: pass on 2026-08-18; final Goal 4 candidate rerun remains required before archive. - Evidence: [@manual](docs/evidence/opencode-host-verification.md) ## Requirement: OC-02 - Validation: both official install entry paths produce a loadable plugin. -- Verification: planned isolated CLI-install and direct-config cells. -- Result: pending implementation. +- Verification: isolated official CLI configuration mutation and direct config-array cells + loaded the same extracted exact tarball. Direct `.tgz` module input was retained as a failed + assumption rather than claimed as an install route. +- Result: pass for candidate-directory and config routes; bare future registry coordinates + remain a post-publication boundary. - Evidence: [@manual](docs/evidence/opencode-host-verification.md) ## Requirement: OC-03 - Validation: CI tests shipped bytes rather than workspace resolution. -- Verification: planned exact-tarball workflow and retained output. -- Result: pending implementation. +- Verification: CI now feeds the one `npm pack` filename into the bounded host harness and + uploads its JSON beside the tarball. +- Result: pass in the dated VPS rehearsal; required CI wiring is present. - Evidence: [@test](.github/workflows/ci.yml) ## Requirement: OC-04 - Validation: compatibility claims name only identically tested host versions. -- Verification: planned exact current/oldest resolver and policy assertions. -- Result: pending implementation. +- Verification: policy and tests name 1.18.18 as both exact current stable and oldest-tested, + execute one deduplicated cell, and narrow the SDK peer to 1.18.18. +- Result: pass; broader 1.x and V2 beta compatibility are explicitly unproved. - Evidence: [@test](test/governance-policy.test.ts) ## Requirement: QUAL-03 - Validation: the matrix exercises the real stable host boundary users receive. -- Verification: planned clean config/cache, discovery, schema, and smoke trace. -- Result: pending implementation. +- Verification: separate empty XDG roots, exact digest/version, live health and schemas, bounded + logs, and a byte-unchanged packed lifecycle-list smoke were recorded without inference. +- Result: pass on the initial Goal 4 candidate; exact final-byte rerun remains required. - Evidence: [@manual](docs/evidence/opencode-host-verification.md) diff --git a/specs/changes/packed-opencode-host-matrix/tasks.md b/specs/changes/packed-opencode-host-matrix/tasks.md index 627b5f8..a128c3b 100644 --- a/specs/changes/packed-opencode-host-matrix/tasks.md +++ b/specs/changes/packed-opencode-host-matrix/tasks.md @@ -1,10 +1,10 @@ # Tasks: Verify packed OpenCode host compatibility -- [ ] Confirm proposal validation and human approval. -- [ ] Add a deterministic packed-host harness with bounded logs and injectable host commands. -- [ ] Exercise exact tarball CLI-install and config-array discovery in clean roots. -- [ ] Assert tool IDs/schemas and safe read-only lifecycle smoke on the exact stable host. -- [ ] Narrow compatibility metadata/policy to the exact current and oldest-tested releases. -- [ ] Add the packed-host cell to CI and retain exact package/host/config/output evidence. -- [ ] Record failures/exclusions and update `specs/current/opencode-integration.spec.md`. +- [x] Confirm proposal validation and human approval. +- [x] Add a deterministic packed-host harness with bounded logs and injectable host commands. +- [x] Exercise exact tarball CLI-install and config-array discovery in clean roots. +- [x] Assert tool IDs/schemas and safe read-only lifecycle smoke on the exact stable host. +- [x] Narrow compatibility metadata/policy to the exact current and oldest-tested releases. +- [x] Add the packed-host cell to CI and retain exact package/host/config/output evidence. +- [x] Record failures/exclusions and update `specs/current/opencode-integration.spec.md`. - [ ] Run repository validation and archive the packet. diff --git a/specs/current/opencode-integration.spec.md b/specs/current/opencode-integration.spec.md new file mode 100644 index 0000000..333e6a8 --- /dev/null +++ b/specs/current/opencode-integration.spec.md @@ -0,0 +1,31 @@ +# Native OpenCode integration + +## Current behavior + +- `OC-01`: the exact npm tarball loads through stable OpenCode and registers + `artifact_publish`, `artifact_lifecycle`, `artifact_db`, `artifact_state`, and + `artifact_comments` with their shipped argument schemas. +- `OC-02`: the primary published-package command is `opencode plugin opencode-artifacts`. + Candidate-byte verification first installs the tarball into an empty package prefix and + passes that extracted package URL to the same official plugin command. Direct `plugin` + configuration is tested separately. A checkout `file:` URL remains development-only. +- `OC-03`: CI packs once, installs those bytes with candidate lifecycle scripts disabled, + installs the exact pinned stable host, and retains health, config, discovery, schemas, + smoke output, bounded logs, versions, and package digest. +- `OC-04`: current stable and oldest-tested are initially the same exact OpenCode 1.18.18 + cell. The peer SDK claim is exact `@opencode-ai/plugin@1.18.18`; no wider 1.x or V2 beta + compatibility is implied. +- `QUAL-03`: each host route uses empty config/data/cache/state roots and a loopback server. + Tool discovery requests schema metadata only and performs no provider inference. A packed- + module `artifact_lifecycle list` smoke is non-mutating and separately proves executable + shipped code without asking a model to select the tool. + +## Evidence boundary + +- `scripts/opencode-host-matrix.ts` is the executable packed-host contract and + `.github/workflows/ci.yml` retains its JSON output with exact candidate evidence. +- `docs/evidence/opencode-host-verification.md` records the dated manual run, including the + rejected direct-tarball assumption and the exact successful install boundary. +- A registry-coordinate check for an unpublished candidate is impossible before publication. + The bare published-package route remains post-publication evidence; it is not substituted + for the exact pre-publication tarball. diff --git a/test/governance-policy.test.ts b/test/governance-policy.test.ts index a7e98cf..96f08e9 100644 --- a/test/governance-policy.test.ts +++ b/test/governance-policy.test.ts @@ -28,7 +28,9 @@ test("checked-in governance policy is complete and claim-consistent", async () = test("support claims cannot pass without exact dated evidence", () => { const changed = structuredClone(policy); const cells = changed["supportCells"] as Array>; - cells[1]["status"] = "supported"; + const target = cells.find((cell) => cell["id"] === "ubuntu-lts-desktop"); + assert.ok(target); + target["status"] = "supported"; const errors = validateGovernancePolicy(changed).join("\n"); assert.match(errors, /cannot be supported without dated evidence/); }); @@ -58,6 +60,17 @@ test("Goal 2 optional platform evidence never becomes support or default enablem assert.ok((policy["supportCells"] as Array>).every((cell) => cell["status"] !== "supported")); }); +test("OpenCode compatibility names only the exact packed stable host cell", () => { + const cell = (policy["supportCells"] as Array>) + .find((item) => item["id"] === "packed-opencode-stable"); + assert.ok(cell); + assert.equal(cell["status"], "tested"); + assert.match(String(cell["opencode"]), /1\.18\.18/); + assert.match(String(cell["scope"]), /Exact candidate tarball/); + assert.doesNotMatch(String(cell["opencode"]), /V2|1\.x/); + assert.equal(policy["reviewedAt"], "2026-08-18"); +}); + test("inflated README provenance and mismatched Node claims fail consistency", () => { const errors = validateGovernanceClaims({ readme: "with provenance attestations", diff --git a/test/opencode-host-matrix.test.ts b/test/opencode-host-matrix.test.ts new file mode 100644 index 0000000..3c140f9 --- /dev/null +++ b/test/opencode-host-matrix.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ARTIFACT_TOOL_CONTRACT, + assertArtifactToolContract, + boundedLog, + exactStableMatrix, + parseServerUrl, +} from "../scripts/opencode-host-matrix.ts"; + +test("stable host matrix deduplicates identical exact current and oldest cells", () => { + assert.deepEqual(exactStableMatrix("1.18.18", "1.18.18"), { + versions: ["1.18.18"], + deduplicated: true, + }); + assert.deepEqual(exactStableMatrix("1.19.0", "1.18.18"), { + versions: ["1.19.0", "1.18.18"], + deduplicated: false, + }); +}); + +test("host discovery requires every shipped tool and documented property", () => { + const ids = Object.keys(ARTIFACT_TOOL_CONTRACT); + const tools = Object.entries(ARTIFACT_TOOL_CONTRACT).map(([id, properties]) => ({ + id, + parameters: { properties: Object.fromEntries(properties.map((property) => [property, {}])) }, + })); + assert.doesNotThrow(() => assertArtifactToolContract(ids, tools)); + assert.throws( + () => assertArtifactToolContract(ids.filter((id) => id !== "artifact_lifecycle"), tools), + /missing artifact_lifecycle/, + ); + const missingSchema = structuredClone(tools); + const publish = missingSchema.find((item) => item.id === "artifact_publish"); + assert.ok(publish); + delete publish.parameters.properties.markdown; + assert.throws(() => assertArtifactToolContract(ids, missingSchema), /artifact_publish schema is missing markdown/); +}); + +test("host startup parsing is loopback-only and logs stay bounded", () => { + assert.equal( + parseServerUrl("notice\nopencode server listening on http://127.0.0.1:49123\n"), + "http://127.0.0.1:49123", + ); + assert.equal(parseServerUrl("opencode server listening on http://0.0.0.0:49123"), undefined); + const output = boundedLog("old", "x".repeat(200), 64); + assert.ok(Buffer.byteLength(output, "utf8") <= 64); + assert.match(output, /earlier output truncated/); +}); From 3765295fe8cd815f0c016d8d0771e1e44a93c1b8 Mon Sep 17 00:00:00 2001 From: bitgorust Date: Tue, 18 Aug 2026 04:26:19 +0200 Subject: [PATCH 4/8] feat: separate OpenCode publish permissions --- docs/evidence/opencode-host-verification.md | 26 ++++ scripts/opencode-host-matrix.ts | 23 ++- .../opencode-permission-contracts/change.json | 2 +- .../opencode-permission-contracts/evidence.md | 15 +- .../opencode-permission-contracts/tasks.md | 12 +- specs/current/opencode-integration.spec.md | 14 ++ src/opencode-permissions.ts | 142 ++++++++++++++++++ src/plugin.ts | 51 +++++-- test/model/opencode-permission-model.ts | 37 +++++ test/opencode-host-matrix.test.ts | 9 ++ test/opencode-permission-model.test.ts | 36 +++++ test/opencode-permissions.test.ts | 51 +++++++ test/plugin.test.ts | 45 ++++++ 13 files changed, 438 insertions(+), 25 deletions(-) create mode 100644 src/opencode-permissions.ts create mode 100644 test/model/opencode-permission-model.ts create mode 100644 test/opencode-permission-model.test.ts create mode 100644 test/opencode-permissions.test.ts diff --git a/docs/evidence/opencode-host-verification.md b/docs/evidence/opencode-host-verification.md index c22c35f..ce4986b 100644 --- a/docs/evidence/opencode-host-verification.md +++ b/docs/evidence/opencode-host-verification.md @@ -123,3 +123,29 @@ record as `release-evidence/opencode-host-matrix.json` and retains it with the e The `/tmp` path is transient and is not itself release evidence. Bare registry-coordinate verification of future candidate bytes remains a post-publication gate; the dated published- package probe above covers the already published route only. + +### Stable permission policy probe + +An additional isolated OpenCode 1.18.18 server loaded this inline policy without a plugin or +provider turn: + +```json +{ + "permission": { + "*": "allow", + "artifact_publish": "ask", + "artifact_datasource": "ask", + "artifact_deploy": "deny", + "artifact_audience": "deny" + } +} +``` + +`GET /config` returned those five entries unchanged. This proves the stable host accepts the +four independent resources and preserves explicit deploy/audience denies beneath broad auto +allow. It does not claim a provider-selected tool execution: doing that through the native +server requires a provider turn, which this gate deliberately excludes. The exact ask order, +denial at every transition, no-write/no-runner result, bounded metadata, and auto-deny property +are exercised by `test/plugin.test.ts`, `test/opencode-permissions.test.ts`, and the bounded +model in `test/model/opencode-permission-model.ts`. CI repeats the effective-config assertion +inside the packed config-array cell. diff --git a/scripts/opencode-host-matrix.ts b/scripts/opencode-host-matrix.ts index d07e574..b1192bf 100644 --- a/scripts/opencode-host-matrix.ts +++ b/scripts/opencode-host-matrix.ts @@ -16,6 +16,13 @@ export const ARTIFACT_TOOL_CONTRACT = { artifact_state: ["slug"], artifact_comments: ["slug", "resolveId", "digest", "expectedRevision", "expectedHash", "operationId"], } as const; +export const OPENCODE_PERMISSION_POLICY = { + "*": "allow", + artifact_publish: "ask", + artifact_datasource: "ask", + artifact_deploy: "deny", + artifact_audience: "deny", +} as const; interface CommandResult { command: string[]; @@ -39,6 +46,7 @@ interface ServerResult { tools: ToolDescription[]; logs: string; config: unknown; + effectivePermission?: unknown; } export interface MatrixEvidence { @@ -237,7 +245,7 @@ async function probeRoute(input: { }): Promise { const env = { ...cleanEnvironment(input.envRoot), - ...(input.route === "config-array" ? { OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: [input.pluginUrl] }) } : {}), + ...(input.route === "config-array" ? { OPENCODE_CONFIG_CONTENT: JSON.stringify(input.config) } : {}), }; await Promise.all(Object.values(cleanEnvironment(input.envRoot)).map((path) => mkdir(path, { recursive: true }))); const child = spawn(input.hostBinary, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { @@ -254,6 +262,16 @@ async function probeRoute(input: { const ids = await fetchJson(`${server.url}/experimental/tool/ids`); const tools = await fetchJson(`${server.url}/experimental/tool?provider=opencode&model=big-pickle`); assertArtifactToolContract(ids, tools); + const effectiveConfig = await fetchJson(`${server.url}/config`); + const effectivePermission = isRecord(effectiveConfig) ? effectiveConfig["permission"] : undefined; + if (input.route === "config-array") { + if (!isRecord(effectivePermission)) throw new Error("stable host omitted configured artifact permissions"); + for (const [permission, decision] of Object.entries(OPENCODE_PERMISSION_POLICY)) { + if (effectivePermission[permission] !== decision) { + throw new Error(`stable host changed ${permission} permission from ${decision}`); + } + } + } return { route: input.route, hostVersion: input.hostVersion, @@ -262,6 +280,7 @@ async function probeRoute(input: { tools: (tools as ToolDescription[]).filter((item) => item.id.startsWith("artifact_")), logs: server.logs(), config: input.config, + ...(effectivePermission === undefined ? {} : { effectivePermission }), }; } finally { await stopServer(child); @@ -361,7 +380,7 @@ export async function runMatrix(tarballInput: string, outputInput: string): Prom const cliConfig = JSON.parse(await readFile(cliConfigPath, "utf8")) as unknown; routes.push( await probeRoute({ route: "cli-install", hostVersion: version, hostBinary, project: cliProject, envRoot: cliEnvRoot, pluginUrl, config: cliConfig }), - await probeRoute({ route: "config-array", hostVersion: version, hostBinary, project: configProject, envRoot: join(work, `config-env-${versionKey}`), pluginUrl, config: { plugin: [pluginUrl] } }), + await probeRoute({ route: "config-array", hostVersion: version, hostBinary, project: configProject, envRoot: join(work, `config-env-${versionKey}`), pluginUrl, config: { plugin: [pluginUrl], permission: OPENCODE_PERMISSION_POLICY } }), ); } const smoke = await readOnlyPackedSmoke(pluginDirectory, join(work, "smoke-project")); diff --git a/specs/changes/opencode-permission-contracts/change.json b/specs/changes/opencode-permission-contracts/change.json index d9529a6..71ae1fe 100644 --- a/specs/changes/opencode-permission-contracts/change.json +++ b/specs/changes/opencode-permission-contracts/change.json @@ -6,7 +6,7 @@ "status": "approved", "affectedRequirements": ["UX-03", "OC-06"], "currentSpecs": ["specs/current/opencode-integration.spec.md"], - "currentSpecsUpdated": false, + "currentSpecsUpdated": true, "approval": { "by": "aaron.zeng", "at": "2026-08-18T01:48:52Z" diff --git a/specs/changes/opencode-permission-contracts/evidence.md b/specs/changes/opencode-permission-contracts/evidence.md index acf385e..50dc414 100644 --- a/specs/changes/opencode-permission-contracts/evidence.md +++ b/specs/changes/opencode-permission-contracts/evidence.md @@ -6,12 +6,17 @@ Do not hide failed or excluded results. ## Requirement: UX-03 - Validation: each audience/datasource/side-effect boundary is separately understandable. -- Verification: planned transition model plus denial/no-mutation integration tests. -- Result: pending implementation. -- Evidence: [@test](test/plugin.test.ts) +- Verification: the bounded model exhausts requested-scope subsets and denial positions; + integration tests deny each live `ctx.ask` transition and assert no artifact/datasource file. +- Result: pass; elevated grants have empty remembered scope and prompt metadata excludes + content, arguments, and full executable paths. +- Evidence: [@model](test/model/opencode-permission-model.ts), [@test](test/plugin.test.ts) ## Requirement: OC-06 - Validation: stable OpenCode permission policy can control each artifact authority. -- Verification: planned packed-host allow/ask/deny/auto matrix and exact prompt trace. -- Result: pending implementation. +- Verification: stable 1.18.18 retained the four named rules and explicit deploy/audience deny + beneath wildcard auto allow; CI repeats this on packed config discovery. Injected asks prove + enforcement because provider-selected native execution is excluded from this gate. +- Result: pass for stable policy parsing, precedence model, and plugin enforcement without a + real deployment or provider call. - Evidence: [@manual](docs/evidence/opencode-host-verification.md) diff --git a/specs/changes/opencode-permission-contracts/tasks.md b/specs/changes/opencode-permission-contracts/tasks.md index 46b13f9..bf0d34e 100644 --- a/specs/changes/opencode-permission-contracts/tasks.md +++ b/specs/changes/opencode-permission-contracts/tasks.md @@ -1,9 +1,9 @@ # Tasks: Separate OpenCode artifact permissions -- [ ] Confirm proposal validation and human approval. -- [ ] Add a permission state/property model covering every requested-scope subset and denial. -- [ ] Add injectable no-write/no-runner tests for ask order, abort, and bounded metadata. -- [ ] Implement separate local-write, datasource, deploy, and audience resources. -- [ ] Verify stable-host allow/ask/deny and auto mode without a real deployment. -- [ ] Record exact permission traces and update `specs/current/opencode-integration.spec.md`. +- [x] Confirm proposal validation and human approval. +- [x] Add a permission state/property model covering every requested-scope subset and denial. +- [x] Add injectable no-write/no-runner tests for ask order, abort, and bounded metadata. +- [x] Implement separate local-write, datasource, deploy, and audience resources. +- [x] Verify stable-host allow/ask/deny and auto mode without a real deployment. +- [x] Record exact permission traces and update `specs/current/opencode-integration.spec.md`. - [ ] Run repository validation and archive the packet. diff --git a/specs/current/opencode-integration.spec.md b/specs/current/opencode-integration.spec.md index 333e6a8..ea50ff7 100644 --- a/specs/current/opencode-integration.spec.md +++ b/specs/current/opencode-integration.spec.md @@ -19,6 +19,16 @@ Tool discovery requests schema metadata only and performs no provider inference. A packed- module `artifact_lifecycle list` smoke is non-mutating and separately proves executable shipped code without asking a model to select the tool. +- `UX-03`: `artifact_publish` completes final-byte validation, then asks for every requested + authority before its first write or provider call. The order is exact local publication, + optional datasource execution, optional provider deployment, then optional public audience. + A refusal at any point returns `permission-denied` with `mutation: none`. +- `OC-06`: stable permission resources are `artifact_publish`, `artifact_datasource`, + `artifact_deploy`, and `artifact_audience`. Local remembered scope is bound to one hashed + artifact key; elevated scopes use `always: []`. Metadata is bounded and omits authored + Markdown, datasource arguments, full executable paths, credentials, and provider output. + Stable OpenCode 1.18.18 preserves exact `allow`/`ask`/`deny` rules, including explicit deploy + and audience deny entries beneath a broad auto-allow wildcard. ## Evidence boundary @@ -29,3 +39,7 @@ - A registry-coordinate check for an unpublished candidate is impossible before publication. The bare published-package route remains post-publication evidence; it is not substituted for the exact pre-publication tarball. +- `test/model/opencode-permission-model.ts` exhausts optional-scope subsets and denial + transitions. Stable-host evidence confirms policy parsing and exact precedence without + provider inference; tool-selection enforcement is covered by injected `ctx.ask` integration + because native tool execution otherwise requires a provider turn. diff --git a/src/opencode-permissions.ts b/src/opencode-permissions.ts new file mode 100644 index 0000000..2f96f18 --- /dev/null +++ b/src/opencode-permissions.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import { basename } from "node:path"; +import type { ToolContext } from "@opencode-ai/plugin"; + +export type ArtifactPermission = + | "artifact_publish" + | "artifact_datasource" + | "artifact_deploy" + | "artifact_audience"; + +export interface DataSourceAuthority { + name: string; + command: string; + args?: string[]; +} + +export interface DeployAuthority { + target: "github" | "cloudflare"; + coordinate: string; +} + +export interface PublishAuthorityInput { + slug: string; + format: "markdown" | "html"; + trustedHtml: boolean; + dataSources?: DataSourceAuthority[]; + deploy?: DeployAuthority; +} + +export interface ArtifactPermissionRequest { + permission: ArtifactPermission; + patterns: string[]; + always: string[]; + metadata: Record; +} + +export class ArtifactPermissionDeniedError extends Error { + readonly permission: ArtifactPermission; + + constructor(permission: ArtifactPermission) { + super(`${permission} was not approved`); + this.name = "ArtifactPermissionDeniedError"; + this.permission = permission; + } +} + +function digest(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 16); +} + +export function artifactPermissionKey(slug: string): string { + return `${slug.slice(0, 48)}:${digest(slug)}`; +} + +function validateDataSources(sources: DataSourceAuthority[]): void { + if (sources.length > 32) throw new Error("dataSources supports at most 32 registered sources"); + const names = new Set(); + for (const source of sources) { + if (!/^[a-z0-9-]{1,64}$/.test(source.name)) { + throw new Error("datasource names must be 1-64 lowercase letters, digits, or hyphens"); + } + if (names.has(source.name)) throw new Error(`datasource name '${source.name}' is duplicated`); + names.add(source.name); + const executable = basename(source.command); + if (source.command.trim() === "" || executable === "" || executable.length > 128) { + throw new Error(`datasource '${source.name}' has an invalid executable`); + } + } +} + +function validateDeploy(deploy: DeployAuthority): void { + if (deploy.coordinate.length === 0 || deploy.coordinate.length > 220) { + throw new Error("deploy coordinate must contain 1-220 characters"); + } + if (!/^[A-Za-z0-9._/@:+-]+$/.test(deploy.coordinate)) { + throw new Error("deploy coordinate contains unsupported characters"); + } +} + +export function publishPermissionRequests(input: PublishAuthorityInput): ArtifactPermissionRequest[] { + const artifact = artifactPermissionKey(input.slug); + const requests: ArtifactPermissionRequest[] = [{ + permission: "artifact_publish", + patterns: [artifact], + always: [artifact], + metadata: { artifact, format: input.format, trustedHtml: input.trustedHtml }, + }]; + if (input.dataSources && input.dataSources.length > 0) { + validateDataSources(input.dataSources); + const names = input.dataSources.map((source) => source.name); + const executables = input.dataSources.map((source) => basename(source.command)); + requests.push({ + permission: "artifact_datasource", + patterns: names.map((name) => `${artifact}:${name}`), + always: [], + metadata: { artifact, capability: "datasource-execution", names, executables }, + }); + } + if (input.deploy) { + validateDeploy(input.deploy); + const coordinateKey = `${input.deploy.target}:${digest(input.deploy.coordinate)}`; + requests.push({ + permission: "artifact_deploy", + patterns: [`${artifact}:${coordinateKey}`], + always: [], + metadata: { + artifact, + capability: "provider-deploy", + target: input.deploy.target, + coordinate: input.deploy.coordinate, + }, + }); + requests.push({ + permission: "artifact_audience", + patterns: [`${artifact}:public-static:${coordinateKey}`], + always: [], + metadata: { + artifact, + capability: "public-static", + target: input.deploy.target, + coordinate: input.deploy.coordinate, + visibility: "public", + }, + }); + } + return requests; +} + +export async function approvePublishPermissions( + ctx: Pick, + input: PublishAuthorityInput, +): Promise { + const requests = publishPermissionRequests(input); + for (const request of requests) { + try { + await ctx.ask(request); + } catch { + throw new ArtifactPermissionDeniedError(request.permission); + } + } + return requests; +} diff --git a/src/plugin.ts b/src/plugin.ts index 5bd407a..d26b6c9 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -39,6 +39,10 @@ import { ArtifactLifecycleConflictError, ArtifactLifecycleStore, } from "./artifact-lifecycle.ts"; +import { + approvePublishPermissions, + ArtifactPermissionDeniedError, +} from "./opencode-permissions.ts"; function ghPagesCloneDir(repo: string): string { return join(homedir(), ".cache", "opencode-artifacts", "ghpages", repo.replace("/", "__")); @@ -219,12 +223,31 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { if (finalFindings.length > 0 && args.force !== true) { return `Publish blocked: the final portable bytes contain credential-looking strings: ${formatFindings(finalFindings)}. If these are intentional, call again with force: true.`; } + const plannedDeploy = await (async () => { + if (!args.deploy) return undefined; + const config = await loadConfig(workRoot(ctx)); + const resolved = resolveDeploy( + { repo: args.repo, target: args.target, workerName: args.workerName }, + config, + ); + if (resolved.target === "github") { + if (!resolved.repo) { + throw new Error("deploy target 'github' is missing its repo — run `opencode-artifacts init`"); + } + return { resolved, authority: { target: "github" as const, coordinate: `${resolved.repo}@${resolved.branch ?? "main"}` } }; + } + if (!resolved.workerName) { + throw new Error("deploy target 'cloudflare' is missing its workerName — run `opencode-artifacts init`"); + } + return { resolved, authority: { target: "cloudflare" as const, coordinate: resolved.workerName } }; + })(); - await ctx.ask({ - permission: "artifact_publish", - patterns: [slug], - always: ["*"], - metadata: { title, slug, format: args.format ?? "markdown", trustedHtml: args.format === "html" }, + await approvePublishPermissions(ctx, { + slug, + format: args.format ?? "markdown", + trustedHtml: args.format === "html", + ...(args.dataSources === undefined ? {} : { dataSources: args.dataSources }), + ...(plannedDeploy === undefined ? {} : { deploy: plannedDeploy.authority }), }); const localDir = join(workRoot(ctx), ".opencode", "artifacts"); @@ -246,9 +269,8 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { inputFormat: args.format ?? "markdown", }); let url: string | undefined; - if (args.deploy) { - const config = await loadConfig(workRoot(ctx)); - const resolved = resolveDeploy({ repo: args.repo, target: args.target, workerName: args.workerName }, config); + if (plannedDeploy) { + const resolved = plannedDeploy.resolved; if (resolved.target === "github" && resolved.repo) { const adapter = new GitHubPagesPublisher(localDir, { repo: resolved.repo, branch: resolved.branch, cloneDir: ghPagesCloneDir(resolved.repo), allowSensitive: args.force === true }); const base = await adapter.sync(`publish ${slug} v${status.headRevision}`); @@ -268,9 +290,8 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { } else { if (args.artifact !== undefined || args.expectedRevision !== undefined) throw new Error("artifact and expectedRevision require a migrated schema-2 store"); const publisher = await (async () => { - if (!args.deploy) return new FilePublisher(localDir); - const config = await loadConfig(workRoot(ctx)); - const resolved = resolveDeploy({ repo: args.repo, target: args.target, workerName: args.workerName }, config); + if (!plannedDeploy) return new FilePublisher(localDir); + const resolved = plannedDeploy.resolved; if (resolved.target === "github" && resolved.repo) return new GitHubPagesPublisher(localDir, { repo: resolved.repo, branch: resolved.branch, cloneDir: ghPagesCloneDir(resolved.repo), allowSensitive: args.force === true }); if (resolved.target === "cloudflare" && resolved.workerName) return new CloudflarePublisher(localDir, { workerName: resolved.workerName, stagingDir: cfStagingDir(resolved.workerName), allowSensitive: args.force === true }); throw new Error(`deploy target '${resolved.target}' is missing its ${resolved.target === "github" ? "repo" : "workerName"} — run \`opencode-artifacts init\``); @@ -300,6 +321,14 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { const warning = preflightWarnings.length === 0 ? "" : `\nPreflight warnings: ${preflightWarnings.map((item) => `${item.code} at ${item.line}:${item.column}`).join(", ")}`; return `Artifact published to ${result.path}${result.url ? ` — live at ${result.url}` : ""} (gallery: ${result.gallery}, hash: ${result.hash})${warning}`; } catch (err) { + if (err instanceof ArtifactPermissionDeniedError) { + return JSON.stringify({ + error: "permission-denied", + permission: err.permission, + mutation: "none", + nextAction: `allow or ask for ${err.permission} at the exact requested scope, then retry`, + }, null, 2); + } if (err instanceof ArtifactTooLargeError) { return `Artifact too large: ${err.message}`; } diff --git a/test/model/opencode-permission-model.ts b/test/model/opencode-permission-model.ts new file mode 100644 index 0000000..9b19130 --- /dev/null +++ b/test/model/opencode-permission-model.ts @@ -0,0 +1,37 @@ +export const PERMISSION_ORDER = [ + "artifact_publish", + "artifact_datasource", + "artifact_deploy", + "artifact_audience", +] as const; + +export type ModelPermission = typeof PERMISSION_ORDER[number]; +export type ModelDecision = "allow" | "ask-approved" | "deny"; + +export interface PermissionModelInput { + datasource: boolean; + deploy: boolean; + decisions: Partial>; + autoAllow?: boolean; +} + +export interface PermissionModelResult { + requested: ModelPermission[]; + reached: ModelPermission[]; + denied?: ModelPermission; + canMutate: boolean; +} + +export function evaluatePermissionModel(input: PermissionModelInput): PermissionModelResult { + const requested: ModelPermission[] = ["artifact_publish"]; + if (input.datasource) requested.push("artifact_datasource"); + if (input.deploy) requested.push("artifact_deploy", "artifact_audience"); + const reached: ModelPermission[] = []; + for (const permission of requested) { + reached.push(permission); + const explicit = input.decisions[permission]; + const decision = explicit ?? (input.autoAllow ? "allow" : "deny"); + if (decision === "deny") return { requested, reached, denied: permission, canMutate: false }; + } + return { requested, reached, canMutate: true }; +} diff --git a/test/opencode-host-matrix.test.ts b/test/opencode-host-matrix.test.ts index 3c140f9..65d3a3d 100644 --- a/test/opencode-host-matrix.test.ts +++ b/test/opencode-host-matrix.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { ARTIFACT_TOOL_CONTRACT, + OPENCODE_PERMISSION_POLICY, assertArtifactToolContract, boundedLog, exactStableMatrix, @@ -47,3 +48,11 @@ test("host startup parsing is loopback-only and logs stay bounded", () => { assert.ok(Buffer.byteLength(output, "utf8") <= 64); assert.match(output, /earlier output truncated/); }); + +test("stable permission probe keeps explicit denies under broad auto allow", () => { + assert.equal(OPENCODE_PERMISSION_POLICY["*"], "allow"); + assert.equal(OPENCODE_PERMISSION_POLICY.artifact_publish, "ask"); + assert.equal(OPENCODE_PERMISSION_POLICY.artifact_datasource, "ask"); + assert.equal(OPENCODE_PERMISSION_POLICY.artifact_deploy, "deny"); + assert.equal(OPENCODE_PERMISSION_POLICY.artifact_audience, "deny"); +}); diff --git a/test/opencode-permission-model.test.ts b/test/opencode-permission-model.test.ts new file mode 100644 index 0000000..0e8ef35 --- /dev/null +++ b/test/opencode-permission-model.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + evaluatePermissionModel, + type ModelPermission, +} from "./model/opencode-permission-model.ts"; + +test("permission model requires every requested scope before mutation", () => { + for (const datasource of [false, true]) { + for (const deploy of [false, true]) { + const allowed = evaluatePermissionModel({ datasource, deploy, decisions: {}, autoAllow: true }); + assert.equal(allowed.canMutate, true); + for (const denied of allowed.requested) { + const decisions = Object.fromEntries( + allowed.requested.map((permission) => [permission, permission === denied ? "deny" : "allow"]), + ) as Record; + const result = evaluatePermissionModel({ datasource, deploy, decisions, autoAllow: true }); + assert.equal(result.canMutate, false); + assert.equal(result.denied, denied); + assert.deepEqual(result.reached, allowed.requested.slice(0, allowed.requested.indexOf(denied) + 1)); + } + } + } +}); + +test("explicit deny remains effective under broad auto allow", () => { + const result = evaluatePermissionModel({ + datasource: true, + deploy: true, + decisions: { artifact_deploy: "deny" }, + autoAllow: true, + }); + assert.equal(result.canMutate, false); + assert.equal(result.denied, "artifact_deploy"); + assert.deepEqual(result.reached, ["artifact_publish", "artifact_datasource", "artifact_deploy"]); +}); diff --git a/test/opencode-permissions.test.ts b/test/opencode-permissions.test.ts new file mode 100644 index 0000000..bdea674 --- /dev/null +++ b/test/opencode-permissions.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + artifactPermissionKey, + publishPermissionRequests, +} from "../src/opencode-permissions.ts"; + +test("publish authorities are ordered, exact, bounded, and non-secret", () => { + const requests = publishPermissionRequests({ + slug: "incident-report", + format: "markdown", + trustedHtml: false, + dataSources: [ + { name: "latency", command: "/usr/local/bin/collect-latency", args: ["secret-argument"] }, + ], + deploy: { target: "github", coordinate: "team/artifacts@main" }, + }); + assert.deepEqual(requests.map((request) => request.permission), [ + "artifact_publish", + "artifact_datasource", + "artifact_deploy", + "artifact_audience", + ]); + assert.deepEqual(requests.map((request) => request.always), [[artifactPermissionKey("incident-report")], [], [], []]); + const serialized = JSON.stringify(requests); + assert.doesNotMatch(serialized, /secret-argument|\/usr\/local\/bin/); + assert.match(serialized, /collect-latency/); + assert.ok(requests.every((request) => request.patterns.every((pattern) => pattern.length <= 128))); + assert.ok(requests.every((request) => Buffer.byteLength(JSON.stringify(request.metadata), "utf8") <= 1024)); +}); + +test("malformed or unbounded datasource and deploy scopes fail before asking", () => { + assert.throws( + () => publishPermissionRequests({ + slug: "page", + format: "markdown", + trustedHtml: false, + dataSources: [{ name: "../escape", command: "collect" }], + }), + /datasource names/, + ); + assert.throws( + () => publishPermissionRequests({ + slug: "page", + format: "markdown", + trustedHtml: false, + deploy: { target: "github", coordinate: "team/artifacts with spaces@main" }, + }), + /unsupported characters/, + ); +}); diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 30a9af1..ddd0332 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -44,6 +44,9 @@ test("artifact_publish asks permission, publishes, and reports the path", async assert.equal(asked.length, 1); assert.equal(asked[0].permission, "artifact_publish"); + assert.equal(asked[0].always.length, 1); + assert.notEqual(asked[0].always[0], "*"); + assert.equal("title" in asked[0].metadata, false); assert.match(String(result), /Artifact published to .*demo-page\.html/); const page = await readFile(join(dir, ".opencode", "artifacts", "demo-page.html"), "utf8"); @@ -55,6 +58,48 @@ test("artifact_publish asks permission, publishes, and reports the path", async }); }); +test("artifact_publish resolves every authority before mutation and fails closed on denial", async () => { + const publish = (await ArtifactsPlugin({} as unknown as PluginInput)).tool?.artifact_publish; + assert.ok(publish); + const order = ["artifact_publish", "artifact_datasource", "artifact_deploy", "artifact_audience"]; + for (const denied of order) { + await withWorktree(async (dir) => { + const asked: Array[0]> = []; + const ctx: ToolContext = { + sessionID: "s-deny", + messageID: "m-deny", + agent: "test", + directory: dir, + worktree: dir, + abort: new AbortController().signal, + metadata: () => {}, + ask: async (input) => { + asked.push(input); + if (input.permission === denied) throw new Error("denied by test policy"); + }, + }; + const result = String(await publish.execute({ + markdown: "---\ntitle: Permission Probe\n---\n# Safe\n", + dataSources: [{ name: "latency", command: "/usr/local/bin/collect", args: ["not-in-metadata"] }], + deploy: true, + target: "github", + repo: "team/artifacts", + }, ctx)); + assert.match(result, /"error": "permission-denied"/); + assert.match(result, new RegExp(`"permission": "${denied}"`)); + assert.match(result, /"mutation": "none"/); + assert.deepEqual( + asked.map((input) => input.permission), + order.slice(0, order.indexOf(denied) + 1), + ); + assert.ok(asked.filter((input) => input.permission !== "artifact_publish").every((input) => input.always.length === 0)); + assert.doesNotMatch(JSON.stringify(asked), /not-in-metadata|# Safe|\/usr\/local\/bin/); + await assert.rejects(readFile(join(dir, ".opencode", "artifacts", "manifest.json"), "utf8")); + await assert.rejects(readFile(join(dir, ".opencode", "artifacts", ".datasources", "permission-probe.json"), "utf8")); + }); + } +}); + test("artifact_publish blocks credential-looking content unless forced", async () => { const hooks = await ArtifactsPlugin({} as unknown as PluginInput); const publish = hooks.tool?.artifact_publish; assert.ok(publish); From 3f29788f6a2a9f755fee4f7e30cdd665caf1ad0d Mon Sep 17 00:00:00 2001 From: bitgorust Date: Tue, 18 Aug 2026 04:37:35 +0200 Subject: [PATCH 5/8] feat: add structured lifecycle reopen --- README.md | 11 + scripts/opencode-host-matrix.ts | 11 + .../opencode-lifecycle-results/change.json | 2 +- .../opencode-lifecycle-results/evidence.md | 15 +- .../opencode-lifecycle-results/tasks.md | 12 +- specs/current/artifact-lifecycle.spec.md | 13 + specs/current/opencode-integration.spec.md | 8 + src/cli.ts | 24 +- src/open.ts | 27 +- src/opencode-results.ts | 81 ++++++ src/plugin.ts | 256 ++++++++++++++---- test/cli-latest.test.ts | 21 ++ test/opencode-host-matrix.test.ts | 5 +- test/opencode-results.test.ts | 25 ++ test/plugin.test.ts | 106 ++++++++ 15 files changed, 533 insertions(+), 84 deletions(-) create mode 100644 src/opencode-results.ts create mode 100644 test/cli-latest.test.ts create mode 100644 test/opencode-results.test.ts diff --git a/README.md b/README.md index 5d61d80..879eaf2 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,17 @@ opencode-artifacts export --output ./bundle opencode-artifacts import ./bundle ``` +Stable OpenCode also injects `/artifact-reopen `, backed by +`artifact_lifecycle` with `op: "reopen"`. It accepts an exact active ID, slug, contained path, +or registered URL and never guesses. If a user already owns that command name, the plugin +leaves it unchanged; `opencode-artifacts latest --open` remains the portable fallback. + +Publish and lifecycle tools return the stable host's structured result shape. Human/model text +is in `output`; `metadata.artifactResult` is a bounded schema-versioned identity, revision, +path/URL, capability, visibility, outcome, and next-action envelope. Existing operation and +argument spellings remain accepted, and prior readable text/JSON stays in `output` through at +least the next supported release after this metadata contract was introduced. + ## Authoring format Full reference: [`docs/component-spec.md`](docs/component-spec.md). Short version: diff --git a/scripts/opencode-host-matrix.ts b/scripts/opencode-host-matrix.ts index b1192bf..5536097 100644 --- a/scripts/opencode-host-matrix.ts +++ b/scripts/opencode-host-matrix.ts @@ -47,6 +47,7 @@ interface ServerResult { logs: string; config: unknown; effectivePermission?: unknown; + effectiveCommand?: unknown; } export interface MatrixEvidence { @@ -132,6 +133,10 @@ export function assertArtifactToolContract(ids: unknown, tools: unknown): assert if (!(property in actual)) throw new Error(`${id} schema is missing ${property}`); } } + const lifecycleOp = byId.get("artifact_lifecycle")?.parameters?.properties?.["op"]; + if (!isRecord(lifecycleOp) || !Array.isArray(lifecycleOp["enum"]) || !lifecycleOp["enum"].includes("reopen")) { + throw new Error("artifact_lifecycle op schema is missing reopen"); + } } async function runCommand( @@ -264,6 +269,11 @@ async function probeRoute(input: { assertArtifactToolContract(ids, tools); const effectiveConfig = await fetchJson(`${server.url}/config`); const effectivePermission = isRecord(effectiveConfig) ? effectiveConfig["permission"] : undefined; + const effectiveCommand = isRecord(effectiveConfig) ? effectiveConfig["command"] : undefined; + const reopenCommand = isRecord(effectiveCommand) ? effectiveCommand["artifact-reopen"] : undefined; + if (!isRecord(reopenCommand) || typeof reopenCommand["template"] !== "string" || !reopenCommand["template"].includes("artifact_lifecycle")) { + throw new Error("stable host omitted the injected artifact-reopen command"); + } if (input.route === "config-array") { if (!isRecord(effectivePermission)) throw new Error("stable host omitted configured artifact permissions"); for (const [permission, decision] of Object.entries(OPENCODE_PERMISSION_POLICY)) { @@ -281,6 +291,7 @@ async function probeRoute(input: { logs: server.logs(), config: input.config, ...(effectivePermission === undefined ? {} : { effectivePermission }), + effectiveCommand: { "artifact-reopen": reopenCommand }, }; } finally { await stopServer(child); diff --git a/specs/changes/opencode-lifecycle-results/change.json b/specs/changes/opencode-lifecycle-results/change.json index 1aaa923..4727efc 100644 --- a/specs/changes/opencode-lifecycle-results/change.json +++ b/specs/changes/opencode-lifecycle-results/change.json @@ -9,7 +9,7 @@ "specs/current/artifact-lifecycle.spec.md", "specs/current/opencode-integration.spec.md" ], - "currentSpecsUpdated": false, + "currentSpecsUpdated": true, "approval": { "by": "aaron.zeng", "at": "2026-08-18T01:48:52Z" diff --git a/specs/changes/opencode-lifecycle-results/evidence.md b/specs/changes/opencode-lifecycle-results/evidence.md index c4105c7..cc91853 100644 --- a/specs/changes/opencode-lifecycle-results/evidence.md +++ b/specs/changes/opencode-lifecycle-results/evidence.md @@ -6,18 +6,21 @@ Do not hide failed or excluded results. ## Requirement: UX-01 - Validation: users and models see exact identity, revision, capability, and next action. -- Verification: planned bounded envelope and clean-host reopen command observation. -- Result: pending implementation. +- Verification: fixed output/metadata byte limits, publish/lifecycle schema-1 envelopes, typed + refusals, exact immutable read paths, and legacy string conversion are contract-tested. +- Result: implementation pass; final packed-host command observation remains pending. - Evidence: [@test](test/plugin.test.ts) ## Requirement: LIFE-06 - Validation: reopen joins the existing list/read/status/archive lifecycle surface. -- Verification: planned exact-reference launcher and no-open refusal tests. -- Result: pending implementation. +- Verification: injected launchers receive exact local paths and registered URLs; invalid + references and launch failures invoke no accepted launcher. CLI latest/open remains tested. +- Result: pass in plugin and CLI integration tests; packed stable-host observation pending. - Evidence: [@test](test/artifact-lifecycle.test.ts) ## Requirement: COMPAT-05 - Validation: old callers retain accepted arguments and actionable deprecation output. -- Verification: planned old/new contract fixtures and packed-host schema snapshot. -- Result: pending implementation. +- Verification: old operations/arguments still execute, prior JSON/text remains in `output` and + `String(result)`, while packed schema assertions now require additive `reopen`. +- Result: implementation pass; final candidate schema snapshot pending. - Evidence: [@test](test/plugin.test.ts) diff --git a/specs/changes/opencode-lifecycle-results/tasks.md b/specs/changes/opencode-lifecycle-results/tasks.md index c04de95..fa43eb4 100644 --- a/specs/changes/opencode-lifecycle-results/tasks.md +++ b/specs/changes/opencode-lifecycle-results/tasks.md @@ -1,10 +1,10 @@ # Tasks: Expose bounded lifecycle results and reopen -- [ ] Confirm proposal validation and human approval. -- [ ] Freeze public input/result compatibility fixtures and bounded envelope limits. -- [ ] Return concise output with versioned structured metadata and typed failures. -- [ ] Add exact-reference `reopen` to `artifact_lifecycle` with an injectable launcher. -- [ ] Inject and verify `/artifact-reopen` only through the supported stable config hook. -- [ ] Test old spellings, large results, invalid references, and CLI fallback behavior. +- [x] Confirm proposal validation and human approval. +- [x] Freeze public input/result compatibility fixtures and bounded envelope limits. +- [x] Return concise output with versioned structured metadata and typed failures. +- [x] Add exact-reference `reopen` to `artifact_lifecycle` with an injectable launcher. +- [x] Inject and verify `/artifact-reopen` only through the supported stable config hook. +- [x] Test old spellings, large results, invalid references, and CLI fallback behavior. - [ ] Record host evidence and update lifecycle/OpenCode current specs. - [ ] Run repository validation and archive the packet. diff --git a/specs/current/artifact-lifecycle.spec.md b/specs/current/artifact-lifecycle.spec.md index 7c163df..00a7367 100644 --- a/specs/current/artifact-lifecycle.spec.md +++ b/specs/current/artifact-lifecycle.spec.md @@ -32,6 +32,15 @@ restore, preview-bound recoverable archive/unarchive, and checksummed directory export/import. Archive confirmation is one-use and head/state/deployment scoped. Irreversible deletion is not exposed. Plugin archive confirmation uses a distinct permission request. +- `LIFE-06`, `UX-01`: plugin lifecycle also exposes `reopen` for one exact active ID, slug, + contained path, or registered URL. Local references open the stable contained path; an exact + registered URL opens that URL. Invalid, archived, escaping, unregistered, or failed launcher + selections open nothing and return a typed next action. Stable configuration injects + `/artifact-reopen`; `opencode-artifacts latest --open` remains the standalone fallback. +- Plugin publish/lifecycle calls return stable host result objects with concise `output` and a + bounded `metadata.artifactResult` schema-1 envelope. `String(result)` and `output` preserve + prior readable text/JSON. Large reads use an exact immutable revision path plus a bounded + preview rather than placing full HTML in metadata. - Authoring sources, local state, migration backups/reports, transactions, archive records, previews, and exported bundles are not copied into public-static staging by default. @@ -41,6 +50,10 @@ and restore spelling remain accepted compatibility aliases. Under schema 2, history is unconditional, an existing-artifact publish without a precondition refuses, and restore requires the expected head. These public changes use the next pre-1.0 minor. +- `COMPAT-05`: `reopen` and result-envelope schema 1 are additive. Existing lifecycle operation + names and argument spellings remain accepted, and the prior text/JSON lives in `output` for + the documented deprecation window. User-owned `artifact-reopen` command configuration is + preserved rather than overwritten. - `QUAL-02`, `QUAL-06`: the Node 24 full suite covers deterministic unit, model, multi-process, crash, migration, CLI, plugin, hostile-reference, bundle, and loopback behavior. A real two-tab Chromium run verifies visible decision/comment conflicts and a non-mutating quota diff --git a/specs/current/opencode-integration.spec.md b/specs/current/opencode-integration.spec.md index ea50ff7..516ad0e 100644 --- a/specs/current/opencode-integration.spec.md +++ b/specs/current/opencode-integration.spec.md @@ -29,6 +29,11 @@ Markdown, datasource arguments, full executable paths, credentials, and provider output. Stable OpenCode 1.18.18 preserves exact `allow`/`ask`/`deny` rules, including explicit deploy and audience deny entries beneath a broad auto-allow wildcard. +- `LIFE-06`, `COMPAT-05`: the stable lifecycle schema adds `op: "reopen"`. The plugin config + hook injects `/artifact-reopen` only when that command name is free, and leaves a user-owned + command untouched. Stable results use `{title, output, metadata}` with a bounded version-1 + `metadata.artifactResult`; the non-enumerable string conversion returns the same `output` for + direct legacy callers. ## Evidence boundary @@ -43,3 +48,6 @@ transitions. Stable-host evidence confirms policy parsing and exact precedence without provider inference; tool-selection enforcement is covered by injected `ctx.ask` integration because native tool execution otherwise requires a provider turn. +- Packed discovery requires the `reopen` enum member and the effective injected command. + `opencode-artifacts latest --open` is tested separately with an injectable launcher and does + not depend on the host config hook. diff --git a/src/cli.ts b/src/cli.ts index bf00b20..fd20650 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { renderArtifact, renderRawHtml } from "./render.ts"; import type { PortableAssets } from "./assets.ts"; import type { ResolvedDesignTokens } from "./design-tokens.ts"; @@ -216,7 +217,10 @@ async function restoreCommand(args: string[]): Promise { } } -async function latestCommand(args: string[]): Promise { +export async function latestCommand( + args: string[], + launcher: (path: string) => void = openFile, +): Promise { const dir = optionValue(args, "--dir") ?? DEFAULT_DIR; const open = args.includes("--open"); const publisher = new FilePublisher(resolve(dir)); @@ -226,8 +230,9 @@ async function latestCommand(args: string[]): Promise { process.exit(1); } const path = join(resolve(dir), `${latest.slug}.html`); - if (open) openFile(path); + if (open) launcher(path); console.log(path); + return path; } async function stateCommand(args: string[]): Promise { const [slug] = positional(args, ["--dir"]); @@ -443,7 +448,7 @@ async function initCommand(args: string[]): Promise { } } -async function main(argv: string[]): Promise { +export async function main(argv: string[]): Promise { const [command, ...rest] = argv; switch (command) { case "render": @@ -453,7 +458,8 @@ async function main(argv: string[]): Promise { case "restore": return restoreCommand(rest); case "latest": - return latestCommand(rest); + await latestCommand(rest); + return; case "state": return stateCommand(rest); case "list": @@ -481,7 +487,9 @@ async function main(argv: string[]): Promise { } } -main(process.argv.slice(2)).catch((err: unknown) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main(process.argv.slice(2)).catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + }); +} diff --git a/src/open.ts b/src/open.ts index 9fe3bb6..bce78d9 100644 --- a/src/open.ts +++ b/src/open.ts @@ -1,14 +1,27 @@ import { spawn } from "node:child_process"; +function opener(path: string): { command: string; args: string[] } { + const platform = process.platform; + const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; + const args = platform === "darwin" ? [path] : platform === "win32" ? ["/c", "start", "", path] : [path]; + return { command, args }; +} + +export async function openFileChecked(path: string): Promise { + const selected = opener(path); + await new Promise((resolve, reject) => { + const child = spawn(selected.command, selected.args, { detached: true, stdio: "ignore" }); + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} + export function openFile(path: string): void { try { - const platform = process.platform; - const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; - const args = - platform === "darwin" ? [path] : platform === "win32" ? ["/c", "start", "", path] : [path]; - const child = spawn(command, args, { detached: true, stdio: "ignore" }); - child.on("error", () => {}); - child.unref(); + void openFileChecked(path).catch(() => {}); } catch { // Opening a browser is best-effort; failing to open must never break publishing. } diff --git a/src/opencode-results.ts b/src/opencode-results.ts new file mode 100644 index 0000000..73aefc0 --- /dev/null +++ b/src/opencode-results.ts @@ -0,0 +1,81 @@ +import type { ToolResult } from "@opencode-ai/plugin"; + +export const ARTIFACT_RESULT_SCHEMA_VERSION = 1; +export const MAX_TOOL_OUTPUT_BYTES = 16 * 1024; +export const MAX_TOOL_METADATA_BYTES = 8 * 1024; +export const MAX_INLINE_RESULT_CONTENT_BYTES = 8 * 1024; + +export interface ArtifactResultEnvelope { + schemaVersion: 1; + operation: string; + outcome: "success" | "refused" | "error"; + artifactId?: string; + slug?: string; + revision?: number; + path?: string; + url?: string; + capability?: "portable-local" | "public-static" | "authenticated" | "connector-capable"; + visibility?: "local" | "public" | "authenticated"; + active?: boolean; + count?: number; + error?: string; + nextAction?: string; +} + +export type CompatibleToolResult = Exclude & { toString(): string }; + +function truncateUtf8(value: string, limit: number): string { + if (Buffer.byteLength(value, "utf8") <= limit) return value; + const marker = "\n… [bounded result; use the exact path/operation in metadata]"; + const keep = Math.max(0, limit - Buffer.byteLength(marker, "utf8")); + return Buffer.from(value, "utf8").subarray(0, keep).toString("utf8") + marker; +} + +function boundedEnvelope(envelope: ArtifactResultEnvelope): ArtifactResultEnvelope { + const bounded: ArtifactResultEnvelope = { ...envelope }; + bounded.operation = truncateUtf8(bounded.operation, 1024); + if (bounded.artifactId !== undefined) bounded.artifactId = truncateUtf8(bounded.artifactId, 1024); + if (bounded.slug !== undefined) bounded.slug = truncateUtf8(bounded.slug, 1024); + if (bounded.path !== undefined) bounded.path = truncateUtf8(bounded.path, 1024); + if (bounded.url !== undefined) bounded.url = truncateUtf8(bounded.url, 1024); + if (bounded.error !== undefined) bounded.error = truncateUtf8(bounded.error, 1024); + if (bounded.nextAction !== undefined) bounded.nextAction = truncateUtf8(bounded.nextAction, 1024); + if (Buffer.byteLength(JSON.stringify({ artifactResult: bounded }), "utf8") > MAX_TOOL_METADATA_BYTES) { + throw new Error("artifact result metadata exceeded its fixed byte limit"); + } + return bounded; +} + +export function artifactToolResult( + title: string, + output: string, + envelope: ArtifactResultEnvelope, +): CompatibleToolResult { + const boundedOutput = truncateUtf8(output, MAX_TOOL_OUTPUT_BYTES); + const result = { + title: truncateUtf8(title, 160), + output: boundedOutput, + metadata: { artifactResult: boundedEnvelope(envelope) }, + } as CompatibleToolResult; + Object.defineProperty(result, "toString", { + enumerable: false, + value: () => boundedOutput, + }); + return result; +} + +export function artifactFailureResult( + operation: string, + output: string, + error: string, + nextAction: string, + outcome: "refused" | "error" = "refused", +): CompatibleToolResult { + return artifactToolResult(`Artifact ${operation} ${outcome}`, output, { + schemaVersion: ARTIFACT_RESULT_SCHEMA_VERSION, + operation, + outcome, + error, + nextAction, + }); +} diff --git a/src/plugin.ts b/src/plugin.ts index d26b6c9..2162f86 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -19,7 +19,7 @@ import { CloudflarePublisher } from "./cloudflare-publisher.ts"; import { loadConfig, resolveDeploy } from "./config.ts"; import { formatFindings, scanSensitive } from "./guard.ts"; import { NAME_RE, readCollection, writeCollection } from "./serve.ts"; -import { openFile } from "./open.ts"; +import { openFile, openFileChecked } from "./open.ts"; import { ArtifactStateError, STATE_KEY_RE, @@ -38,11 +38,19 @@ import { import { ArtifactLifecycleConflictError, ArtifactLifecycleStore, + ArtifactReferenceError, + type ArtifactLifecycleStatus, } from "./artifact-lifecycle.ts"; import { approvePublishPermissions, ArtifactPermissionDeniedError, } from "./opencode-permissions.ts"; +import { + ARTIFACT_RESULT_SCHEMA_VERSION, + MAX_INLINE_RESULT_CONTENT_BYTES, + artifactFailureResult, + artifactToolResult, +} from "./opencode-results.ts"; function ghPagesCloneDir(repo: string): string { return join(homedir(), ".cache", "opencode-artifacts", "ghpages", repo.replace("/", "__")); @@ -122,6 +130,10 @@ async function proactiveGuidance(): Promise { } export const ArtifactsPlugin: Plugin = async (_input, options) => { + const configuredLauncher = options?.["launcher"]; + const reopenLauncher = typeof configuredLauncher === "function" + ? configuredLauncher as (target: string) => void | Promise + : openFileChecked; const hooks: Hooks = { tool: { artifact_publish: tool({ @@ -194,13 +206,19 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { }, async execute(args, ctx) { let slug = "artifact"; + const refused = (output: string, error: string, nextAction: string) => + artifactFailureResult("publish", output, error, nextAction); try { const parsedTitle = args.format === "html" ? undefined : parseDocument(args.markdown).meta.title; const title = args.title ?? parsedTitle ?? "Artifact"; slug = slugify(title); const findings = scanSensitive(`${args.markdown}\n${title}`); if (findings.length > 0 && args.force !== true) { - return `Publish blocked: the content contains credential-looking strings: ${formatFindings(findings)}. If these are intentional (e.g. redacted examples), call again with force: true.`; + return refused( + `Publish blocked: the content contains credential-looking strings: ${formatFindings(findings)}. If these are intentional (e.g. redacted examples), call again with force: true.`, + "sensitive-content", + "remove the finding or retry with force only after reviewing the exact portable content", + ); } let preflightWarnings: AuthoringDiagnostic[] = []; let portableAssets: PortableAssets | undefined; @@ -210,7 +228,9 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { } else { const preflight = await preflightDocument(args.markdown, { worktreeRoot: workRoot(ctx) }); const errors = preflight.diagnostics.filter((item) => item.severity === "error"); - if (errors.length > 0 || preflight.omitted > 0) return formatPreflight(preflight); + if (errors.length > 0 || preflight.omitted > 0) { + return refused(formatPreflight(preflight), "preflight", "fix the bounded diagnostics and retry"); + } preflightWarnings = preflight.diagnostics; portableAssets = preflight.assets; designTokens = preflight.designTokens; @@ -221,7 +241,11 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { : renderArtifact(args.markdown, { assets: portableAssets, designTokens }); const finalFindings = scanSensitive(rendered.html); if (finalFindings.length > 0 && args.force !== true) { - return `Publish blocked: the final portable bytes contain credential-looking strings: ${formatFindings(finalFindings)}. If these are intentional, call again with force: true.`; + return refused( + `Publish blocked: the final portable bytes contain credential-looking strings: ${formatFindings(finalFindings)}. If these are intentional, call again with force: true.`, + "sensitive-final-bytes", + "remove the finding or retry with force only after reviewing the exact portable bytes", + ); } const plannedDeploy = await (async () => { if (!args.deploy) return undefined; @@ -251,7 +275,7 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { }); const localDir = join(workRoot(ctx), ".opencode", "artifacts"); - let result: { path: string; version: number; gallery: string; hash: string; url?: string }; + let result: { id?: string; slug: string; path: string; version: number; gallery: string; hash: string; url?: string }; if (await isSchema2Store(localDir)) { const lifecycle = new ArtifactLifecycleStore(localDir); let status = await lifecycle.write({ @@ -286,7 +310,7 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { } } if (args.version !== undefined) console.error("artifact_publish version is deprecated for schema 2; immutable history is always retained"); - result = { path: status.stablePath ?? join(localDir, `${status.slug}.html`), version: status.headRevision, gallery: join(localDir, "index.html"), hash: status.contentHash.slice(0, 12), ...(url === undefined ? {} : { url }) }; + result = { id: status.id, slug: status.slug, path: status.stablePath ?? join(localDir, `${status.slug}.html`), version: status.headRevision, gallery: join(localDir, "index.html"), hash: status.contentHash.slice(0, 12), ...(url === undefined ? {} : { url }) }; } else { if (args.artifact !== undefined || args.expectedRevision !== undefined) throw new Error("artifact and expectedRevision require a migrated schema-2 store"); const publisher = await (async () => { @@ -296,7 +320,7 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { if (resolved.target === "cloudflare" && resolved.workerName) return new CloudflarePublisher(localDir, { workerName: resolved.workerName, stagingDir: cfStagingDir(resolved.workerName), allowSensitive: args.force === true }); throw new Error(`deploy target '${resolved.target}' is missing its ${resolved.target === "github" ? "repo" : "workerName"} — run \`opencode-artifacts init\``); })(); - result = await publisher.publish({ slug, html: rendered.html, title, icon: rendered.meta.icon, description: rendered.meta.description, source: rendered.meta.source, charts: rendered.chartCount, version: args.version ?? false, expectedHash: args.expectedHash }); + result = { slug, ...await publisher.publish({ slug, html: rendered.html, title, icon: rendered.meta.icon, description: rendered.meta.description, source: rendered.meta.source, charts: rendered.chartCount, version: args.version ?? false, expectedHash: args.expectedHash }) }; } if (args.open) openFile(result.path); if (args.dataSources && args.dataSources.length > 0) { @@ -319,21 +343,37 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { }, }); const warning = preflightWarnings.length === 0 ? "" : `\nPreflight warnings: ${preflightWarnings.map((item) => `${item.code} at ${item.line}:${item.column}`).join(", ")}`; - return `Artifact published to ${result.path}${result.url ? ` — live at ${result.url}` : ""} (gallery: ${result.gallery}, hash: ${result.hash})${warning}`; + return artifactToolResult( + `Artifact: ${title}`, + `Artifact published to ${result.path}${result.url ? ` — live at ${result.url}` : ""} (gallery: ${result.gallery}, hash: ${result.hash})${warning}`, + { + schemaVersion: ARTIFACT_RESULT_SCHEMA_VERSION, + operation: args.expectedRevision === undefined && args.expectedHash === undefined ? "create-or-update" : "update", + outcome: "success", + ...(result.id === undefined ? {} : { artifactId: result.id }), + slug: result.slug, + revision: result.version, + path: result.path, + ...(result.url === undefined ? {} : { url: result.url }), + capability: result.url === undefined ? "portable-local" : "public-static", + visibility: result.url === undefined ? "local" : "public", + }, + ); } catch (err) { if (err instanceof ArtifactPermissionDeniedError) { - return JSON.stringify({ + const output = JSON.stringify({ error: "permission-denied", permission: err.permission, mutation: "none", nextAction: `allow or ask for ${err.permission} at the exact requested scope, then retry`, }, null, 2); + return refused(output, "permission-denied", `allow or ask for ${err.permission} at the exact requested scope, then retry`); } if (err instanceof ArtifactTooLargeError) { - return `Artifact too large: ${err.message}`; + return refused(`Artifact too large: ${err.message}`, "artifact-too-large", "reduce portable bytes and retry"); } if (err instanceof AssetPreflightError) { - return JSON.stringify({ error: err.code, path: err.assetPath, message: err.message, nextAction: err.nextAction }, null, 2); + return refused(JSON.stringify({ error: err.code, path: err.assetPath, message: err.message, nextAction: err.nextAction }, null, 2), err.code, err.nextAction); } if (err instanceof StaleArtifactError) { const currentPath = join(workRoot(ctx), ".opencode", "artifacts", `${slug}.html`); @@ -351,25 +391,26 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { : bodyAt > 0 ? `${current.slice(0, 2000)}\n…\n${current.slice(bodyAt, bodyAt + cap)}` : current.slice(0, cap); - return [ + return refused([ `Publish refused: ${err.message}.`, "The current published content follows — merge your edits onto it, then publish again with the new hash.", `[Artifact ${slug} — live version; raw HTML follows]`, preview, "[End of live content]", - ].join("\n\n"); + ].join("\n\n"), "stale", "merge onto the bounded live preview and retry with its current hash"); } if (err instanceof ArtifactLifecycleConflictError) { - return JSON.stringify({ error: "stale", message: err.message, artifact: err.artifact, merge: err.merge, nextAction: "merge onto the returned immutable input and retry with artifact plus expectedRevision/hash" }, null, 2); + return refused(JSON.stringify({ error: "stale", message: err.message, artifact: err.artifact, merge: err.merge, nextAction: "merge onto the returned immutable input and retry with artifact plus expectedRevision/hash" }, null, 2), "stale", "merge onto the returned immutable input and retry with artifact plus expectedRevision/hash"); } - throw err; + const message = err instanceof Error ? err.message : "unknown publish failure"; + return artifactFailureResult("publish", `Artifact publish failed: ${message}`, "publish-failed", "fix the reported layer and retry", "error"); } }, }), artifact_lifecycle: tool({ - description: "List, inspect, read, restore, archive/unarchive, export, or import schema-2 artifacts by exact identity/reference. Archive is recoverable and requires a preview-bound permission confirmation.", + description: "List, inspect, read, reopen, restore, archive/unarchive, export, or import schema-2 artifacts by exact identity/reference. Archive is recoverable and requires a preview-bound permission confirmation.", args: { - op: tool.schema.enum(["list", "status", "read", "restore", "archive-preview", "archive-confirm", "unarchive", "export", "import"]), + op: tool.schema.enum(["list", "status", "read", "reopen", "restore", "archive-preview", "archive-confirm", "unarchive", "export", "import"]), artifact: tool.schema.string().optional().describe("Exact artifact reference or opaque ID"), revision: tool.schema.number().optional().describe("Revision to read or restore"), expectedRevision: tool.schema.number().optional().describe("Expected current head for restore"), @@ -379,47 +420,144 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { }, async execute(args, ctx) { const root = join(workRoot(ctx), ".opencode", "artifacts"); - if (!(await isSchema2Store(root))) return "artifact_lifecycle requires a migrated schema-2 store"; + const failure = (output: string, error: string, nextAction: string, outcome: "refused" | "error" = "refused") => + artifactFailureResult(`lifecycle:${args.op}`, output, error, nextAction, outcome); + const fromStatus = ( + operation: string, + status: ArtifactLifecycleStatus, + output: string, + overrides: { path?: string; url?: string; capability?: "portable-local" | "public-static" | "authenticated" | "connector-capable"; visibility?: "local" | "public" | "authenticated" } = {}, + ) => artifactToolResult(`Artifact ${operation}: ${status.title}`, output, { + schemaVersion: ARTIFACT_RESULT_SCHEMA_VERSION, + operation, + outcome: "success", + artifactId: status.id, + slug: status.slug, + revision: status.headRevision, + ...(overrides.path === undefined && status.stablePath === null ? {} : { path: overrides.path ?? status.stablePath ?? undefined }), + ...(overrides.url === undefined ? {} : { url: overrides.url }), + capability: overrides.capability ?? "portable-local", + visibility: overrides.visibility ?? "local", + active: status.active, + }); + if (!(await isSchema2Store(root))) { + return failure( + "artifact_lifecycle requires a migrated schema-2 store", + "migration-required", + "run `opencode-artifacts migrate inspect`, then explicitly apply the reviewed migration", + ); + } const lifecycle = new ArtifactLifecycleStore(root); - switch (args.op) { - case "list": - return JSON.stringify({ schemaVersion: 1, artifacts: await lifecycle.list() }, null, 2); - case "status": - if (!args.artifact) return "status requires artifact"; - return JSON.stringify(await lifecycle.status(args.artifact), null, 2); - case "read": { - if (!args.artifact) return "read requires artifact"; - const result = await lifecycle.read(args.artifact, args.revision); - const bytes = Buffer.byteLength(result.html, "utf8"); - return JSON.stringify({ status: result.status, revision: result.revision, ...(bytes <= 256 * 1024 ? { html: result.html } : { pinnedPath: join(root, ...result.revision.pagePath.split("/")), preview: `${result.html.slice(0, 8192)}\n…\n${result.html.slice(-8192)}` }) }, null, 2); - } - case "restore": - if (!args.artifact || args.revision === undefined || args.expectedRevision === undefined) return "restore requires artifact, revision, and expectedRevision"; - try { - return JSON.stringify(await lifecycle.restore(args.artifact, args.revision, args.expectedRevision), null, 2); - } catch (error) { - if (error instanceof ArtifactLifecycleConflictError) return JSON.stringify({ error: "stale", message: error.message, artifact: error.artifact, merge: error.merge }, null, 2); - throw error; + try { + switch (args.op) { + case "list": { + const artifacts = await lifecycle.list(); + const output = JSON.stringify({ schemaVersion: 1, artifacts }, null, 2); + return artifactToolResult("Artifact lifecycle list", output, { + schemaVersion: ARTIFACT_RESULT_SCHEMA_VERSION, + operation: "list", + outcome: "success", + count: artifacts.length, + nextAction: artifacts.length === 0 ? "publish or import an artifact" : "use an exact ID, slug, contained path, or registered URL", + }); + } + case "status": { + if (!args.artifact) return failure("status requires artifact", "missing-reference", "pass an exact artifact reference"); + const status = await lifecycle.status(args.artifact); + return fromStatus("status", status, JSON.stringify(status, null, 2)); } - case "archive-preview": - if (!args.artifact) return "archive-preview requires artifact"; - return JSON.stringify(await lifecycle.previewArchive(args.artifact), null, 2); - case "archive-confirm": { - if (!args.token) return "archive-confirm requires token"; - const preview = await lifecycle.inspectArchivePreview(args.token); - await ctx.ask({ permission: "artifact_archive", patterns: [preview.artifact.id, args.token], always: [], metadata: { artifactId: preview.artifact.id, slug: preview.artifact.slug, headRevision: preview.artifact.headRevision, token: args.token } }); - return JSON.stringify(await lifecycle.archive(args.token), null, 2); + case "read": { + if (!args.artifact) return failure("read requires artifact", "missing-reference", "pass an exact artifact reference"); + const result = await lifecycle.read(args.artifact, args.revision); + const bytes = Buffer.byteLength(result.html, "utf8"); + const pinnedPath = join(root, ...result.revision.pagePath.split("/")); + const content = bytes <= MAX_INLINE_RESULT_CONTENT_BYTES + ? { html: result.html } + : { pinnedPath, preview: `${result.html.slice(0, 4096)}\n…\n${result.html.slice(-4096)}` }; + const output = JSON.stringify({ status: result.status, revision: result.revision, ...content }, null, 2); + return fromStatus("read", result.status, output, { path: pinnedPath }); + } + case "reopen": { + if (!args.artifact) return failure("reopen requires artifact", "missing-reference", "pass an exact active artifact reference"); + const status = await lifecycle.status(args.artifact); + let exactUrl: string | undefined; + try { + const normalized = new URL(args.artifact).href; + exactUrl = status.deploymentReferences.find((entry) => entry.url === normalized)?.url; + } catch { + // Non-URL references reopen the stable contained local path. + } + const target = exactUrl ?? status.stablePath; + if (!target || !status.active) { + return failure("reopen requires an active local path or exact registered URL", "inactive-artifact", "unarchive by exact ID or choose an active reference"); + } + try { + await reopenLauncher(target); + } catch { + return failure("Artifact reopen failed before the launcher accepted the target", "launch-failed", "open the exact returned path or URL manually, or use `opencode-artifacts latest --open`", "error"); + } + const output = JSON.stringify({ schemaVersion: 1, status, opened: target }, null, 2); + if (exactUrl === undefined) return fromStatus("reopen", status, output, { path: target }); + const capability = status.deploymentReferences.find((entry) => entry.url === exactUrl)?.capability ?? "public-static"; + return fromStatus("reopen", status, output, { + url: exactUrl, + capability, + visibility: capability === "authenticated" ? "authenticated" : "public", + }); + } + case "restore": { + if (!args.artifact || args.revision === undefined || args.expectedRevision === undefined) { + return failure("restore requires artifact, revision, and expectedRevision", "missing-restore-input", "pass an exact reference, immutable revision, and current expected head"); + } + const status = await lifecycle.restore(args.artifact, args.revision, args.expectedRevision); + return fromStatus("restore", status, JSON.stringify(status, null, 2)); + } + case "archive-preview": { + if (!args.artifact) return failure("archive-preview requires artifact", "missing-reference", "pass an exact active artifact reference"); + const preview = await lifecycle.previewArchive(args.artifact); + return fromStatus("archive-preview", preview.artifact, JSON.stringify(preview, null, 2)); + } + case "archive-confirm": { + if (!args.token) return failure("archive-confirm requires token", "missing-token", "run archive-preview and pass its one-use head-bound token"); + const preview = await lifecycle.inspectArchivePreview(args.token); + try { + await ctx.ask({ permission: "artifact_archive", patterns: [preview.artifact.id, args.token], always: [], metadata: { artifactId: preview.artifact.id, slug: preview.artifact.slug, headRevision: preview.artifact.headRevision, token: args.token } }); + } catch { + return failure("Archive permission denied; artifact state is unchanged", "permission-denied", "approve the exact head-bound archive scope and retry before the token expires"); + } + const status = await lifecycle.archive(args.token); + return fromStatus("archive-confirm", status, JSON.stringify(status, null, 2)); + } + case "unarchive": { + if (!args.artifact) return failure("unarchive requires artifact ID", "missing-id", "pass the exact archived artifact ID"); + const status = await lifecycle.unarchive(args.artifact, args.slug); + return fromStatus("unarchive", status, JSON.stringify(status, null, 2)); + } + case "export": { + if (!args.artifact || !args.path) return failure("export requires artifact and path", "missing-export-input", "pass an exact artifact reference and contained destination path"); + const exported = await lifecycle.exportBundle(args.artifact, args.path); + return fromStatus("export", exported.artifact, JSON.stringify(exported, null, 2), { path: exported.path }); + } + case "import": { + if (!args.path) return failure("import requires path", "missing-import-path", "pass the exact bundle directory"); + try { + await ctx.ask({ permission: "artifact_import", patterns: [resolve(args.path)], always: [], metadata: { bundle: resolve(args.path) } }); + } catch { + return failure("Import permission denied; artifact state is unchanged", "permission-denied", "approve the exact bundle path and retry"); + } + const status = await lifecycle.importBundle(args.path); + return fromStatus("import", status, JSON.stringify(status, null, 2)); + } + } + } catch (error) { + if (error instanceof ArtifactLifecycleConflictError) { + return failure(JSON.stringify({ error: "stale", message: error.message, artifact: error.artifact, merge: error.merge }, null, 2), "stale", "merge onto the returned immutable input and retry with the current expected revision"); } - case "unarchive": - if (!args.artifact) return "unarchive requires artifact ID"; - return JSON.stringify(await lifecycle.unarchive(args.artifact, args.slug), null, 2); - case "export": - if (!args.artifact || !args.path) return "export requires artifact and path"; - return JSON.stringify(await lifecycle.exportBundle(args.artifact, args.path), null, 2); - case "import": - if (!args.path) return "import requires path"; - await ctx.ask({ permission: "artifact_import", patterns: [resolve(args.path)], always: [], metadata: { bundle: resolve(args.path) } }); - return JSON.stringify(await lifecycle.importBundle(args.path), null, 2); + if (error instanceof ArtifactReferenceError) { + return failure(`Artifact reference refused: ${error.message}`, "invalid-reference", "list artifacts and retry with one exact accepted reference"); + } + const message = error instanceof Error ? error.message : "unknown lifecycle failure"; + return failure(`Artifact lifecycle failed: ${message}`, "lifecycle-failed", "fix the reported exact input and retry", "error"); } }, }), @@ -655,6 +793,14 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { }, }; + hooks.config = async (config) => { + config.command ??= {}; + config.command["artifact-reopen"] ??= { + template: "Call artifact_lifecycle with op \"reopen\" and artifact \"$ARGUMENTS\". Require one exact active ID, slug, contained path, or registered URL; do not guess.", + description: "Reopen an exact artifact reference", + }; + }; + if (options?.["proactive"] === true) { const guidance = await proactiveGuidance(); hooks["experimental.chat.system.transform"] = async (_input, output) => { diff --git a/test/cli-latest.test.ts b/test/cli-latest.test.ts new file mode 100644 index 0000000..0d1a6f9 --- /dev/null +++ b/test/cli-latest.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { latestCommand } from "../src/cli.ts"; +import { FilePublisher } from "../src/publisher.ts"; + +test("latest --open remains an injectable standalone reopen fallback", async () => { + const dir = await mkdtemp(join(tmpdir(), "cli-latest-")); + try { + const publisher = new FilePublisher(dir); + await publisher.publish({ slug: "fallback", html: "fallback" }); + const opened: string[] = []; + const path = await latestCommand(["--dir", dir, "--open"], (target) => { opened.push(target); }); + assert.equal(path, join(dir, "fallback.html")); + assert.deepEqual(opened, [path]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/opencode-host-matrix.test.ts b/test/opencode-host-matrix.test.ts index 65d3a3d..05b9420 100644 --- a/test/opencode-host-matrix.test.ts +++ b/test/opencode-host-matrix.test.ts @@ -24,7 +24,10 @@ test("host discovery requires every shipped tool and documented property", () => const ids = Object.keys(ARTIFACT_TOOL_CONTRACT); const tools = Object.entries(ARTIFACT_TOOL_CONTRACT).map(([id, properties]) => ({ id, - parameters: { properties: Object.fromEntries(properties.map((property) => [property, {}])) }, + parameters: { properties: Object.fromEntries(properties.map((property) => [ + property, + id === "artifact_lifecycle" && property === "op" ? { enum: ["list", "reopen"] } : {}, + ])) }, })); assert.doesNotThrow(() => assertArtifactToolContract(ids, tools)); assert.throws( diff --git a/test/opencode-results.test.ts b/test/opencode-results.test.ts new file mode 100644 index 0000000..2dbdf2f --- /dev/null +++ b/test/opencode-results.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ARTIFACT_RESULT_SCHEMA_VERSION, + MAX_TOOL_METADATA_BYTES, + MAX_TOOL_OUTPUT_BYTES, + artifactToolResult, +} from "../src/opencode-results.ts"; + +test("structured artifact results retain legacy text and fixed bounds", () => { + const result = artifactToolResult("Artifact read", "x".repeat(MAX_TOOL_OUTPUT_BYTES * 2), { + schemaVersion: ARTIFACT_RESULT_SCHEMA_VERSION, + operation: "read", + outcome: "success", + artifactId: "11111111-1111-4111-8111-111111111111", + path: `/tmp/${"p".repeat(4000)}`, + nextAction: "inspect the pinned revision", + }); + assert.equal(typeof result.output, "string"); + assert.equal(String(result), result.output); + assert.ok(Buffer.byteLength(result.output, "utf8") <= MAX_TOOL_OUTPUT_BYTES); + assert.ok(Buffer.byteLength(JSON.stringify(result.metadata), "utf8") <= MAX_TOOL_METADATA_BYTES); + assert.equal(Object.keys(result).includes("toString"), false); + assert.equal(result.metadata?.["artifactResult"]?.schemaVersion, 1); +}); diff --git a/test/plugin.test.ts b/test/plugin.test.ts index ddd0332..998b686 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -8,6 +8,8 @@ import { ArtifactsPlugin } from "../src/plugin.ts"; import { FilePublisher } from "../src/publisher.ts"; import { replaceArtifactState } from "../src/artifact-state.ts"; import { emptyArtifactManifestV2, readArtifactManifestV2 } from "../src/artifact-schema.ts"; +import { ArtifactLifecycleStore } from "../src/artifact-lifecycle.ts"; +import { MAX_TOOL_METADATA_BYTES, MAX_TOOL_OUTPUT_BYTES } from "../src/opencode-results.ts"; async function withWorktree(run: (dir: string) => Promise): Promise { const dir = await mkdtemp(join(tmpdir(), "plugin-")); @@ -48,6 +50,12 @@ test("artifact_publish asks permission, publishes, and reports the path", async assert.notEqual(asked[0].always[0], "*"); assert.equal("title" in asked[0].metadata, false); assert.match(String(result), /Artifact published to .*demo-page\.html/); + assert.equal(typeof result, "object"); + if (typeof result === "object") { + assert.equal(result.metadata?.["artifactResult"]?.schemaVersion, 1); + assert.equal(result.metadata?.["artifactResult"]?.operation, "create-or-update"); + assert.equal(result.metadata?.["artifactResult"]?.visibility, "local"); + } const page = await readFile(join(dir, ".opencode", "artifacts", "demo-page.html"), "utf8"); assert.match(page, /

Hello<\/h1>/); @@ -421,6 +429,104 @@ test("plugin lifecycle tool and publish arguments enforce exact updates and arch }); }); +test("lifecycle reopen resolves exact local and registered references with bounded results", async () => { + const opened: string[] = []; + const hooks = await ArtifactsPlugin({} as unknown as PluginInput, { + launcher: async (target: string) => { opened.push(target); }, + }); + const publish = hooks.tool?.artifact_publish; + const lifecycle = hooks.tool?.artifact_lifecycle; + assert.ok(publish && lifecycle); + await withWorktree(async (dir) => { + const ctx: ToolContext = { + sessionID: "s-reopen", + messageID: "m-reopen", + agent: "test", + directory: dir, + worktree: dir, + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + }; + const root = join(dir, ".opencode", "artifacts"); + await mkdir(root, { recursive: true }); + await writeFile(join(root, "manifest.json"), `${JSON.stringify(emptyArtifactManifestV2(), null, 2)}\n`, "utf8"); + const created = await publish.execute({ markdown: "---\ntitle: Reopen Me\n---\n# Exact" }, ctx); + assert.equal(typeof created, "object"); + const manifest = await readArtifactManifestV2(root); + const id = manifest.slugIndex["reopen-me"]; + assert.ok(id); + + const local = await lifecycle.execute({ op: "reopen", artifact: "reopen-me" }, ctx); + assert.deepEqual(opened, [join(root, "reopen-me.html")]); + assert.match(String(local), /"opened"/); + if (typeof local === "object") { + assert.equal(local.metadata?.["artifactResult"]?.operation, "reopen"); + assert.equal(local.metadata?.["artifactResult"]?.artifactId, id); + assert.equal(local.metadata?.["artifactResult"]?.visibility, "local"); + } + + const url = "https://example.test/artifacts/reopen-me.html"; + await new ArtifactLifecycleStore(root).recordDeployment(id, { + capability: "public-static", + target: "test:public", + url, + }); + const remote = await lifecycle.execute({ op: "reopen", artifact: url }, ctx); + assert.deepEqual(opened, [join(root, "reopen-me.html"), url]); + if (typeof remote === "object") assert.equal(remote.metadata?.["artifactResult"]?.url, url); + + const beforeInvalid = opened.length; + const invalid = await lifecycle.execute({ op: "reopen", artifact: "../escape" }, ctx); + assert.equal(opened.length, beforeInvalid); + assert.match(String(invalid), /reference refused/i); + + const read = await lifecycle.execute({ op: "read", artifact: id }, ctx); + assert.equal(typeof read, "object"); + if (typeof read === "object") { + assert.ok(Buffer.byteLength(read.output, "utf8") <= MAX_TOOL_OUTPUT_BYTES); + assert.ok(Buffer.byteLength(JSON.stringify(read.metadata), "utf8") <= MAX_TOOL_METADATA_BYTES); + assert.match(String(read.metadata?.["artifactResult"]?.path), /\/revisions\//); + } + }); +}); + +test("reopen launcher failures and command conflicts remain recoverable", async () => { + const hooks = await ArtifactsPlugin({} as unknown as PluginInput, { + launcher: async () => { throw new Error("launcher unavailable"); }, + }); + const publish = hooks.tool?.artifact_publish; + const lifecycle = hooks.tool?.artifact_lifecycle; + assert.ok(publish && lifecycle && hooks.config); + await withWorktree(async (dir) => { + const ctx: ToolContext = { + sessionID: "s-launch-fail", + messageID: "m-launch-fail", + agent: "test", + directory: dir, + worktree: dir, + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + }; + const root = join(dir, ".opencode", "artifacts"); + await mkdir(root, { recursive: true }); + await writeFile(join(root, "manifest.json"), `${JSON.stringify(emptyArtifactManifestV2(), null, 2)}\n`, "utf8"); + await publish.execute({ markdown: "---\ntitle: Launch Fail\n---\n# Exact" }, ctx); + const result = await lifecycle.execute({ op: "reopen", artifact: "launch-fail" }, ctx); + assert.match(String(result), /launcher accepted/); + if (typeof result === "object") assert.equal(result.metadata?.["artifactResult"]?.error, "launch-failed"); + }); + + const config: Parameters>[0] = { command: {} }; + await hooks.config(config); + assert.match(config.command?.["artifact-reopen"]?.template ?? "", /artifact_lifecycle/); + assert.match(config.command?.["artifact-reopen"]?.template ?? "", /\$ARGUMENTS/); + config.command!["artifact-reopen"] = { template: "user-owned" }; + await hooks.config(config); + assert.equal(config.command?.["artifact-reopen"]?.template, "user-owned"); +}); + test("artifact_comments lists threads and resolves by id", async () => { const hooks = await ArtifactsPlugin({} as unknown as PluginInput); const comments = hooks.tool?.artifact_comments; From 07b28833571a5718e88f4a86fb6e0d084d2bc00a Mon Sep 17 00:00:00 2001 From: bitgorust Date: Tue, 18 Aug 2026 04:51:37 +0200 Subject: [PATCH 6/8] feat: install native OpenCode skill --- README.md | 21 ++- scripts/opencode-host-matrix.ts | 94 ++++++++++ specs/current/opencode-integration.spec.md | 9 + src/cli.ts | 21 ++- src/skill-installer.ts | 197 +++++++++++++++++++++ test/skill-installer.test.ts | 142 +++++++++++++++ 6 files changed, 481 insertions(+), 3 deletions(-) create mode 100644 src/skill-installer.ts create mode 100644 test/skill-installer.test.ts diff --git a/README.md b/README.md index 879eaf2..2787f5f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,23 @@ dependencies automatically at startup: } ``` +Install the bundled native skill explicitly after the plugin. Project scope is the safest +default and writes only `.opencode/skills/artifact-pages` in the current project: + +```bash +npx opencode-artifacts skill install --project +# or, when you intentionally want it for every project: +npx opencode-artifacts skill install --global +``` + +The global destination is `~/.config/opencode/skills/artifact-pages`. Repeating an identical +install is a no-op. A differing destination is left unchanged; intentional replacement must +name it exactly, for example +`--force /absolute/project/.opencode/skills/artifact-pages`, and retains the prior directory as +a sibling backup. Remove an installed skill manually only after reviewing that exact directory. +If installation fails, fix the reported source, parent, symlink, or collision layer and rerun; +the installer never relies on a repository checkout or writes from package postinstall. + The `file:` spec is only for developing this plugin itself (point OpenCode at your checkout after `npm install && npm run build`): @@ -134,8 +151,8 @@ plugin option: This injects the bundled guidance (adapted from Claude Code's artifact-design skill) into the session's system context — visible in the plugin source, off by default, and removable by -deleting the option. Alternative for non-plugin environments: `cp -r skills/artifact-pages -~/.agents/skills/` (don't use both). +deleting the option. Native on-demand discovery from the explicit skill installer is separate; +do not enable proactive injection merely to make the skill discoverable. Comment triage at scale: `agents/artifact-comment-analyst.md` is a read-only subagent that digests open comment threads into an actionable brief (blocking issues first, with the page's diff --git a/scripts/opencode-host-matrix.ts b/scripts/opencode-host-matrix.ts index 5536097..984bb3a 100644 --- a/scripts/opencode-host-matrix.ts +++ b/scripts/opencode-host-matrix.ts @@ -87,6 +87,21 @@ export interface MatrixEvidence { filesystemUnchanged: boolean; executionBoundary: "exact-packed-module"; }; + skill: { + install: CommandResult; + destination: string; + sourcePackageRemoved: true; + files: Array<{ path: string; sha256: string; bytes: number }>; + hosts: Array<{ + hostVersion: string; + name: "artifact-pages"; + description: string; + location: string; + contentSha256: string; + contentBytes: number; + logs: string; + }>; + }; result: "pass"; } @@ -334,6 +349,53 @@ async function readOnlyPackedSmoke(pluginDirectory: string, project: string): Pr }; } +async function probeNativeSkill(input: { + hostVersion: string; + hostBinary: string; + project: string; + envRoot: string; + destination: string; +}): Promise { + const env = { ...cleanEnvironment(input.envRoot), OPENCODE_DISABLE_EXTERNAL_SKILLS: "true" }; + await Promise.all(Object.values(cleanEnvironment(input.envRoot)).map((path) => mkdir(path, { recursive: true }))); + const child = spawn(input.hostBinary, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { + cwd: input.project, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + const server = await waitForServer(child); + try { + const health = await fetchJson(`${server.url}/global/health`); + if (!isRecord(health) || health["healthy"] !== true || health["version"] !== input.hostVersion) { + throw new Error(`unexpected native-skill host health: ${JSON.stringify(health)}`); + } + const skills = await fetchJson(`${server.url}/skill`); + if (!Array.isArray(skills)) throw new Error("native skill endpoint did not return an array"); + const match = skills.find((value) => isRecord(value) && value["name"] === "artifact-pages"); + if (!isRecord(match) || typeof match["description"] !== "string" || typeof match["location"] !== "string" || typeof match["content"] !== "string") { + throw new Error("stable host did not advertise and load artifact-pages"); + } + const expectedLocation = join(input.destination, "SKILL.md"); + if (resolve(match["location"]) !== resolve(expectedLocation)) { + throw new Error(`stable host selected an unexpected artifact-pages location: ${match["location"]}`); + } + if (!match["content"].includes("# Artifact Pages") || !match["content"].includes("reference/components.md")) { + throw new Error("stable host returned incomplete artifact-pages content"); + } + return { + hostVersion: input.hostVersion, + name: "artifact-pages", + description: match["description"], + location: match["location"], + contentSha256: createHash("sha256").update(match["content"], "utf8").digest("hex"), + contentBytes: Buffer.byteLength(match["content"], "utf8"), + logs: server.logs(), + }; + } finally { + await stopServer(child); + } +} + function requiredArgument(name: string): string { const index = process.argv.indexOf(name); const value = index === -1 ? undefined : process.argv[index + 1]; @@ -372,6 +434,7 @@ export async function runMatrix(tarballInput: string, outputInput: string): Prom const pluginUrl = pathToFileURL(pluginDirectory).href; const matrix = exactStableMatrix(currentStable, OLDEST_TESTED_OPENCODE_VERSION); const hosts: Array<{ version: string; command: CommandResult }> = []; + const hostBinaries: Array<{ version: string; binary: string }> = []; const cliPlugins: Array<{ version: string; command: CommandResult }> = []; const routes: ServerResult[] = []; for (const version of matrix.versions) { @@ -383,6 +446,7 @@ export async function runMatrix(tarballInput: string, outputInput: string): Prom const hostInstall = await runCommand("npm", ["install", "--prefix", hostRoot, "--no-audit", "--no-fund", `opencode-ai@${version}`], { cwd: work }); hosts.push({ version, command: hostInstall }); const hostBinary = join(hostRoot, "node_modules", ".bin", process.platform === "win32" ? "opencode.cmd" : "opencode"); + hostBinaries.push({ version, binary: hostBinary }); const cliEnvRoot = join(work, `cli-env-${versionKey}`); await Promise.all(Object.values(cleanEnvironment(cliEnvRoot)).map((path) => mkdir(path, { recursive: true }))); const cliPlugin = await runCommand(hostBinary, ["plugin", pluginUrl], { cwd: cliProject, env: cleanEnvironment(cliEnvRoot) }); @@ -395,6 +459,29 @@ export async function runMatrix(tarballInput: string, outputInput: string): Prom ); } const smoke = await readOnlyPackedSmoke(pluginDirectory, join(work, "smoke-project")); + const skillProject = join(work, "skill-project"); + await mkdir(skillProject, { recursive: true }); + const skillInstall = await runCommand(process.execPath, [join(pluginDirectory, "dist", "cli.js"), "skill", "install", "--project"], { cwd: skillProject }); + const installedSkill = JSON.parse(skillInstall.output.trim()) as unknown; + if (!isRecord(installedSkill) || installedSkill["status"] !== "installed" || typeof installedSkill["destination"] !== "string") { + throw new Error(`packed skill installer returned an unexpected result: ${skillInstall.output}`); + } + const skillDestination = installedSkill["destination"]; + const skillFiles = await Promise.all(["SKILL.md", "reference/components.md", "reference/visuals.md"].map(async (path) => { + const bytes = await readFile(join(skillDestination, ...path.split("/"))); + return { path, sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length }; + })); + await rm(packageRoot, { recursive: true, force: true }); + const skillHosts = [] as MatrixEvidence["skill"]["hosts"]; + for (const host of hostBinaries) { + skillHosts.push(await probeNativeSkill({ + hostVersion: host.version, + hostBinary: host.binary, + project: skillProject, + envRoot: join(work, `skill-env-${host.version.replaceAll(".", "-")}`), + destination: skillDestination, + })); + } const evidence: MatrixEvidence = { schemaVersion: 1, generatedAt: new Date().toISOString(), @@ -421,6 +508,13 @@ export async function runMatrix(tarballInput: string, outputInput: string): Prom install: { package: packageInstall, currentResolution, hosts, cliPlugins }, routes, smoke, + skill: { + install: skillInstall, + destination: skillDestination, + sourcePackageRemoved: true, + files: skillFiles, + hosts: skillHosts, + }, result: "pass", }; await mkdir(resolve(output, ".."), { recursive: true }); diff --git a/specs/current/opencode-integration.spec.md b/specs/current/opencode-integration.spec.md index 516ad0e..38105be 100644 --- a/specs/current/opencode-integration.spec.md +++ b/specs/current/opencode-integration.spec.md @@ -34,6 +34,12 @@ command untouched. Stable results use `{title, output, metadata}` with a bounded version-1 `metadata.artifactResult`; the non-enumerable string conversion returns the same `output` for direct legacy callers. +- `OC-05`, `DIST-01`, `DIST-02`: `opencode-artifacts skill install --project|--global` + installs only the reviewed packed `artifact-pages` body and two references into official + `.opencode/skills` or `~/.config/opencode/skills` discovery. It is explicit, atomic, and + byte-idempotent; no postinstall writes occur. Differing or symlinked destinations refuse. + Force replacement requires the exact destination and retains the prior directory as a + sibling backup. Proactive plugin injection remains a separate opt-in. ## Evidence boundary @@ -51,3 +57,6 @@ - Packed discovery requires the `reopen` enum member and the effective injected command. `opencode-artifacts latest --open` is tested separately with an injectable launcher and does not depend on the host config hook. +- Packed-host evidence runs the shipped CLI from the extracted tarball, removes its source + package tree, then requires stable `/skill` discovery to return the installed name, + description, exact location, and loaded body; reference hashes are retained separately. diff --git a/src/cli.ts b/src/cli.ts index fd20650..429a57b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,6 +32,7 @@ import { planArtifactMigration, rollbackArtifactMigration, } from "./artifact-migration.ts"; +import { installBundledSkill } from "./skill-installer.ts"; const DEFAULT_DIR = join(".opencode", "artifacts"); @@ -54,7 +55,8 @@ function usage(): never { opencode-artifacts migrate rollback --migration-id [--dir ] opencode-artifacts deploy --repo [--dir ] [--branch ] [--force] opencode-artifacts deploy --target cloudflare --name [--dir ] [--force] - opencode-artifacts init [--global] [--target github|cloudflare] [--repo ] [--worker-name ] [--yes]`); + opencode-artifacts init [--global] [--target github|cloudflare] [--repo ] [--worker-name ] [--yes] + opencode-artifacts skill install --project|--global [--force ]`); process.exit(2); } @@ -235,6 +237,21 @@ export async function latestCommand( return path; } +export async function skillCommand(args: string[]): Promise { + if (args[0] !== "install") usage(); + const project = args.includes("--project"); + const global = args.includes("--global"); + if (project === global) usage(); + const forceDestination = optionValue(args, "--force"); + if (args.includes("--force") && (!forceDestination || forceDestination.startsWith("--"))) usage(); + const result = await installBundledSkill({ + scope: project ? "project" : "global", + ...(project ? { projectRoot: process.cwd() } : {}), + ...(forceDestination === undefined ? {} : { forceDestination }), + }); + console.log(JSON.stringify(result, null, 2)); +} + async function stateCommand(args: string[]): Promise { const [slug] = positional(args, ["--dir"]); const dir = optionValue(args, "--dir") ?? DEFAULT_DIR; if (!slug) usage(); @@ -482,6 +499,8 @@ export async function main(argv: string[]): Promise { return deployCommand(rest); case "init": return initCommand(rest); + case "skill": + return skillCommand(rest); default: usage(); } diff --git a/src/skill-installer.ts b/src/skill-installer.ts new file mode 100644 index 0000000..c135b4d --- /dev/null +++ b/src/skill-installer.ts @@ -0,0 +1,197 @@ +import { createHash, randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; + +export const BUNDLED_SKILL_FILES = [ + "SKILL.md", + "reference/components.md", + "reference/visuals.md", +] as const; + +export type SkillInstallScope = "project" | "global"; + +export interface SkillInstallOptions { + scope: SkillInstallScope; + projectRoot?: string; + homeRoot?: string; + sourceRoot?: string; + forceDestination?: string; +} + +export interface SkillInstallResult { + schemaVersion: 1; + status: "installed" | "unchanged" | "replaced"; + destination: string; + files: string[]; + digest: string; + backup?: string; + removal: string; +} + +interface SkillSnapshot { + files: Map; + digest: string; +} + +function errnoCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +export function bundledSkillSource(): string { + return join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "artifact-pages"); +} + +export function skillDestination(options: Pick): string { + if (options.scope === "project") { + return join(resolve(options.projectRoot ?? process.cwd()), ".opencode", "skills", "artifact-pages"); + } + return join(resolve(options.homeRoot ?? homedir()), ".config", "opencode", "skills", "artifact-pages"); +} + +async function assertRealDirectory(path: string, label: string): Promise { + const info = await lstat(path); + if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`${label} must be a real directory: ${path}`); +} + +async function snapshotSource(source: string): Promise { + await assertRealDirectory(source, "bundled skill source"); + const files = await installedFiles(source); + if (!files) throw new Error(`bundled skill source is missing: ${source}`); + const expected = new Set(BUNDLED_SKILL_FILES); + if (files.size !== expected.size || [...files.keys()].some((file) => !expected.has(file))) { + throw new Error("bundled skill inventory differs from the reviewed file set"); + } + const hash = createHash("sha256"); + for (const file of BUNDLED_SKILL_FILES) { + const bytes = files.get(file); + if (!bytes) throw new Error(`bundled skill file is missing: ${file}`); + if (bytes.length > 512 * 1024) throw new Error(`bundled skill file exceeds 512 KiB: ${file}`); + hash.update(file).update("\0").update(bytes).update("\0"); + } + return { files, digest: hash.digest("hex") }; +} + +async function assertSafeParents(root: string, destination: string): Promise { + await assertRealDirectory(root, "install scope root"); + const rel = relative(root, destination); + if (rel === "" || rel.startsWith(`..${sep}`) || rel === ".." || rel.includes(`..${sep}`)) { + throw new Error(`skill destination escapes its selected scope: ${destination}`); + } + let current = root; + for (const segment of rel.split(sep).slice(0, -1)) { + current = join(current, segment); + try { + const info = await lstat(current); + if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`skill destination parent is unsafe: ${current}`); + } catch (error) { + if (errnoCode(error) !== "ENOENT") throw error; + break; + } + } +} + +async function installedFiles(destination: string): Promise | undefined> { + try { + const info = await lstat(destination); + if (info.isSymbolicLink() || !info.isDirectory()) throw new Error(`skill destination is unsafe: ${destination}`); + } catch (error) { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + } + const files = new Map(); + const visit = async (directory: string, prefix: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`; + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error(`installed skill contains a symlink: ${relativePath}`); + if (entry.isDirectory()) { + await visit(path, relativePath); + } else if (entry.isFile()) { + files.set(relativePath, await readFile(path)); + } else { + throw new Error(`installed skill contains an unsupported entry: ${relativePath}`); + } + } + }; + await visit(destination, ""); + return files; +} + +function sameSnapshot(actual: Map, expected: SkillSnapshot): boolean { + if (actual.size !== expected.files.size) return false; + for (const [file, bytes] of expected.files) { + const installed = actual.get(file); + if (!installed || !installed.equals(bytes)) return false; + } + return true; +} + +async function writeStaging(parent: string, snapshot: SkillSnapshot): Promise { + const staging = join(parent, `.artifact-pages.install-${randomUUID()}`); + await mkdir(staging, { recursive: false }); + try { + for (const [file, bytes] of snapshot.files) { + const path = join(staging, ...file.split("/")); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes, { flag: "wx" }); + } + return staging; + } catch (error) { + await rm(staging, { recursive: true, force: true }); + throw error; + } +} + +export async function installBundledSkill(options: SkillInstallOptions): Promise { + const source = resolve(options.sourceRoot ?? bundledSkillSource()); + const destination = skillDestination(options); + const scopeRoot = resolve(options.scope === "project" ? options.projectRoot ?? process.cwd() : options.homeRoot ?? homedir()); + const snapshot = await snapshotSource(source); + await assertSafeParents(scopeRoot, destination); + const existing = await installedFiles(destination); + const removal = `Remove manually only after review: ${destination}`; + if (existing && sameSnapshot(existing, snapshot)) { + return { schemaVersion: 1, status: "unchanged", destination, files: [...BUNDLED_SKILL_FILES], digest: snapshot.digest, removal }; + } + if (existing && resolve(options.forceDestination ?? "") !== destination) { + throw new Error(`skill destination differs and was left unchanged: ${destination}; retry with --force ${destination}`); + } + const parent = dirname(destination); + await mkdir(parent, { recursive: true }); + await assertSafeParents(scopeRoot, destination); + const staging = await writeStaging(parent, snapshot); + let backup: string | undefined; + try { + if (existing) { + backup = join(parent, `.artifact-pages.backup-${randomUUID()}`); + await rename(destination, backup); + } + try { + await rename(staging, destination); + } catch (error) { + if (backup) { + try { + await rename(backup, destination); + } catch (restoreError) { + throw new AggregateError([error, restoreError], `skill replacement failed and backup recovery requires manual action: ${backup}`); + } + } + throw error; + } + } finally { + await rm(staging, { recursive: true, force: true }); + } + return { + schemaVersion: 1, + status: existing ? "replaced" : "installed", + destination, + files: [...BUNDLED_SKILL_FILES], + digest: snapshot.digest, + ...(backup === undefined ? {} : { backup }), + removal, + }; +} diff --git a/test/skill-installer.test.ts b/test/skill-installer.test.ts new file mode 100644 index 0000000..65c9d05 --- /dev/null +++ b/test/skill-installer.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { cp, lstat, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + BUNDLED_SKILL_FILES, + bundledSkillSource, + installBundledSkill, + skillDestination, +} from "../src/skill-installer.ts"; + +async function temporaryRoot(prefix: string): Promise { + return await mkdtemp(join(tmpdir(), prefix)); +} + +test("project skill installation is packed-byte complete and idempotent", async () => { + const root = await temporaryRoot("skill-project-"); + const source = join(root, "packed", "artifact-pages"); + const project = join(root, "project"); + try { + await cp(bundledSkillSource(), source, { recursive: true }); + await mkdir(project, { recursive: true }); + const first = await installBundledSkill({ scope: "project", projectRoot: project, sourceRoot: source }); + assert.equal(first.status, "installed"); + assert.equal(first.destination, join(project, ".opencode", "skills", "artifact-pages")); + assert.deepEqual(first.files, [...BUNDLED_SKILL_FILES]); + const second = await installBundledSkill({ scope: "project", projectRoot: project, sourceRoot: source }); + assert.equal(second.status, "unchanged"); + assert.equal(second.digest, first.digest); + await rm(join(root, "packed"), { recursive: true, force: true }); + for (const file of BUNDLED_SKILL_FILES) { + assert.ok((await readFile(join(first.destination, ...file.split("/")))).length > 0); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("global install uses the official OpenCode config skill directory", async () => { + const home = await temporaryRoot("skill-global-"); + try { + const result = await installBundledSkill({ scope: "global", homeRoot: home }); + assert.equal(result.destination, join(home, ".config", "opencode", "skills", "artifact-pages")); + assert.equal(result.destination, skillDestination({ scope: "global", homeRoot: home })); + assert.equal(result.status, "installed"); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test("differing skills are preserved unless force names the exact directory", async () => { + const project = await temporaryRoot("skill-collision-"); + try { + const installed = await installBundledSkill({ scope: "project", projectRoot: project }); + const skillFile = join(installed.destination, "SKILL.md"); + await writeFile(skillFile, "user customization\n", "utf8"); + await assert.rejects( + installBundledSkill({ scope: "project", projectRoot: project }), + /left unchanged/, + ); + assert.equal(await readFile(skillFile, "utf8"), "user customization\n"); + await assert.rejects( + installBundledSkill({ scope: "project", projectRoot: project, forceDestination: join(project, "wrong") }), + /left unchanged/, + ); + const replaced = await installBundledSkill({ + scope: "project", + projectRoot: project, + forceDestination: installed.destination, + }); + assert.equal(replaced.status, "replaced"); + assert.ok(replaced.backup); + assert.equal(await readFile(join(replaced.backup, "SKILL.md"), "utf8"), "user customization\n"); + assert.match(await readFile(skillFile, "utf8"), /^---\nname: artifact-pages/m); + } finally { + await rm(project, { recursive: true, force: true }); + } +}); + +test("destination and parent symlinks are refused even with force", async (context) => { + if (process.platform === "win32") { + context.skip("symlink creation requires platform-specific privileges on Windows"); + return; + } + const root = await temporaryRoot("skill-symlink-"); + try { + const project = join(root, "project"); + const outside = join(root, "outside"); + await mkdir(join(project, ".opencode", "skills"), { recursive: true }); + await mkdir(outside, { recursive: true }); + const destination = join(project, ".opencode", "skills", "artifact-pages"); + await symlink(outside, destination, "dir"); + await assert.rejects( + installBundledSkill({ scope: "project", projectRoot: project, forceDestination: destination }), + /destination is unsafe/, + ); + assert.equal((await readdirNames(outside)).length, 0); + + const second = join(root, "second"); + await mkdir(join(second, ".opencode"), { recursive: true }); + await symlink(outside, join(second, ".opencode", "skills"), "dir"); + await assert.rejects( + installBundledSkill({ scope: "project", projectRoot: second }), + /parent is unsafe/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function readdirNames(path: string): Promise { + const { readdir } = await import("node:fs/promises"); + return await readdir(path); +} + +test("missing or unexpected packed inventory creates no partial destination", async () => { + const root = await temporaryRoot("skill-inventory-"); + const source = join(root, "source"); + const project = join(root, "project"); + try { + await cp(bundledSkillSource(), source, { recursive: true }); + await writeFile(join(source, "unexpected.md"), "unexpected", "utf8"); + await mkdir(project, { recursive: true }); + await assert.rejects( + installBundledSkill({ scope: "project", projectRoot: project, sourceRoot: source }), + /inventory differs/, + ); + await assert.rejects(lstat(skillDestination({ scope: "project", projectRoot: project }))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("bundled skill carries official bounded discovery frontmatter", async () => { + const body = await readFile(join(bundledSkillSource(), "SKILL.md"), "utf8"); + const frontmatter = body.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ""; + assert.match(frontmatter, /^name: artifact-pages$/m); + const description = frontmatter.match(/^description: (.+)$/m)?.[1] ?? ""; + assert.ok(description.length > 20 && description.length <= 1024); + assert.doesNotMatch(frontmatter, / Date: Tue, 18 Aug 2026 04:51:53 +0200 Subject: [PATCH 7/8] docs: archive Goal 4 OpenCode packets --- docs/evidence/opencode-host-verification.md | 46 ++++++++++++++++--- .../change.json | 10 ++-- .../delta.md | 0 .../evidence.md | 6 +-- .../proposal.md | 0 .../tasks.md | 4 +- .../change.json | 13 ++++-- .../delta.md | 0 .../design.md | 0 .../evidence.md | 0 .../proposal.md | 0 .../tasks.md | 2 +- .../change.json | 16 +++++-- .../delta.md | 0 .../evidence.md | 15 +++--- .../proposal.md | 0 .../tasks.md | 9 ++++ .../change.json | 16 +++++-- .../delta.md | 0 .../evidence.md | 7 +-- .../proposal.md | 0 .../tasks.md | 2 +- .../opencode-skill-distribution/tasks.md | 9 ---- 23 files changed, 108 insertions(+), 47 deletions(-) rename specs/{changes/opencode-lifecycle-results => archive/2026-08-18-opencode-lifecycle-results}/change.json (78%) rename specs/{changes/opencode-lifecycle-results => archive/2026-08-18-opencode-lifecycle-results}/delta.md (100%) rename specs/{changes/opencode-lifecycle-results => archive/2026-08-18-opencode-lifecycle-results}/evidence.md (83%) rename specs/{changes/opencode-lifecycle-results => archive/2026-08-18-opencode-lifecycle-results}/proposal.md (100%) rename specs/{changes/opencode-lifecycle-results => archive/2026-08-18-opencode-lifecycle-results}/tasks.md (82%) rename specs/{changes/opencode-permission-contracts => archive/2026-08-18-opencode-permission-contracts}/change.json (65%) rename specs/{changes/opencode-permission-contracts => archive/2026-08-18-opencode-permission-contracts}/delta.md (100%) rename specs/{changes/opencode-permission-contracts => archive/2026-08-18-opencode-permission-contracts}/design.md (100%) rename specs/{changes/opencode-permission-contracts => archive/2026-08-18-opencode-permission-contracts}/evidence.md (100%) rename specs/{changes/opencode-permission-contracts => archive/2026-08-18-opencode-permission-contracts}/proposal.md (100%) rename specs/{changes/opencode-permission-contracts => archive/2026-08-18-opencode-permission-contracts}/tasks.md (90%) rename specs/{changes/opencode-skill-distribution => archive/2026-08-18-opencode-skill-distribution}/change.json (58%) rename specs/{changes/opencode-skill-distribution => archive/2026-08-18-opencode-skill-distribution}/delta.md (100%) rename specs/{changes/opencode-skill-distribution => archive/2026-08-18-opencode-skill-distribution}/evidence.md (51%) rename specs/{changes/opencode-skill-distribution => archive/2026-08-18-opencode-skill-distribution}/proposal.md (100%) create mode 100644 specs/archive/2026-08-18-opencode-skill-distribution/tasks.md rename specs/{changes/packed-opencode-host-matrix => archive/2026-08-18-packed-opencode-host-matrix}/change.json (60%) rename specs/{changes/packed-opencode-host-matrix => archive/2026-08-18-packed-opencode-host-matrix}/delta.md (100%) rename specs/{changes/packed-opencode-host-matrix => archive/2026-08-18-packed-opencode-host-matrix}/evidence.md (88%) rename specs/{changes/packed-opencode-host-matrix => archive/2026-08-18-packed-opencode-host-matrix}/proposal.md (100%) rename specs/{changes/packed-opencode-host-matrix => archive/2026-08-18-packed-opencode-host-matrix}/tasks.md (92%) delete mode 100644 specs/changes/opencode-skill-distribution/tasks.md diff --git a/docs/evidence/opencode-host-verification.md b/docs/evidence/opencode-host-verification.md index ce4986b..acbaa2d 100644 --- a/docs/evidence/opencode-host-verification.md +++ b/docs/evidence/opencode-host-verification.md @@ -80,11 +80,10 @@ Host: Ubuntu 24.04.4 LTS, Linux 6.8.0-137-generic x86_64. Node: 24.19.0. OpenCode stable current/oldest-tested: exact 1.18.18. V2 beta was excluded. No model provider or inference endpoint was called. -The candidate was built and packed once, then installed from -`opencode-artifacts-0.15.0.tgz` into an empty dependency prefix with lifecycle scripts -disabled. The initial verified candidate SHA-256 was -`901a1ee6439dd97a0142d63aacecdebd67d2660f4a8ad37a96c559e42a4dd5cf`; Goal 4's final -gate reruns this command and replaces the digest if later packet work changes shipped bytes. +The final Goal 4 candidate was built and packed once, then installed from +`opencode-artifacts-0.15.0.tgz` into an empty dependency prefix with candidate lifecycle +scripts disabled. It contained 69 files, measured 123,449 bytes, and had SHA-256 +`df778eafd2cd17b6f2674224af85767089024d326765f34c5e10bbb60307e091`. The first attempted shortcut—placing the `.tgz` URL directly in the `plugin` array or passing the tarball path directly to `opencode plugin`—was rejected as evidence. Stable OpenCode treats @@ -118,7 +117,7 @@ exercises safe shipped tool code without asking a provider model to select a too recorded one deduplicated version cell because current stable and oldest-tested are identical; it explicitly sets `broaderRangeProven: false`. -The local JSON result is `/tmp/goal4-newpack/opencode-host-matrix.json`; CI generates the same +The local JSON result is `/tmp/goal4-finalpack/opencode-host-matrix.json`; CI generates the same record as `release-evidence/opencode-host-matrix.json` and retains it with the exact tarball. The `/tmp` path is transient and is not itself release evidence. Bare registry-coordinate verification of future candidate bytes remains a post-publication gate; the dated published- @@ -149,3 +148,38 @@ denial at every transition, no-write/no-runner result, bounded metadata, and aut are exercised by `test/plugin.test.ts`, `test/opencode-permissions.test.ts`, and the bounded model in `test/model/opencode-permission-model.ts`. CI repeats the effective-config assertion inside the packed config-array cell. + +### Lifecycle result and reopen observation + +Both packed routes advertised the prior lifecycle operations plus additive `reopen`. Effective +configuration contained `/artifact-reopen` with the exact-reference prompt template. The +packed direct `list` smoke returned the legacy-readable JSON text and left its fixture +unchanged; unit/contract tests bind the stable `{title, output, metadata}` result, schema-1 +`metadata.artifactResult`, 16 KiB output cap, 8 KiB metadata cap, exact reopen selection, and +launch-failure refusal. Provider-free server discovery cannot select and execute a model tool, +so exact launcher behavior remains injected integration evidence rather than a claimed native +provider turn. + +### Native skill install and load observation + +The final harness executed the CLI shipped in the extracted tarball: + +```text +opencode-artifacts skill install --project +``` + +It installed exactly these bytes into `.opencode/skills/artifact-pages`: + +| File | Bytes | SHA-256 | +|---|---:|---| +| `SKILL.md` | 6,454 | `1f8a6377b312d4f7b3759d897e4901cdbc8c82aeb39c60e788913db3e961a117` | +| `reference/components.md` | 5,047 | `9096365cd1ca3d74fbd86c09051cbca083606839cc12a5431900237f5326df0a` | +| `reference/visuals.md` | 2,280 | `0228e8d7f496da912e9ba1102bcccb9a82584c1b1ae84f5a9d5734e4a8dd1b99` | + +The harness then removed the entire installed candidate package tree and started a fresh stable +host with external compatibility skill roots disabled. Native `GET /skill` returned exactly +the project-installed `artifact-pages` location, its complete description, and 5,961-byte body +with SHA-256 `86dcbaa3b04513cdc08cb60f715419acf1147f7d52cfddc3759f81b7c77589af`. +This proves official-path advertisement and native body loading without a checkout, proactive +plugin injection, or provider inference. Reference files were independently readable and +hash-bound after source removal. diff --git a/specs/changes/opencode-lifecycle-results/change.json b/specs/archive/2026-08-18-opencode-lifecycle-results/change.json similarity index 78% rename from specs/changes/opencode-lifecycle-results/change.json rename to specs/archive/2026-08-18-opencode-lifecycle-results/change.json index 4727efc..ddeb4e9 100644 --- a/specs/changes/opencode-lifecycle-results/change.json +++ b/specs/archive/2026-08-18-opencode-lifecycle-results/change.json @@ -3,8 +3,12 @@ "id": "opencode-lifecycle-results", "title": "Expose bounded lifecycle results and reopen", "lane": "standard", - "status": "approved", - "affectedRequirements": ["UX-01", "LIFE-06", "COMPAT-05"], + "status": "archived", + "affectedRequirements": [ + "UX-01", + "LIFE-06", + "COMPAT-05" + ], "currentSpecs": [ "specs/current/artifact-lifecycle.spec.md", "specs/current/opencode-integration.spec.md" @@ -20,5 +24,5 @@ "reason": "" }, "createdAt": "2026-08-18", - "archivedAt": null + "archivedAt": "2026-08-18" } diff --git a/specs/changes/opencode-lifecycle-results/delta.md b/specs/archive/2026-08-18-opencode-lifecycle-results/delta.md similarity index 100% rename from specs/changes/opencode-lifecycle-results/delta.md rename to specs/archive/2026-08-18-opencode-lifecycle-results/delta.md diff --git a/specs/changes/opencode-lifecycle-results/evidence.md b/specs/archive/2026-08-18-opencode-lifecycle-results/evidence.md similarity index 83% rename from specs/changes/opencode-lifecycle-results/evidence.md rename to specs/archive/2026-08-18-opencode-lifecycle-results/evidence.md index cc91853..921959b 100644 --- a/specs/changes/opencode-lifecycle-results/evidence.md +++ b/specs/archive/2026-08-18-opencode-lifecycle-results/evidence.md @@ -8,19 +8,19 @@ Do not hide failed or excluded results. - Validation: users and models see exact identity, revision, capability, and next action. - Verification: fixed output/metadata byte limits, publish/lifecycle schema-1 envelopes, typed refusals, exact immutable read paths, and legacy string conversion are contract-tested. -- Result: implementation pass; final packed-host command observation remains pending. +- Result: pass; final packed 1.18.18 host exposed the schema and injected command. - Evidence: [@test](test/plugin.test.ts) ## Requirement: LIFE-06 - Validation: reopen joins the existing list/read/status/archive lifecycle surface. - Verification: injected launchers receive exact local paths and registered URLs; invalid references and launch failures invoke no accepted launcher. CLI latest/open remains tested. -- Result: pass in plugin and CLI integration tests; packed stable-host observation pending. +- Result: pass in plugin/CLI tests and final packed stable-host discovery. - Evidence: [@test](test/artifact-lifecycle.test.ts) ## Requirement: COMPAT-05 - Validation: old callers retain accepted arguments and actionable deprecation output. - Verification: old operations/arguments still execute, prior JSON/text remains in `output` and `String(result)`, while packed schema assertions now require additive `reopen`. -- Result: implementation pass; final candidate schema snapshot pending. +- Result: pass; final candidate schema snapshot retained additive old operations plus reopen. - Evidence: [@test](test/plugin.test.ts) diff --git a/specs/changes/opencode-lifecycle-results/proposal.md b/specs/archive/2026-08-18-opencode-lifecycle-results/proposal.md similarity index 100% rename from specs/changes/opencode-lifecycle-results/proposal.md rename to specs/archive/2026-08-18-opencode-lifecycle-results/proposal.md diff --git a/specs/changes/opencode-lifecycle-results/tasks.md b/specs/archive/2026-08-18-opencode-lifecycle-results/tasks.md similarity index 82% rename from specs/changes/opencode-lifecycle-results/tasks.md rename to specs/archive/2026-08-18-opencode-lifecycle-results/tasks.md index fa43eb4..d1d032f 100644 --- a/specs/changes/opencode-lifecycle-results/tasks.md +++ b/specs/archive/2026-08-18-opencode-lifecycle-results/tasks.md @@ -6,5 +6,5 @@ - [x] Add exact-reference `reopen` to `artifact_lifecycle` with an injectable launcher. - [x] Inject and verify `/artifact-reopen` only through the supported stable config hook. - [x] Test old spellings, large results, invalid references, and CLI fallback behavior. -- [ ] Record host evidence and update lifecycle/OpenCode current specs. -- [ ] Run repository validation and archive the packet. +- [x] Record host evidence and update lifecycle/OpenCode current specs. +- [x] Run repository validation and archive the packet. diff --git a/specs/changes/opencode-permission-contracts/change.json b/specs/archive/2026-08-18-opencode-permission-contracts/change.json similarity index 65% rename from specs/changes/opencode-permission-contracts/change.json rename to specs/archive/2026-08-18-opencode-permission-contracts/change.json index 71ae1fe..0528556 100644 --- a/specs/changes/opencode-permission-contracts/change.json +++ b/specs/archive/2026-08-18-opencode-permission-contracts/change.json @@ -3,9 +3,14 @@ "id": "opencode-permission-contracts", "title": "Separate OpenCode artifact permissions", "lane": "high-risk", - "status": "approved", - "affectedRequirements": ["UX-03", "OC-06"], - "currentSpecs": ["specs/current/opencode-integration.spec.md"], + "status": "archived", + "affectedRequirements": [ + "UX-03", + "OC-06" + ], + "currentSpecs": [ + "specs/current/opencode-integration.spec.md" + ], "currentSpecsUpdated": true, "approval": { "by": "aaron.zeng", @@ -17,5 +22,5 @@ "reason": "" }, "createdAt": "2026-08-18", - "archivedAt": null + "archivedAt": "2026-08-18" } diff --git a/specs/changes/opencode-permission-contracts/delta.md b/specs/archive/2026-08-18-opencode-permission-contracts/delta.md similarity index 100% rename from specs/changes/opencode-permission-contracts/delta.md rename to specs/archive/2026-08-18-opencode-permission-contracts/delta.md diff --git a/specs/changes/opencode-permission-contracts/design.md b/specs/archive/2026-08-18-opencode-permission-contracts/design.md similarity index 100% rename from specs/changes/opencode-permission-contracts/design.md rename to specs/archive/2026-08-18-opencode-permission-contracts/design.md diff --git a/specs/changes/opencode-permission-contracts/evidence.md b/specs/archive/2026-08-18-opencode-permission-contracts/evidence.md similarity index 100% rename from specs/changes/opencode-permission-contracts/evidence.md rename to specs/archive/2026-08-18-opencode-permission-contracts/evidence.md diff --git a/specs/changes/opencode-permission-contracts/proposal.md b/specs/archive/2026-08-18-opencode-permission-contracts/proposal.md similarity index 100% rename from specs/changes/opencode-permission-contracts/proposal.md rename to specs/archive/2026-08-18-opencode-permission-contracts/proposal.md diff --git a/specs/changes/opencode-permission-contracts/tasks.md b/specs/archive/2026-08-18-opencode-permission-contracts/tasks.md similarity index 90% rename from specs/changes/opencode-permission-contracts/tasks.md rename to specs/archive/2026-08-18-opencode-permission-contracts/tasks.md index bf0d34e..bc318f1 100644 --- a/specs/changes/opencode-permission-contracts/tasks.md +++ b/specs/archive/2026-08-18-opencode-permission-contracts/tasks.md @@ -6,4 +6,4 @@ - [x] Implement separate local-write, datasource, deploy, and audience resources. - [x] Verify stable-host allow/ask/deny and auto mode without a real deployment. - [x] Record exact permission traces and update `specs/current/opencode-integration.spec.md`. -- [ ] Run repository validation and archive the packet. +- [x] Run repository validation and archive the packet. diff --git a/specs/changes/opencode-skill-distribution/change.json b/specs/archive/2026-08-18-opencode-skill-distribution/change.json similarity index 58% rename from specs/changes/opencode-skill-distribution/change.json rename to specs/archive/2026-08-18-opencode-skill-distribution/change.json index 7e049e8..9bcea79 100644 --- a/specs/changes/opencode-skill-distribution/change.json +++ b/specs/archive/2026-08-18-opencode-skill-distribution/change.json @@ -3,10 +3,16 @@ "id": "opencode-skill-distribution", "title": "Install the artifact skill through official discovery", "lane": "standard", - "status": "approved", - "affectedRequirements": ["OC-05", "DIST-01", "DIST-02"], - "currentSpecs": ["specs/current/opencode-integration.spec.md"], - "currentSpecsUpdated": false, + "status": "archived", + "affectedRequirements": [ + "OC-05", + "DIST-01", + "DIST-02" + ], + "currentSpecs": [ + "specs/current/opencode-integration.spec.md" + ], + "currentSpecsUpdated": true, "approval": { "by": "aaron.zeng", "at": "2026-08-18T01:48:52Z" @@ -17,5 +23,5 @@ "reason": "" }, "createdAt": "2026-08-18", - "archivedAt": null + "archivedAt": "2026-08-18" } diff --git a/specs/changes/opencode-skill-distribution/delta.md b/specs/archive/2026-08-18-opencode-skill-distribution/delta.md similarity index 100% rename from specs/changes/opencode-skill-distribution/delta.md rename to specs/archive/2026-08-18-opencode-skill-distribution/delta.md diff --git a/specs/changes/opencode-skill-distribution/evidence.md b/specs/archive/2026-08-18-opencode-skill-distribution/evidence.md similarity index 51% rename from specs/changes/opencode-skill-distribution/evidence.md rename to specs/archive/2026-08-18-opencode-skill-distribution/evidence.md index 4fe599a..403d513 100644 --- a/specs/changes/opencode-skill-distribution/evidence.md +++ b/specs/archive/2026-08-18-opencode-skill-distribution/evidence.md @@ -6,18 +6,21 @@ Do not hide failed or excluded results. ## Requirement: OC-05 - Validation: the native stable host advertises and loads the installed skill on demand. -- Verification: planned frontmatter, permission, discovery, and load observation. -- Result: pending implementation. +- Verification: official frontmatter and project/global discovery paths are unit-tested; final + exact-tarball stable `/skill` returned the installed description, location, and full body. +- Result: pass after candidate source-package removal. - Evidence: [@manual](docs/evidence/opencode-host-verification.md) ## Requirement: DIST-01 - Validation: the exact tarball carries every file the explicit installer needs. -- Verification: planned packed-file and clean-install assertions. -- Result: pending implementation. +- Verification: installer source is an exact three-file inventory resolved relative to packed + runtime code; CI removes the source package before native load verification. +- Result: pass in the final packed VPS rehearsal; CI repeats the same source-removal gate. - Evidence: [@test](.github/workflows/ci.yml) ## Requirement: DIST-02 - Validation: project/global official paths work from clean state and fail safely on collision. -- Verification: planned installer matrix plus README-only clean-host flow. -- Result: pending implementation. +- Verification: tests cover project/global, idempotence, collision, exact force, retained + backup, symlink refusal, unexpected inventory, and source removal. README documents all paths. +- Result: pass. - Evidence: [@manual](README.md) diff --git a/specs/changes/opencode-skill-distribution/proposal.md b/specs/archive/2026-08-18-opencode-skill-distribution/proposal.md similarity index 100% rename from specs/changes/opencode-skill-distribution/proposal.md rename to specs/archive/2026-08-18-opencode-skill-distribution/proposal.md diff --git a/specs/archive/2026-08-18-opencode-skill-distribution/tasks.md b/specs/archive/2026-08-18-opencode-skill-distribution/tasks.md new file mode 100644 index 0000000..b45646a --- /dev/null +++ b/specs/archive/2026-08-18-opencode-skill-distribution/tasks.md @@ -0,0 +1,9 @@ +# Tasks: Install the artifact skill through official discovery + +- [x] Confirm proposal validation and human approval. +- [x] Add contained installer tests for project/global, idempotence, collision, force, and symlinks. +- [x] Implement explicit atomic skill installation from packed bytes into official directories. +- [x] Document install, safe manual removal, proactive-mode distinction, and failure recovery. +- [x] Verify native stable-host advertisement and on-demand load from a clean packed install. +- [x] Record the installer contract and update `specs/current/opencode-integration.spec.md`. +- [x] Run repository validation and archive the packet. diff --git a/specs/changes/packed-opencode-host-matrix/change.json b/specs/archive/2026-08-18-packed-opencode-host-matrix/change.json similarity index 60% rename from specs/changes/packed-opencode-host-matrix/change.json rename to specs/archive/2026-08-18-packed-opencode-host-matrix/change.json index 4a979f7..4b5b958 100644 --- a/specs/changes/packed-opencode-host-matrix/change.json +++ b/specs/archive/2026-08-18-packed-opencode-host-matrix/change.json @@ -3,9 +3,17 @@ "id": "packed-opencode-host-matrix", "title": "Verify packed OpenCode host compatibility", "lane": "standard", - "status": "approved", - "affectedRequirements": ["OC-01", "OC-02", "OC-03", "OC-04", "QUAL-03"], - "currentSpecs": ["specs/current/opencode-integration.spec.md"], + "status": "archived", + "affectedRequirements": [ + "OC-01", + "OC-02", + "OC-03", + "OC-04", + "QUAL-03" + ], + "currentSpecs": [ + "specs/current/opencode-integration.spec.md" + ], "currentSpecsUpdated": true, "approval": { "by": "aaron.zeng", @@ -17,5 +25,5 @@ "reason": "" }, "createdAt": "2026-08-18", - "archivedAt": null + "archivedAt": "2026-08-18" } diff --git a/specs/changes/packed-opencode-host-matrix/delta.md b/specs/archive/2026-08-18-packed-opencode-host-matrix/delta.md similarity index 100% rename from specs/changes/packed-opencode-host-matrix/delta.md rename to specs/archive/2026-08-18-packed-opencode-host-matrix/delta.md diff --git a/specs/changes/packed-opencode-host-matrix/evidence.md b/specs/archive/2026-08-18-packed-opencode-host-matrix/evidence.md similarity index 88% rename from specs/changes/packed-opencode-host-matrix/evidence.md rename to specs/archive/2026-08-18-packed-opencode-host-matrix/evidence.md index 94b1ecd..ba224dd 100644 --- a/specs/changes/packed-opencode-host-matrix/evidence.md +++ b/specs/archive/2026-08-18-packed-opencode-host-matrix/evidence.md @@ -8,7 +8,8 @@ Do not hide failed or excluded results. - Validation: clean-host discovery exposes the tools a user installed. - Verification: exact 0.15.0 candidate tarball installed into clean roots; stable 1.18.18 live discovery returned all five tools and their documented schemas through both entry routes. -- Result: pass on 2026-08-18; final Goal 4 candidate rerun remains required before archive. +- Result: pass on the final 69-file candidate, SHA-256 + `df778eafd2cd17b6f2674224af85767089024d326765f34c5e10bbb60307e091`. - Evidence: [@manual](docs/evidence/opencode-host-verification.md) ## Requirement: OC-02 @@ -24,7 +25,7 @@ Do not hide failed or excluded results. - Validation: CI tests shipped bytes rather than workspace resolution. - Verification: CI now feeds the one `npm pack` filename into the bounded host harness and uploads its JSON beside the tarball. -- Result: pass in the dated VPS rehearsal; required CI wiring is present. +- Result: pass in the final dated VPS rehearsal; required CI wiring is present. - Evidence: [@test](.github/workflows/ci.yml) ## Requirement: OC-04 @@ -38,5 +39,5 @@ Do not hide failed or excluded results. - Validation: the matrix exercises the real stable host boundary users receive. - Verification: separate empty XDG roots, exact digest/version, live health and schemas, bounded logs, and a byte-unchanged packed lifecycle-list smoke were recorded without inference. -- Result: pass on the initial Goal 4 candidate; exact final-byte rerun remains required. +- Result: pass on the exact final Goal 4 candidate without provider inference. - Evidence: [@manual](docs/evidence/opencode-host-verification.md) diff --git a/specs/changes/packed-opencode-host-matrix/proposal.md b/specs/archive/2026-08-18-packed-opencode-host-matrix/proposal.md similarity index 100% rename from specs/changes/packed-opencode-host-matrix/proposal.md rename to specs/archive/2026-08-18-packed-opencode-host-matrix/proposal.md diff --git a/specs/changes/packed-opencode-host-matrix/tasks.md b/specs/archive/2026-08-18-packed-opencode-host-matrix/tasks.md similarity index 92% rename from specs/changes/packed-opencode-host-matrix/tasks.md rename to specs/archive/2026-08-18-packed-opencode-host-matrix/tasks.md index a128c3b..bf1320a 100644 --- a/specs/changes/packed-opencode-host-matrix/tasks.md +++ b/specs/archive/2026-08-18-packed-opencode-host-matrix/tasks.md @@ -7,4 +7,4 @@ - [x] Narrow compatibility metadata/policy to the exact current and oldest-tested releases. - [x] Add the packed-host cell to CI and retain exact package/host/config/output evidence. - [x] Record failures/exclusions and update `specs/current/opencode-integration.spec.md`. -- [ ] Run repository validation and archive the packet. +- [x] Run repository validation and archive the packet. diff --git a/specs/changes/opencode-skill-distribution/tasks.md b/specs/changes/opencode-skill-distribution/tasks.md deleted file mode 100644 index 51eb717..0000000 --- a/specs/changes/opencode-skill-distribution/tasks.md +++ /dev/null @@ -1,9 +0,0 @@ -# Tasks: Install the artifact skill through official discovery - -- [ ] Confirm proposal validation and human approval. -- [ ] Add contained installer tests for project/global, idempotence, collision, force, and symlinks. -- [ ] Implement explicit atomic skill installation from packed bytes into official directories. -- [ ] Document install, safe manual removal, proactive-mode distinction, and failure recovery. -- [ ] Verify native stable-host advertisement and on-demand load from a clean packed install. -- [ ] Record exact evidence and update `specs/current/opencode-integration.spec.md`. -- [ ] Run repository validation and archive the packet. From d4222fe8d927f343804206858779d1bcec1c5183 Mon Sep 17 00:00:00 2001 From: bitgorust Date: Tue, 18 Aug 2026 16:58:18 +0200 Subject: [PATCH 8/8] fix: accept npm latest pack output --- scripts/opencode-host-matrix.ts | 20 ++++++++++++++++---- test/opencode-host-matrix.test.ts | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/scripts/opencode-host-matrix.ts b/scripts/opencode-host-matrix.ts index 984bb3a..a047820 100644 --- a/scripts/opencode-host-matrix.ts +++ b/scripts/opencode-host-matrix.ts @@ -403,16 +403,28 @@ function requiredArgument(name: string): string { return value; } +export function packFilename(pack: unknown): string { + let result: Record | undefined; + if (Array.isArray(pack) && pack.length === 1 && isRecord(pack[0])) { + result = pack[0]; + } else if (isRecord(pack)) { + const values = Object.values(pack); + if (values.length === 1 && isRecord(values[0])) result = values[0]; + } + const filename = result?.["filename"]; + if (typeof filename !== "string" || filename.trim() === "") { + throw new Error("--pack-json must contain exactly one npm pack result with a filename"); + } + return filename; +} + async function requestedTarball(): Promise { const directIndex = process.argv.indexOf("--tarball"); const direct = directIndex === -1 ? undefined : process.argv[directIndex + 1]; if (direct && !direct.startsWith("--")) return direct; const packJson = requiredArgument("--pack-json"); const parsed = JSON.parse(await readFile(resolve(packJson), "utf8")) as unknown; - if (!Array.isArray(parsed) || !isRecord(parsed[0]) || typeof parsed[0]["filename"] !== "string") { - throw new Error("--pack-json must contain npm pack JSON with a filename"); - } - return parsed[0]["filename"]; + return packFilename(parsed); } export async function runMatrix(tarballInput: string, outputInput: string): Promise { diff --git a/test/opencode-host-matrix.test.ts b/test/opencode-host-matrix.test.ts index 05b9420..356702b 100644 --- a/test/opencode-host-matrix.test.ts +++ b/test/opencode-host-matrix.test.ts @@ -6,9 +6,24 @@ import { assertArtifactToolContract, boundedLog, exactStableMatrix, + packFilename, parseServerUrl, } from "../scripts/opencode-host-matrix.ts"; +test("pack filename accepts npm array and keyed-object JSON while requiring one result", () => { + assert.equal(packFilename([{ filename: "pkg-1.2.3.tgz" }]), "pkg-1.2.3.tgz"); + assert.equal( + packFilename({ "pkg@1.2.3": { filename: "pkg-1.2.3.tgz" } }), + "pkg-1.2.3.tgz", + ); + assert.throws(() => packFilename([]), /exactly one npm pack result/); + assert.throws( + () => packFilename({ first: { filename: "a.tgz" }, second: { filename: "b.tgz" } }), + /exactly one npm pack result/, + ); + assert.throws(() => packFilename({ pkg: { filename: "" } }), /exactly one npm pack result/); +}); + test("stable host matrix deduplicates identical exact current and oldest cells", () => { assert.deepEqual(exactStableMatrix("1.18.18", "1.18.18"), { versions: ["1.18.18"],