mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Compare commits
5
Commits
cli-v4.0.0
...
cli-v4.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eebfb7c2ce | ||
|
|
8dac6ae7e0 | ||
|
|
46ffe5caa2 | ||
|
|
b077f6f0e4 | ||
|
|
641ff95502 |
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
`skill/` is the source of truth for the Impeccable skill: `SKILL.src.md`, `reference/`, `scripts/`, and `agents/`. `skill/scripts/` holds the launcher (`impeccable`, `impeccable.cmd`), the pinned engine `VERSION`, `command-metadata.json`, and the in-page live-mode JS; every skill verb (`{{scripts_path}}/impeccable <verb>`) runs in the engine binary, which is built in a separate repo and pinned by the root `ENGINE_VERSION` file. Build logic lives in `scripts/`, with provider configs in `scripts/lib/transformers/`. `cli/` is the npm shim that runs the same binary, the browser extension lives in `extension/`, and regression coverage in `tests/` with fixtures under `tests/fixtures/` and the behavior goldens under `tests/oracle/`. `dist/` and `build/` are generated and gitignored. The root harness folders (`.agents/`, `.claude/`, `.cursor/`, etc.) and `plugin/` are generated distribution artifacts that are tracked for direct repo installs, not hand-authored source.
|
`skill/` is the source of truth for the Impeccable skill: `SKILL.src.md`, `reference/`, `scripts/`, and `agents/`. `skill/scripts/` holds the launcher (`impeccable`, `impeccable.cmd`), the pinned engine `VERSION`, `command-metadata.json`, and the in-page live-mode JS. Every skill verb (`{{scripts_path}}/impeccable <verb>`) runs in the engine binary, built from this repo's Cargo workspace under `crates/`; the root `ENGINE_VERSION` pins the released binary used by installs. Read `docs/ENGINE.md` before changing runtime code. Build logic lives in `scripts/`, with provider configs in `scripts/lib/transformers/`. `cli/` is the npm shim that runs the same binary, the browser extension lives in `extension/`, and regression coverage lives in the Rust crates and `tests/`, including fixtures under `tests/fixtures/` and behavior goldens under `tests/oracle/`. The website and service live in the separate private `impeccable-site` repo. `dist/` and `build/` are generated and gitignored. The root harness folders (`.agents/`, `.claude/`, `.cursor/`, etc.) and `plugin/` are generated distribution artifacts that are tracked for direct repo installs, not hand-authored source.
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
- `bun run dev` - start the local Bun server.
|
- `cargo build --release -p impeccable` - build this checkout's runtime into `target/release/impeccable`.
|
||||||
|
- `cargo test --workspace` - run the Rust workspace tests.
|
||||||
- `bun run build` - source-first build: regenerate `dist/`, derived site assets, and validation output without syncing tracked harness folders.
|
- `bun run build` - source-first build: regenerate `dist/`, derived site assets, and validation output without syncing tracked harness folders.
|
||||||
- `bun run build:release` - release/distribution build: run the full build and sync tracked root harness folders plus `plugin/`.
|
- `bun run build:release` - release/distribution build: run the full build and sync tracked root harness folders plus `plugin/`.
|
||||||
- `bun run rebuild` - clean and rebuild everything from scratch without syncing tracked harness folders.
|
- `bun run rebuild` - clean and rebuild everything from scratch without syncing tracked harness folders.
|
||||||
@@ -25,7 +26,7 @@ Run `bun run build` after changing anything in `skill/`, transformer code, or us
|
|||||||
|
|
||||||
The root harness folders (`.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, `.gemini/skills/`, `.github/skills/`, `.grok/skills/`, `.hermes/skills/`, `.kiro/skills/`, `.opencode/skills/`, `.pi/skills/`, `.qoder/skills/`, `.rovodev/skills/`, `.trae*/skills/`, `.vibe/skills/`) and `plugin/` stay tracked so `main` remains installable for direct GitHub, `npx skills`, and submodule users. They are still generated artifacts.
|
The root harness folders (`.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, `.gemini/skills/`, `.github/skills/`, `.grok/skills/`, `.hermes/skills/`, `.kiro/skills/`, `.opencode/skills/`, `.pi/skills/`, `.qoder/skills/`, `.rovodev/skills/`, `.trae*/skills/`, `.vibe/skills/`) and `plugin/` stay tracked so `main` remains installable for direct GitHub, `npx skills`, and submodule users. They are still generated artifacts.
|
||||||
|
|
||||||
Normal development should be source-first: stage changes in `skill/`, `scripts/`, `cli/`, `site/`, `extension/`, `functions/`, and `tests/`; leave generated harness churn unstaged unless the user asked for it. After source changes land on `main`, `.github/workflows/sync-generated-output.yml` runs `bun run build:release` and commits generated provider output directly back to `main`. Treat generated harness diffs as release artifacts and keep them out of feature PRs unless they are the point of the PR.
|
Normal development should be source-first: stage changes in `crates/`, `browser-bundle/`, `skill/`, `scripts/`, `cli/`, `extension/`, and `tests/`; leave generated harness churn unstaged unless the user asked for it. After source changes land on `main`, `.github/workflows/sync-generated-output.yml` runs `bun run build:release` and commits generated provider output directly back to `main`. Treat generated harness diffs as release artifacts and keep them out of feature PRs unless they are the point of the PR. The two tracked engine assets under `crates/live/assets/` follow the rule-change workflow below instead.
|
||||||
|
|
||||||
## Sandbox gotchas for Codex agents
|
## Sandbox gotchas for Codex agents
|
||||||
|
|
||||||
@@ -39,9 +40,13 @@ Some repo workflows need to run outside the sandbox in the desktop app:
|
|||||||
|
|
||||||
Use ESM, semicolons, and the existing two-space indentation style in JS, HTML, and CSS. Prefer small, single-purpose modules over large abstractions. Keep filenames descriptive and lowercase with hyphens where needed; skill entrypoints stay as `SKILL.md`, build and test helpers use `.js` or `.mjs`. In source frontmatter, use clear kebab-case names and concise descriptions. There is no dedicated formatter or linter configured here, so match surrounding code closely.
|
Use ESM, semicolons, and the existing two-space indentation style in JS, HTML, and CSS. Prefer small, single-purpose modules over large abstractions. Keep filenames descriptive and lowercase with hyphens where needed; skill entrypoints stay as `SKILL.md`, build and test helpers use `.js` or `.mjs`. In source frontmatter, use clear kebab-case names and concise descriptions. There is no dedicated formatter or linter configured here, so match surrounding code closely.
|
||||||
|
|
||||||
|
For Rust, follow the surrounding crate's conventions and workspace formatting configuration. Keep changes scoped; do not reformat unrelated modules.
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
Tests use Bun’s test runner plus Node’s built-in `--test`. Name tests `*.test.js` or `*.test.mjs` and place new fixtures near the behavior they cover, usually under `tests/fixtures/`. Prefer targeted test runs while iterating, then finish with `bun run test`. If you change generated outputs or provider transforms, verify both source parsing and at least one affected provider path in `dist/`.
|
Tests use Bun's test runner plus Node's built-in `--test`. Name tests `*.test.js` or `*.test.mjs` and place new fixtures near the behavior they cover, usually under `tests/fixtures/`. Prefer targeted test runs while iterating, then finish with `bun run test`. If you change generated outputs or provider transforms, verify both source parsing and at least one affected provider path in `dist/`.
|
||||||
|
|
||||||
|
For runtime changes under `crates/`, add a failing regression in the affected crate, run its focused tests, then `cargo test --workspace`. Rebuild with `cargo build --release -p impeccable` and run `IMPECCABLE_BIN="$PWD/target/release/impeccable" bun run test` so the oracle exercises the changed source, not an older downloaded release. Review intended oracle changes by hand; never overwrite goldens just to make a regression pass. `tests/oracle/vectors/calls/` contains frozen function-level vectors and must not be regenerated.
|
||||||
|
|
||||||
For changes to the live-mode page JS (`skill/scripts/live-browser*.js`) or an `ENGINE_VERSION` bump, also run `bun run test:live-e2e` (kept out of the default suite because it does real `npm install` per fixture and boots framework dev servers). Scope to one fixture with `IMPECCABLE_E2E_ONLY=<fixture-name>` while iterating; pass `IMPECCABLE_E2E_DEBUG=1` for page-DOM and dev-server-log dumps on failure. Schema and authoring guide for new fixtures live in `tests/framework-fixtures/README.md`.
|
For changes to the live-mode page JS (`skill/scripts/live-browser*.js`) or an `ENGINE_VERSION` bump, also run `bun run test:live-e2e` (kept out of the default suite because it does real `npm install` per fixture and boots framework dev servers). Scope to one fixture with `IMPECCABLE_E2E_ONLY=<fixture-name>` while iterating; pass `IMPECCABLE_E2E_DEBUG=1` for page-DOM and dev-server-log dumps on failure. Schema and authoring guide for new fixtures live in `tests/framework-fixtures/README.md`.
|
||||||
|
|
||||||
@@ -53,7 +58,11 @@ Other area-to-suite obligations (the canonical mapping is the `triggers` lists i
|
|||||||
|
|
||||||
## Anti-pattern detection rules
|
## Anti-pattern detection rules
|
||||||
|
|
||||||
The rule engine lives in the engine repo, not here. What this repo owns is the behavior contract: `docs/CLI-CONTRACT.md` describes every verb, `tests/oracle/` holds the recorded goldens and replays them against the binary (`tests/oracle.test.mjs`), and `tests/fixtures/antipatterns/*.html` are the fixtures those goldens scan. A rule change lands in the engine, then here as a new oracle case (`node tests/oracle/record.mjs --bin <prefix>`, golden reviewed by hand) and, when it introduces new design guidance, an edit to `skill/SKILL.src.md` or `skill/reference/*.md`. Rule counts quoted in `README.md` and `README.npm.md` are checked by the build against `crates/live/assets/antipatterns.json`, the tracked registry `cargo xtask bundle` writes (it falls back to the gitignored `extension/detector/antipatterns.json`).
|
The rule engine lives in this workspace. `crates/core` holds the checks and browser adapters; `crates/foundation` holds the registry and shared types. `crates/html`, `crates/browser`, and `crates/detect` provide the static HTML, URL, and CLI/text paths. `crates/wasm` compiles the shared rules for the extension, live overlay, and site. See `docs/ENGINE.md` for the crate map and bundle flow, and `docs/CLI-CONTRACT.md` for observable behavior.
|
||||||
|
|
||||||
|
Add a fixture first under `tests/fixtures/antipatterns/` with should-flag and should-pass columns, at least four flag cases and five false-positive shapes, unique headings, and explicit pixel dimensions. Add failing Rust coverage before implementing the rule. Cover each affected engine path and add or update an oracle case (`node tests/oracle/record.mjs --bin <prefix>`, golden reviewed by hand). When a rule introduces design guidance, update `skill/SKILL.src.md` or `skill/reference/*.md` too.
|
||||||
|
|
||||||
|
Run `cargo xtask bundle` after rule or browser-bundle changes and commit its two tracked outputs: `crates/live/assets/detect-antipatterns-browser.js` and `crates/live/assets/antipatterns.json`. The generated `extension/detector/` remains gitignored. Rebuild the native binary after bundling, run the Rust and Bun/Node checks above, and run `bun run build` to validate distribution and rule counts. Verify browser-facing changes on the relevant live fixture; native and browser adapters can disagree.
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
@@ -77,4 +86,4 @@ Tags are per-component because the three components ship independently: `skill-v
|
|||||||
|
|
||||||
## Contributor Notes
|
## Contributor Notes
|
||||||
|
|
||||||
Do not edit generated provider files directly unless you are intentionally patching generated output as part of a build-system change. Prefer fixing the root source in `skill/`, `scripts/`, or `cli/` (or the engine repo for verb behavior), then regenerate artifacts for validation. Stage generated harness artifacts only for release/main-sync or build-system work.
|
Do not edit generated provider files directly unless you are intentionally patching generated output as part of a build-system change. Prefer fixing the root source in `skill/`, `scripts/`, or `cli/`, and `crates/` for runtime behavior, then regenerate artifacts for validation. Stage generated harness artifacts only for release/main-sync or build-system work.
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ bun run test:plugin-e2e # Just the plugin loader E2E (also part of the def
|
|||||||
bun run test:cleanup # Kill live servers a previous run of THIS checkout left behind
|
bun run test:cleanup # Kill live servers a previous run of THIS checkout left behind
|
||||||
```
|
```
|
||||||
|
|
||||||
Unit tests (build orchestration, transformers, validators) run via `bun test`. Everything that spawns the engine binary (`tests/oracle.test.mjs`, `tests/framework-fixtures.test.mjs`) runs via `node --test`; both skip cleanly when no binary is found (`bun run fetch:engine` or `IMPECCABLE_BIN`). The `test` script handles this split automatically. Verb behavior is not unit-tested here at all: the oracle goldens and the engine repo's own tests own it.
|
Unit tests (build orchestration, transformers, validators) run via `bun test`. Everything that spawns the engine binary (`tests/oracle.test.mjs`, `tests/framework-fixtures.test.mjs`) runs via `node --test`; both skip cleanly when no binary is found (`bun run fetch:engine` or `IMPECCABLE_BIN`). The `test` script handles this split automatically. Runtime unit and integration tests live under `crates/` and run with `cargo test --workspace`; the oracle goldens pin observable verb behavior across the same workspace.
|
||||||
|
|
||||||
### Live servers must not outlive their test process
|
### Live servers must not outlive their test process
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ The default suite does not cover everything. When a change touches one of these
|
|||||||
| `ENGINE_VERSION` bump | `bun run test:new-work-e2e` | Playwright, offline, no API cost |
|
| `ENGINE_VERSION` bump | `bun run test:new-work-e2e` | Playwright, offline, no API cost |
|
||||||
| `plugin/`, `skill/agents/`, `scripts/build.js`, plugin manifest validator | `bun run test:plugin-e2e` | ~1 s; already in the default suite, needs the `claude` CLI |
|
| `plugin/`, `skill/agents/`, `scripts/build.js`, plugin manifest validator | `bun run test:plugin-e2e` | ~1 s; already in the default suite, needs the `claude` CLI |
|
||||||
|
|
||||||
Verb-level behavior changes happen in the engine repo; the check they owe here is `bun run test` with a binary present (the oracle), and a new oracle case when the contract grows.
|
For verb-level behavior changes in `crates/`, run focused crate tests and `cargo test --workspace`, then `cargo build --release -p impeccable`. Run `IMPECCABLE_BIN="$PWD/target/release/impeccable" bun run test` to exercise the changed source rather than an older downloaded release. Add a new oracle case when the contract grows and review golden changes by hand. See `docs/ENGINE.md` for browser-bundle checks and generated assets.
|
||||||
|
|
||||||
**Plugin loader E2E** (`tests/plugin-e2e.test.mjs`, in the default suite): installs the committed `./plugin` subtree into a real Claude Code, sandboxed via `CLAUDE_CONFIG_DIR` in a temp dir, and asserts the component inventory from `claude plugin details`: the skill parses, every `plugin/agents/*.md` is visible, hooks are discovered. This is the only check that catches loader-contract surprises the unit guards can't know about (PR #494 shipped an `agents` manifest key that silently loaded zero agents; `claude plugin validate` never flags plugin-manifest problems). Runs in about a second; skips cleanly when the `claude` CLI is not on PATH. The known contract itself (allowed manifest keys, no `agents` key, trailing-slash `skills` path, source agents shipped) is pinned deterministically by `scripts/lib/validate-plugin-manifest.js`, unit-tested in `tests/validate-plugin-manifest.test.js` and enforced as a `bun run build` gate. Never add a key to the generated plugin manifest without verifying it against a real install and extending `KNOWN_LOADER_KEYS`.
|
**Plugin loader E2E** (`tests/plugin-e2e.test.mjs`, in the default suite): installs the committed `./plugin` subtree into a real Claude Code, sandboxed via `CLAUDE_CONFIG_DIR` in a temp dir, and asserts the component inventory from `claude plugin details`: the skill parses, every `plugin/agents/*.md` is visible, hooks are discovered. This is the only check that catches loader-contract surprises the unit guards can't know about (PR #494 shipped an `agents` manifest key that silently loaded zero agents; `claude plugin validate` never flags plugin-manifest problems). Runs in about a second; skips cleanly when the `claude` CLI is not on PATH. The known contract itself (allowed manifest keys, no `agents` key, trailing-slash `skills` path, source agents shipped) is pinned deterministically by `scripts/lib/validate-plugin-manifest.js`, unit-tested in `tests/validate-plugin-manifest.test.js` and enforced as a `bun run build` gate. Never add a key to the generated plugin manifest without verifying it against a real install and extending `KNOWN_LOADER_KEYS`.
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ npx impeccable install # install skills
|
|||||||
npx impeccable --help # show help
|
npx impeccable --help # show help
|
||||||
```
|
```
|
||||||
|
|
||||||
The package no longer exports a JS detector API (`main` / `exports` are gone); the in-page bundle for the extension and site comes from the engine repo.
|
The package no longer exports a JS detector API (`main` / `exports` are gone); the in-page bundle for the extension and site is built from this workspace by `cargo xtask bundle`.
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ The skill launcher, the npm shim (`cli/bin/cli.js`), and `impeccable install` al
|
|||||||
2. Publish the five `@impeccable/cli-<os>-<arch>@<ENGINE_VERSION>` npm platform packages.
|
2. Publish the five `@impeccable/cli-<os>-<arch>@<ENGINE_VERSION>` npm platform packages.
|
||||||
3. Only then tag/publish the skill or CLI release, and only then merge a branch that bumps `ENGINE_VERSION` (the `sync-generated-output.yml` workflow rewrites provider dirs on merge to `main`).
|
3. Only then tag/publish the skill or CLI release, and only then merge a branch that bumps `ENGINE_VERSION` (the `sync-generated-output.yml` workflow rewrites provider dirs on merge to `main`).
|
||||||
|
|
||||||
`scripts/check-engine-release.mjs` verifies step 1 and 2 for the pinned version (ranged-GET each release asset, registry-probe each npm package; honors `IMPECCABLE_DOWNLOAD_BASE`). It exits non-zero and names exactly which assets are missing. `scripts/release.mjs` runs it as a hard gate before tagging the **skill** and **CLI** components and refuses to proceed when any asset is absent; the **extension** release is exempt because it ships a vendored WASM detector and never execs the engine. `IMPECCABLE_SKIP_ENGINE_CHECK=1` bypasses the gate only for the case where the assets exist but the registry probe is unreachable. CI's `engine-release-ready` job runs the same script; it is `continue-on-error: true` with a loud `::warning` until the first engine release is published, at which point flip it to `false` so a mis-ordered merge fails CI.
|
`scripts/check-engine-release.mjs` verifies step 1 and 2 for the pinned version (ranged-GET each release asset, registry-probe each npm package; honors `IMPECCABLE_DOWNLOAD_BASE`). It exits non-zero and names exactly which assets are missing. `scripts/release.mjs` runs it as a hard gate before tagging the **skill** and **CLI** components and refuses to proceed when any asset is absent; the **extension** release is exempt because it ships a vendored WASM detector and never execs the engine. `IMPECCABLE_SKIP_ENGINE_CHECK=1` bypasses the gate only for the case where the assets exist but the registry probe is unreachable. CI's `engine-release-ready` job runs the same script as a hard gate, so missing release assets fail CI.
|
||||||
|
|
||||||
## Adding New Commands
|
## Adding New Commands
|
||||||
|
|
||||||
|
|||||||
Generated
+18
-16
@@ -509,7 +509,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable"
|
name = "impeccable"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"impeccable-browser",
|
"impeccable-browser",
|
||||||
@@ -528,7 +528,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-browser"
|
name = "impeccable-browser"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"impeccable-core",
|
"impeccable-core",
|
||||||
@@ -542,7 +542,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-bundle"
|
name = "impeccable-bundle"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"impeccable-core",
|
"impeccable-core",
|
||||||
@@ -551,14 +551,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-common"
|
name = "impeccable-common"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-comp"
|
name = "impeccable-comp"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -570,7 +570,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-comp-verbs"
|
name = "impeccable-comp-verbs"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"impeccable-common",
|
"impeccable-common",
|
||||||
"impeccable-comp",
|
"impeccable-comp",
|
||||||
@@ -583,7 +583,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-context"
|
name = "impeccable-context"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flate2",
|
"flate2",
|
||||||
"impeccable-common",
|
"impeccable-common",
|
||||||
@@ -600,7 +600,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-core"
|
name = "impeccable-core"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"impeccable-core",
|
"impeccable-core",
|
||||||
"impeccable-foundation",
|
"impeccable-foundation",
|
||||||
@@ -612,7 +612,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-detect"
|
name = "impeccable-detect"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"impeccable-common",
|
"impeccable-common",
|
||||||
"impeccable-core",
|
"impeccable-core",
|
||||||
@@ -624,7 +624,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-foundation"
|
name = "impeccable-foundation"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -637,7 +637,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-hook"
|
name = "impeccable-hook"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"impeccable-common",
|
"impeccable-common",
|
||||||
"impeccable-context",
|
"impeccable-context",
|
||||||
@@ -651,7 +651,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-html"
|
name = "impeccable-html"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"ego-tree",
|
"ego-tree",
|
||||||
@@ -672,7 +672,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-live"
|
name = "impeccable-live"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"impeccable-common",
|
"impeccable-common",
|
||||||
@@ -690,7 +690,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-skills"
|
name = "impeccable-skills"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"impeccable-common",
|
"impeccable-common",
|
||||||
"impeccable-context",
|
"impeccable-context",
|
||||||
@@ -698,6 +698,8 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"regex",
|
"regex",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"ureq",
|
"ureq",
|
||||||
@@ -707,7 +709,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impeccable-wasm"
|
name = "impeccable-wasm"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"impeccable-core",
|
"impeccable-core",
|
||||||
"impeccable-detect",
|
"impeccable-detect",
|
||||||
@@ -1679,7 +1681,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xtask"
|
name = "xtask"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"impeccable-bundle",
|
"impeccable-bundle",
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ resolver = "2"
|
|||||||
members = ["crates/*"]
|
members = ["crates/*"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.1.0
|
0.1.1
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@impeccable/cli-darwin-arm64": "0.1.0",
|
"@impeccable/cli-darwin-arm64": "0.1.1",
|
||||||
"@impeccable/cli-darwin-x64": "0.1.0",
|
"@impeccable/cli-darwin-x64": "0.1.1",
|
||||||
"@impeccable/cli-linux-arm64": "0.1.0",
|
"@impeccable/cli-linux-arm64": "0.1.1",
|
||||||
"@impeccable/cli-linux-x64": "0.1.0",
|
"@impeccable/cli-linux-x64": "0.1.1",
|
||||||
"@impeccable/cli-windows-x64": "0.1.0",
|
"@impeccable/cli-windows-x64": "0.1.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -72,6 +72,16 @@
|
|||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||||
|
|
||||||
|
"@impeccable/cli-darwin-arm64": ["@impeccable/cli-darwin-arm64@0.1.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "impeccable-darwin-arm64": "bin/impeccable" } }, "sha512-1/DZYaiZqDoNwpXpyoG4gRQpgLZ4YDCMADGzA5wYmNiG279b3KPt2RLYUww6NF2hxoMxdVQUAGzdc5062KZKHg=="],
|
||||||
|
|
||||||
|
"@impeccable/cli-darwin-x64": ["@impeccable/cli-darwin-x64@0.1.1", "", { "os": "darwin", "cpu": "x64", "bin": { "impeccable-darwin-x64": "bin/impeccable" } }, "sha512-/itjFZEHPcz1RQDBx3+2aeTebQ4pCD30VKAJ3Zst5KxHteJQOW3hoiTFE7jAOXg3vG2D/qAaEh/7Eo+xzEawew=="],
|
||||||
|
|
||||||
|
"@impeccable/cli-linux-arm64": ["@impeccable/cli-linux-arm64@0.1.1", "", { "os": "linux", "cpu": "arm64", "bin": { "impeccable-linux-arm64": "bin/impeccable" } }, "sha512-uGJ2DNVq3NzH8+RlTlyn1XWpAsNS1bv6kix7PsAnzCa0aBiOHaYSUkPXbqDVUTDy9X4oEOsZYuzbgoY+otkoMQ=="],
|
||||||
|
|
||||||
|
"@impeccable/cli-linux-x64": ["@impeccable/cli-linux-x64@0.1.1", "", { "os": "linux", "cpu": "x64", "bin": { "impeccable-linux-x64": "bin/impeccable" } }, "sha512-wPul+V7w9g0MZAgFmJJvEOXpy8AYt4htmLQJIx7XvakSp8CS+PYBjEFqmWSW41NlrNAaifzDKdUe6rzeRli97A=="],
|
||||||
|
|
||||||
|
"@impeccable/cli-windows-x64": ["@impeccable/cli-windows-x64@0.1.1", "", { "os": "win32", "cpu": "x64", "bin": { "impeccable-windows-x64": "bin/impeccable.exe" } }, "sha512-dqcQ8VQFschjA1iFzhKvO44UEzsbQQGHAZW3MmsxJbqo/uSDENIL8WA5l4K8Q9wqjf4Kbc/H1hqo6MjOuQw85A=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|||||||
+9
-1
@@ -68,6 +68,14 @@ async function locate() {
|
|||||||
return download().catch((err) => { process.stderr.write(`impeccable: ${err.message}\n`); return null; });
|
return download().catch((err) => { process.stderr.write(`impeccable: ${err.message}\n`); return null; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `--version` / `-v` is answered by the shim itself: the number users mean
|
||||||
|
// is this npm package's version, not the engine's (docs/CLI-CONTRACT.md).
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
if (argv[0] === '--version' || argv[0] === '-v') {
|
||||||
|
process.stdout.write(`${pkg.version}\n`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const bin = await locate();
|
const bin = await locate();
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
@@ -76,7 +84,7 @@ if (!bin) {
|
|||||||
);
|
);
|
||||||
process.exit(127);
|
process.exit(127);
|
||||||
}
|
}
|
||||||
const result = spawnSync(bin, process.argv.slice(2), {
|
const result = spawnSync(bin, argv, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: { IMPECCABLE_SELF: 'npx impeccable', ...process.env },
|
env: { IMPECCABLE_SELF: 'npx impeccable', ...process.env },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ fn run(args: &[String], io: &mut Io) -> i32 {
|
|||||||
|
|
||||||
/// The npm `impeccable` package version `cli.js --version` prints (its
|
/// The npm `impeccable` package version `cli.js --version` prints (its
|
||||||
/// `package.json`), tracked separately from the crate version.
|
/// `package.json`), tracked separately from the crate version.
|
||||||
pub const CLI_VERSION: &str = "3.6.0";
|
pub const CLI_VERSION: &str = "4.0.0";
|
||||||
|
|
||||||
/// The engines wired into `impeccable detect`: the static HTML engine
|
/// The engines wired into `impeccable detect`: the static HTML engine
|
||||||
/// (crates/html). The browser engine (crates/browser) plugs in here once it
|
/// (crates/html). The browser engine (crates/browser) plugs in here once it
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ impeccable-common = { path = "../common" }
|
|||||||
impeccable-context = { path = "../context" }
|
impeccable-context = { path = "../context" }
|
||||||
impeccable-detect = { path = "../detect" }
|
impeccable-detect = { path = "../detect" }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
once_cell = { workspace = true }
|
once_cell = { workspace = true }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
ring = "0.17.14"
|
||||||
ureq = { version = "2", default-features = false, features = ["tls", "json"] }
|
ureq = { version = "2", default-features = false, features = ["tls", "json"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
|
|||||||
+144
-2
@@ -17,6 +17,7 @@ use crate::providers::{
|
|||||||
opencode_global_config_dir, provider_display_name, Scope, Sys, API_BASE, PROVIDER_DIRS,
|
opencode_global_config_dir, provider_display_name, Scope, Sys, API_BASE, PROVIDER_DIRS,
|
||||||
};
|
};
|
||||||
use crate::util::{self, jsp};
|
use crate::util::{self, jsp};
|
||||||
|
use crate::bundle_signature::{self, TrustedKeys, MAX_SIGNATURE_BYTES};
|
||||||
|
|
||||||
/// Ceiling on any single download this crate performs (triage C4). The
|
/// Ceiling on any single download this crate performs (triage C4). The
|
||||||
/// launcher-only universal bundle is under 25 MB (the Cloudflare Pages file
|
/// launcher-only universal bundle is under 25 MB (the Cloudflare Pages file
|
||||||
@@ -80,6 +81,7 @@ pub struct FetchResponse {
|
|||||||
fn ureq_fetch(url: &str) -> Result<FetchResponse, String> {
|
fn ureq_fetch(url: &str) -> Result<FetchResponse, String> {
|
||||||
let agent = ureq::AgentBuilder::new()
|
let agent = ureq::AgentBuilder::new()
|
||||||
.timeout_connect(std::time::Duration::from_secs(30))
|
.timeout_connect(std::time::Duration::from_secs(30))
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.redirects(0)
|
.redirects(0)
|
||||||
.build();
|
.build();
|
||||||
match agent.get(url).call() {
|
match agent.get(url).call() {
|
||||||
@@ -293,13 +295,48 @@ pub fn download_and_extract_bundle(sys: &Sys) -> Result<String, String> {
|
|||||||
if let Some(local) = sys.env.get("IMPECCABLE_BUNDLE_PATH").filter(|v| !v.is_empty()) {
|
if let Some(local) = sys.env.get("IMPECCABLE_BUNDLE_PATH").filter(|v| !v.is_empty()) {
|
||||||
return copy_or_extract_local_bundle(sys, local);
|
return copy_or_extract_local_bundle(sys, local);
|
||||||
}
|
}
|
||||||
|
download_remote_bundle(sys, &mut ureq_fetch, bundle_signature::trusted_keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_remote_bundle(
|
||||||
|
sys: &Sys,
|
||||||
|
fetch: &mut dyn FnMut(&str) -> Result<FetchResponse, String>,
|
||||||
|
keys: Result<TrustedKeys, String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
keys.and_then(|keys| download_and_extract_signed_bundle(sys, fetch, &keys))
|
||||||
|
.map_err(|e| format!("{}{e}. Nothing was installed; retry or update the CLI. If this persists, report it at https://github.com/pbakaus/impeccable/issues/479", bundle_signature::ERROR_PREFIX))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_and_extract_signed_bundle(
|
||||||
|
sys: &Sys,
|
||||||
|
fetch: &mut dyn FnMut(&str) -> Result<FetchResponse, String>,
|
||||||
|
keys: &TrustedKeys,
|
||||||
|
) -> Result<String, String> {
|
||||||
let tmp = util::tmpdir(&sys.env);
|
let tmp = util::tmpdir(&sys.env);
|
||||||
let staging = util::mkdtemp(&jsp::join(&[&tmp, "impeccable-update-"]))?;
|
let staging = util::mkdtemp(&jsp::join(&[&tmp, "impeccable-update-"]))?;
|
||||||
let tmp_zip = jsp::join(&[&staging, "bundle.zip"]);
|
let tmp_zip = jsp::join(&[&staging, "bundle.zip"]);
|
||||||
|
let tmp_signature = jsp::join(&[&staging, "bundle.sig.json"]);
|
||||||
let result = (|| -> Result<(), String> {
|
let result = (|| -> Result<(), String> {
|
||||||
download_file(&format!("{API_BASE}/api/download/bundle/universal"), &tmp_zip)?;
|
// Resolve once, then request both assets from that exact release. Never
|
||||||
extract_zip_file(&tmp_zip, &staging, &sys.cwd)?;
|
// pair a latest-version lookup with a independently changing ZIP URL.
|
||||||
|
let response = fetch(&format!("{API_BASE}/api/download/bundle/universal"))?;
|
||||||
|
if !matches!(response.status, 301 | 302 | 303 | 307 | 308) {
|
||||||
|
return Err(format!("Expected a signed bundle release redirect (HTTP {})", response.status));
|
||||||
|
}
|
||||||
|
let location = response.location.ok_or("Missing bundle release redirect")?;
|
||||||
|
let version = bundle_signature::release_version(&location)?;
|
||||||
|
download_file_capped(&format!("{location}.sig.json"), &tmp_signature, fetch, MAX_SIGNATURE_BYTES)?;
|
||||||
|
download_file_with(&location, &tmp_zip, fetch)?;
|
||||||
|
let signature = std::fs::read(&tmp_signature).map_err(|e| e.to_string())?;
|
||||||
|
let file = std::fs::File::open(&tmp_zip).map_err(|e| e.to_string())?;
|
||||||
|
let mut reader = std::io::BufReader::new(file);
|
||||||
|
bundle_signature::verify_reader(&mut reader, &signature, &version, keys)?;
|
||||||
|
// Reuse the verified file handle rather than reopening by pathname.
|
||||||
|
use std::io::Seek;
|
||||||
|
reader.rewind().map_err(|e| e.to_string())?;
|
||||||
|
extract_zip_from(reader, &staging, &sys.cwd)?;
|
||||||
util::rm_rf(&tmp_zip);
|
util::rm_rf(&tmp_zip);
|
||||||
|
util::rm_rf(&tmp_signature);
|
||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
match result {
|
match result {
|
||||||
@@ -931,4 +968,109 @@ mod tests {
|
|||||||
assert_eq!(normalize_for_hash("x .claude/skills/y .trae-cn/skills/z .agent/skills/"), "x .PROVIDER/skills/y .PROVIDER/skills/z .PROVIDER/skills/");
|
assert_eq!(normalize_for_hash("x .claude/skills/y .trae-cn/skills/z .agent/skills/"), "x .PROVIDER/skills/y .PROVIDER/skills/z .PROVIDER/skills/");
|
||||||
assert_eq!(normalize_for_hash(".other/skills/"), ".other/skills/");
|
assert_eq!(normalize_for_hash(".other/skills/"), ".other/skills/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyring_load_failure_is_fatal_before_any_download() {
|
||||||
|
let sys = Sys::new(Default::default(), "/".into());
|
||||||
|
let mut fetch = |_: &str| -> Result<FetchResponse, String> {
|
||||||
|
panic!("A failed keyring must never reach the network");
|
||||||
|
};
|
||||||
|
let error = download_remote_bundle(&sys, &mut fetch, Err("Invalid compiled bundle signing keyring".into())).unwrap_err();
|
||||||
|
assert!(error.starts_with(bundle_signature::ERROR_PREFIX), "{error}");
|
||||||
|
assert!(error.contains("Invalid compiled bundle signing keyring"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_resolution_accepts_standard_redirects_only() {
|
||||||
|
for status in [200, 300, 301, 302, 303, 304, 305, 306, 307, 308, 404] {
|
||||||
|
let root = tmp_dir(&format!("redirect-{status}"));
|
||||||
|
let sys = Sys::new([("TMPDIR".into(), root.clone()), ("TEMP".into(), root.clone())].into(), root.clone());
|
||||||
|
let mut requests = 0;
|
||||||
|
let mut fetch = |_: &str| -> Result<FetchResponse, String> {
|
||||||
|
requests += 1;
|
||||||
|
if requests > 1 { return Err("reached signature download".into()); }
|
||||||
|
Ok(FetchResponse {
|
||||||
|
status,
|
||||||
|
location: Some("https://github.com/pbakaus/impeccable/releases/download/skill-v4.2.0/universal.zip".into()),
|
||||||
|
body: Box::new(std::io::empty()),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let error = download_and_extract_signed_bundle(&sys, &mut fetch, &Default::default()).unwrap_err();
|
||||||
|
if matches!(status, 301 | 302 | 303 | 307 | 308) {
|
||||||
|
assert_eq!(error, "reached signature download", "HTTP {status}");
|
||||||
|
assert_eq!(requests, 2);
|
||||||
|
} else {
|
||||||
|
assert!(error.contains("Expected a signed bundle release redirect"), "{error}");
|
||||||
|
assert_eq!(requests, 1);
|
||||||
|
}
|
||||||
|
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
|
||||||
|
util::rm_rf(&root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_download_verifies_before_extraction_and_cleans_all_failures() {
|
||||||
|
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||||
|
let key = Ed25519KeyPair::from_seed_unchecked(&[7; 32]).unwrap();
|
||||||
|
let hex = |bytes: &[u8]| bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||||
|
let keys = [("test-only".into(), hex(key.public_key().as_ref()))].into();
|
||||||
|
let zip = zip_bytes(&[(".claude/skills/impeccable/SKILL.md", b"verified skill")]);
|
||||||
|
let digest = format!("{:x}", Sha256::digest(&zip));
|
||||||
|
let payload = format!("impeccable-skill-bundle-v1\ntest-only\nskill-v4.2.0\nuniversal.zip\n{}\n{digest}\n", zip.len());
|
||||||
|
let signature = serde_json::to_vec(&serde_json::json!({
|
||||||
|
"schema": 1, "keyId": "test-only", "version": "4.2.0", "artifact": "universal.zip",
|
||||||
|
"size": zip.len(), "sha256": digest, "signature": hex(key.sign(payload.as_bytes()).as_ref()),
|
||||||
|
})).unwrap();
|
||||||
|
let release = "https://github.com/pbakaus/impeccable/releases/download/skill-v4.2.0/universal.zip";
|
||||||
|
for case in ["valid", "tampered", "missing", "oversized", "downgrade", "malformed-zip", "invalid-signature"] {
|
||||||
|
let root = tmp_dir(case);
|
||||||
|
let temp = format!("{root}/temp");
|
||||||
|
std::fs::create_dir(&temp).unwrap();
|
||||||
|
let installed = format!("{root}/existing-skill.md");
|
||||||
|
std::fs::write(&installed, "user's existing skill").unwrap();
|
||||||
|
let sys = Sys::new([("TMPDIR".into(), temp.clone()), ("TEMP".into(), temp.clone())].into(), root.clone());
|
||||||
|
let mut requested = Vec::new();
|
||||||
|
let mut fetch = |url: &str| -> Result<FetchResponse, String> {
|
||||||
|
requested.push(url.to_string());
|
||||||
|
let mut res = FetchResponse { status: 200, location: None, body: Box::new(std::io::Cursor::new(Vec::new())) };
|
||||||
|
if url.ends_with("/api/download/bundle/universal") {
|
||||||
|
res.status = 302;
|
||||||
|
res.location = Some(release.into());
|
||||||
|
} else if url == format!("{release}.sig.json") {
|
||||||
|
res.body = Box::new(std::io::Cursor::new(signature.clone()));
|
||||||
|
match case {
|
||||||
|
"missing" => res.status = 404,
|
||||||
|
"oversized" => res.body = Box::new(std::io::repeat(b' ')),
|
||||||
|
"downgrade" => { res.status = 302; res.location = Some("http://unsafe.test/sig".into()); }
|
||||||
|
"invalid-signature" => res.body = Box::new(std::io::Cursor::new(b"{}".to_vec())),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else if url == release {
|
||||||
|
let mut bytes = zip.clone();
|
||||||
|
if case == "tampered" { bytes[0] ^= 1; }
|
||||||
|
if case == "malformed-zip" { bytes = b"not even a ZIP".to_vec(); }
|
||||||
|
res.body = Box::new(std::io::Cursor::new(bytes));
|
||||||
|
} else { panic!("Unexpected URL: {url}"); }
|
||||||
|
Ok(res)
|
||||||
|
};
|
||||||
|
let result = download_and_extract_signed_bundle(&sys, &mut fetch, &keys);
|
||||||
|
if case == "valid" {
|
||||||
|
let staging = result.unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(format!("{staging}/.claude/skills/impeccable/SKILL.md")).unwrap(), "verified skill");
|
||||||
|
assert!(!util::exists(&format!("{staging}/bundle.zip")));
|
||||||
|
assert!(!util::exists(&format!("{staging}/bundle.sig.json")));
|
||||||
|
util::rm_rf(&staging);
|
||||||
|
} else {
|
||||||
|
let error = result.unwrap_err();
|
||||||
|
if case == "malformed-zip" {
|
||||||
|
assert!(error.contains("size"), "must reject before ZIP parsing: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(std::fs::read_to_string(&installed).unwrap(), "user's existing skill");
|
||||||
|
assert_eq!(std::fs::read_dir(&temp).unwrap().count(), 0, "staging leak in {case}");
|
||||||
|
assert_eq!(requested[0], format!("{API_BASE}/api/download/bundle/universal"));
|
||||||
|
assert_eq!(requested[1], format!("{release}.sig.json"));
|
||||||
|
util::rm_rf(&root);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
//! Authenticity gate for remote skill bundles. The only trust roots are the
|
||||||
|
//! public keys compiled into this binary, never anything in a download.
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use regex::Regex;
|
||||||
|
use ring::signature::{UnparsedPublicKey, ED25519};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{collections::BTreeMap, io::Read};
|
||||||
|
|
||||||
|
pub(crate) const MAX_SIGNATURE_BYTES: u64 = 16 * 1024;
|
||||||
|
pub(crate) const ERROR_PREFIX: &str = "Could not verify skill bundle: ";
|
||||||
|
pub(crate) type TrustedKeys = BTreeMap<String, String>;
|
||||||
|
static VERSION: Lazy<Regex> = Lazy::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
||||||
|
).unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
pub(crate) fn trusted_keys() -> Result<TrustedKeys, String> {
|
||||||
|
serde_json::from_str(include_str!("../../../scripts/bundle-signing-keys.json"))
|
||||||
|
.map_err(|_| "Invalid compiled bundle signing keyring".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn release_version(location: &str) -> Result<String, String> {
|
||||||
|
let version = location
|
||||||
|
.strip_prefix("https://github.com/pbakaus/impeccable/releases/download/skill-v")
|
||||||
|
.and_then(|s| s.strip_suffix("/universal.zip"))
|
||||||
|
.filter(|v| v.len() <= 128 && VERSION.is_match(v));
|
||||||
|
version.map(str::to_string).ok_or_else(|| {
|
||||||
|
"Bundle download must redirect to a versioned Impeccable GitHub release".into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct Envelope {
|
||||||
|
schema: u32,
|
||||||
|
key_id: String,
|
||||||
|
version: String,
|
||||||
|
artifact: String,
|
||||||
|
size: u64,
|
||||||
|
sha256: String,
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_hex(value: &str, size: usize) -> Result<Vec<u8>, String> {
|
||||||
|
if value.len() != size * 2
|
||||||
|
|| !value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||||
|
{
|
||||||
|
return Err("Invalid bundle signature encoding".into());
|
||||||
|
}
|
||||||
|
(0..value.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(&value[i..i + 2], 16).map_err(|_| "Invalid hex".into()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn verify_reader(
|
||||||
|
reader: &mut dyn Read,
|
||||||
|
signature: &[u8],
|
||||||
|
version: &str,
|
||||||
|
keys: &TrustedKeys,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if signature.len() as u64 > MAX_SIGNATURE_BYTES {
|
||||||
|
return Err("Bundle signature is too large".into());
|
||||||
|
}
|
||||||
|
let envelope: Envelope = serde_json::from_slice(signature)
|
||||||
|
.map_err(|_| "Missing or malformed bundle signature".to_string())?;
|
||||||
|
if envelope.schema != 1
|
||||||
|
|| envelope.version != version
|
||||||
|
|| !VERSION.is_match(version)
|
||||||
|
|| envelope.artifact != "universal.zip"
|
||||||
|
|| envelope.size == 0
|
||||||
|
|| envelope.size > crate::bundle::MAX_DOWNLOAD_BYTES
|
||||||
|
{
|
||||||
|
return Err("Bundle signature metadata does not match the requested release".into());
|
||||||
|
}
|
||||||
|
let public_key = keys
|
||||||
|
.get(&envelope.key_id)
|
||||||
|
.ok_or("Unknown bundle signing key; update the Impeccable CLI and retry")?;
|
||||||
|
let public_key = decode_hex(public_key, 32)?;
|
||||||
|
let signature = decode_hex(&envelope.signature, 64)?;
|
||||||
|
decode_hex(&envelope.sha256, 32)?;
|
||||||
|
let payload = format!(
|
||||||
|
"impeccable-skill-bundle-v1\n{}\nskill-v{}\n{}\n{}\n{}\n",
|
||||||
|
envelope.key_id, envelope.version, envelope.artifact, envelope.size, envelope.sha256
|
||||||
|
);
|
||||||
|
UnparsedPublicKey::new(&ED25519, public_key)
|
||||||
|
.verify(payload.as_bytes(), &signature)
|
||||||
|
.map_err(|_| "Bundle signature verification failed".to_string())?;
|
||||||
|
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
let mut size = 0u64;
|
||||||
|
let mut buffer = [0u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let count = reader.read(&mut buffer).map_err(|e| e.to_string())?;
|
||||||
|
if count == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
size += count as u64;
|
||||||
|
if size > envelope.size {
|
||||||
|
return Err("Bundle size does not match its signature".into());
|
||||||
|
}
|
||||||
|
hash.update(&buffer[..count]);
|
||||||
|
}
|
||||||
|
if size != envelope.size || format!("{:x}", hash.finalize()) != envelope.sha256 {
|
||||||
|
return Err("Bundle digest or size does not match its signature".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "Set IMPECCABLE_VERIFY_BUNDLE and IMPECCABLE_VERIFY_BUNDLE_VERSION to a reviewed release ZIP"]
|
||||||
|
fn verifies_reviewed_release_with_production_keyring() {
|
||||||
|
let path = std::env::var("IMPECCABLE_VERIFY_BUNDLE").unwrap();
|
||||||
|
let version = std::env::var("IMPECCABLE_VERIFY_BUNDLE_VERSION").unwrap();
|
||||||
|
let signature = std::fs::read(format!("{path}.sig.json")).unwrap();
|
||||||
|
let mut file = std::fs::File::open(path).unwrap();
|
||||||
|
verify_reader(&mut file, &signature, &version, &trusted_keys().unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verifies_node_interoperability_vector() {
|
||||||
|
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||||
|
"../../../tests/fixtures/bundle-signature.json"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let bundle = fixture["bundle"].as_str().unwrap().as_bytes();
|
||||||
|
let envelope = serde_json::to_vec(&fixture["envelope"]).unwrap();
|
||||||
|
let keys = serde_json::from_value(fixture["keys"].clone()).unwrap();
|
||||||
|
verify_reader(&mut &bundle[..], &envelope, "4.2.0", &keys).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> (Vec<u8>, Vec<u8>, std::collections::BTreeMap<String, String>) {
|
||||||
|
// A public, deterministic TEST key. Never present in the production keyring.
|
||||||
|
let key = Ed25519KeyPair::from_seed_unchecked(&[7; 32]).unwrap();
|
||||||
|
let bundle = b"test bundle".to_vec();
|
||||||
|
let digest = format!("{:x}", Sha256::digest(&bundle));
|
||||||
|
let payload = format!(
|
||||||
|
"impeccable-skill-bundle-v1\ntest-only\nskill-v4.2.0\nuniversal.zip\n11\n{digest}\n"
|
||||||
|
);
|
||||||
|
let hex = |bytes: &[u8]| bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||||
|
let envelope = serde_json::json!({
|
||||||
|
"schema": 1, "keyId": "test-only", "version": "4.2.0",
|
||||||
|
"artifact": "universal.zip", "size": 11, "sha256": digest,
|
||||||
|
"signature": hex(key.sign(payload.as_bytes()).as_ref()),
|
||||||
|
});
|
||||||
|
(
|
||||||
|
bundle,
|
||||||
|
serde_json::to_vec(&envelope).unwrap(),
|
||||||
|
[("test-only".into(), hex(key.public_key().as_ref()))].into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_signed_bytes_and_rejects_tampering() {
|
||||||
|
let (bundle, envelope, keys) = fixture();
|
||||||
|
verify_reader(&mut &bundle[..], &envelope, "4.2.0", &keys).unwrap();
|
||||||
|
for tampered in [b"Test bundle".as_slice(), b"test bundle extra", b"test"] {
|
||||||
|
assert!(verify_reader(&mut &tampered[..], &envelope, "4.2.0", &keys).is_err());
|
||||||
|
}
|
||||||
|
assert!(verify_reader(&mut &bundle[..], &envelope, "4.2.1", &keys).is_err());
|
||||||
|
assert!(verify_reader(&mut &bundle[..], &envelope, "4.2.0", &Default::default()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_changed_metadata_bad_encodings_and_unsigned_bundles() {
|
||||||
|
let (bundle, envelope, keys) = fixture();
|
||||||
|
for (field, value) in [
|
||||||
|
("schema", serde_json::json!(2)),
|
||||||
|
("keyId", serde_json::json!("attacker")),
|
||||||
|
("version", serde_json::json!("4.2.1")),
|
||||||
|
("artifact", serde_json::json!("other.zip")),
|
||||||
|
("size", serde_json::json!(10)),
|
||||||
|
("sha256", serde_json::json!("0".repeat(64))),
|
||||||
|
("signature", serde_json::json!("0".repeat(128))),
|
||||||
|
("signature", serde_json::json!("ff")),
|
||||||
|
("sha256", serde_json::json!("g".repeat(64))),
|
||||||
|
(
|
||||||
|
"publicKey",
|
||||||
|
serde_json::json!("never trust an embedded key"),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut bad: serde_json::Value = serde_json::from_slice(&envelope).unwrap();
|
||||||
|
bad[field] = value;
|
||||||
|
assert!(
|
||||||
|
verify_reader(
|
||||||
|
&mut &bundle[..],
|
||||||
|
&serde_json::to_vec(&bad).unwrap(),
|
||||||
|
"4.2.0",
|
||||||
|
&keys
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"{field}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for malformed in [b"".as_slice(), b"{}", b"not json"] {
|
||||||
|
assert!(verify_reader(&mut &bundle[..], malformed, "4.2.0", &keys).is_err());
|
||||||
|
}
|
||||||
|
let duplicate = String::from_utf8(envelope)
|
||||||
|
.unwrap()
|
||||||
|
.replacen('{', "{\"schema\":1,", 1);
|
||||||
|
assert!(verify_reader(&mut &bundle[..], duplicate.as_bytes(), "4.2.0", &keys).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_location_is_exact_and_versioned() {
|
||||||
|
let prefix = "https://github.com/pbakaus/impeccable/releases/download/";
|
||||||
|
assert_eq!(
|
||||||
|
release_version(&format!("{prefix}skill-v4.2.0/universal.zip")).unwrap(),
|
||||||
|
"4.2.0"
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
format!("{prefix}skill-v4.2.0/other.zip"),
|
||||||
|
format!("{prefix}skill-v4.2.0/universal.zip?key=x"),
|
||||||
|
format!("{prefix}skill-v4.2.0/universal.zip#x"),
|
||||||
|
format!("{prefix}skill-v04.2.0/universal.zip"),
|
||||||
|
"http://github.com/pbakaus/impeccable/releases/download/skill-v4.2.0/universal.zip".into(),
|
||||||
|
"https://github.com/attacker/impeccable/releases/download/skill-v4.2.0/universal.zip".into(),
|
||||||
|
"https://github.com.evil.test/pbakaus/impeccable/releases/download/skill-v4.2.0/universal.zip".into(),
|
||||||
|
] {
|
||||||
|
assert!(release_version(&bad).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -588,7 +588,8 @@ fn install(flags: &[String], io: &mut Io) -> R<()> {
|
|||||||
match bundle::download_and_extract_bundle(&sys) {
|
match bundle::download_and_extract_bundle(&sys) {
|
||||||
Ok(dir) => bundle_dir = Some(dir),
|
Ok(dir) => bundle_dir = Some(dir),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if !missing_hook_targets.is_empty() || !missing_selected_targets.is_empty() {
|
if e.starts_with(crate::bundle_signature::ERROR_PREFIX)
|
||||||
|
|| !missing_hook_targets.is_empty() || !missing_selected_targets.is_empty() {
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
update_check_skipped = true;
|
update_check_skipped = true;
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
//! `cli/bin/commands/skills.mjs` (plus the slice of `cli/lib/impeccable-config.mjs`
|
//! `cli/bin/commands/skills.mjs` (plus the slice of `cli/lib/impeccable-config.mjs`
|
||||||
//! it imports).
|
//! it imports).
|
||||||
//!
|
//!
|
||||||
//! Two deliberate departures from the JS, both part of the release that
|
//! Deliberate departures from the original JS behavior:
|
||||||
//! replaces the Node scripts with the binary:
|
|
||||||
//!
|
//!
|
||||||
//! 1. After a skill directory is written (fresh install, refresh, update), if
|
//! 1. After a skill directory is written (fresh install, refresh, update), if
|
||||||
//! its `scripts/VERSION` exists and `scripts/bin/<os>-<arch>/impeccable`
|
//! its `scripts/VERSION` exists and `scripts/bin/<os>-<arch>/impeccable`
|
||||||
@@ -19,11 +18,16 @@
|
|||||||
//! (`impeccable_hook::admin`) writes, and both paths recognize a manifest
|
//! (`impeccable_hook::admin`) writes, and both paths recognize a manifest
|
||||||
//! entry as ours through `impeccable_context::hook_markers`, so the two
|
//! entry as ours through `impeccable_context::hook_markers`, so the two
|
||||||
//! never drift on detection. See `hook_manifest`.
|
//! never drift on detection. See `hook_manifest`.
|
||||||
|
//! 3. Remote skill ZIPs require an Ed25519 signature from a compiled-in key
|
||||||
|
//! before extraction. Failure is fatal even when an existing install is
|
||||||
|
//! present. Explicit local bundle overrides remain unsigned development
|
||||||
|
//! inputs. See `bundle_signature` and docs/BUNDLE-SIGNING.md.
|
||||||
//!
|
//!
|
||||||
//! Everything else (messages, exit codes, endpoints, flags, prompts, file
|
//! Everything else (messages, exit codes, endpoints, flags, prompts, file
|
||||||
//! layout) follows the JS byte for byte.
|
//! layout) follows the JS byte for byte.
|
||||||
|
|
||||||
pub mod bundle;
|
pub mod bundle;
|
||||||
|
mod bundle_signature;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod engine_binary;
|
pub mod engine_binary;
|
||||||
pub mod hook_manifest;
|
pub mod hook_manifest;
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Skill bundle signatures
|
||||||
|
|
||||||
|
`impeccable install`, `update`, and `check` authenticate a remote skill ZIP
|
||||||
|
before extracting it. `universal.zip.sig.json` is an Ed25519 signature over
|
||||||
|
the ZIP's SHA-256 digest, byte length, release version, artifact name, and key
|
||||||
|
ID. The engine trusts only `scripts/bundle-signing-keys.json`, compiled into
|
||||||
|
the binary. A signature cannot introduce a new trusted key.
|
||||||
|
|
||||||
|
The download endpoint on impeccable.style redirects to a versioned GitHub
|
||||||
|
release. The installer resolves that redirect once and downloads the ZIP and
|
||||||
|
its signature from that same release. Every subsequent redirect must use
|
||||||
|
HTTPS. Missing signatures, unknown keys, changed metadata, and changed ZIP
|
||||||
|
bytes stop the operation before extraction or writes to installed skills.
|
||||||
|
The temporary download directory is removed on failure.
|
||||||
|
|
||||||
|
## Sign a release
|
||||||
|
|
||||||
|
Install the 1Password CLI and enable its desktop app integration. The signing
|
||||||
|
item holds the PKCS#8 Ed25519 private key in a concealed `private-key` field.
|
||||||
|
Set references, not key material, in your shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export OP_ACCOUNT='<account ID or sign-in address>'
|
||||||
|
export IMPECCABLE_SIGNING_KEY_REF='op://<vault ID>/<item ID>/private-key'
|
||||||
|
bun run release:skill
|
||||||
|
```
|
||||||
|
|
||||||
|
For a persistent setup on your machine, use local Git settings instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git config --local impeccable.signingAccount '<account ID or sign-in address>'
|
||||||
|
git config --local impeccable.signingKeyRef 'op://<vault ID>/<item ID>/private-key'
|
||||||
|
```
|
||||||
|
|
||||||
|
Those values stay in `.git/config`, outside version control. Environment
|
||||||
|
variables take precedence. Neither setting contains the private key.
|
||||||
|
|
||||||
|
The release command rebuilds the ZIP, reads the key through `op read`, checks
|
||||||
|
that its public key is trusted, and writes the sidecar before creating any
|
||||||
|
tag or release. The ZIP and sidecar are uploaded together. The key is never
|
||||||
|
passed as a command argument, written to a temporary file, or printed. It
|
||||||
|
does exist briefly in the local signing process's memory. 1Password failures
|
||||||
|
are reported without forwarding child-process output.
|
||||||
|
|
||||||
|
`--dry-run` does not access 1Password or create a signature. It checks the
|
||||||
|
usual release prerequisites and shows both assets in the upload plan; it
|
||||||
|
does not prove that signing credentials work.
|
||||||
|
|
||||||
|
To sign an already-published release for the initial rollout, download and
|
||||||
|
review the exact released `universal.zip`, then run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/sign-bundle.mjs 4.2.0 /path/to/universal.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the resulting sidecar against the Rust verifier and compiled public key:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
IMPECCABLE_VERIFY_BUNDLE=/path/to/universal.zip \
|
||||||
|
IMPECCABLE_VERIFY_BUNDLE_VERSION=4.2.0 \
|
||||||
|
cargo test -p impeccable-skills verifies_reviewed_release_with_production_keyring -- --ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates only the local sidecar. It neither uploads it nor replaces the
|
||||||
|
ZIP. Never regenerate an old ZIP and sign those different bytes as the old
|
||||||
|
release. Uploading the sidecar is a separate maintainer approval step.
|
||||||
|
|
||||||
|
## Rollout and rotation
|
||||||
|
|
||||||
|
Before shipping the enforcing engine, publish a valid signature beside the
|
||||||
|
exact ZIP currently served by impeccable.style. Verify the pair using a
|
||||||
|
locally built engine, then release the engine, its npm platform packages, and
|
||||||
|
the CLI/skill pins. Keep the existing release available throughout. Do not
|
||||||
|
release an enforcing engine with an empty keyring or an unsigned served ZIP.
|
||||||
|
|
||||||
|
For planned rotation, ship an engine trusting both the old and new public
|
||||||
|
keys before signing with the new key. Older engines that do not know the new
|
||||||
|
key will refuse the download and ask for a CLI update. A compromised key
|
||||||
|
requires an engine update removing that public key; removing it from a
|
||||||
|
website does not revoke trust in already-installed binaries. Keep the
|
||||||
|
dedicated signing item separate from GitHub and deployment credentials.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This protects against bundle substitution when an attacker can change the
|
||||||
|
download endpoint, release asset, or both, but cannot use the signing key or
|
||||||
|
replace the trusted engine. It is not a freshness protocol: a previously
|
||||||
|
signed release can still be replayed. Signed timestamp metadata and rollback
|
||||||
|
state are separate work. Signatures do not establish that authored skill
|
||||||
|
content is safe, and do not authenticate separately downloaded engine
|
||||||
|
binaries (those currently use their existing SHA-256 sidecars).
|
||||||
|
|
||||||
|
`IMPECCABLE_BUNDLE_PATH` and `impeccable link` are explicit local-development
|
||||||
|
trust paths. They continue to accept unsigned local files/directories. Do not
|
||||||
|
use those overrides to get around a failed remote verification. There is no
|
||||||
|
unsigned-network fallback or skip-signature flag.
|
||||||
|
|
||||||
|
## Wire format
|
||||||
|
|
||||||
|
JSON sidecar fields: `schema` (1), `keyId`, `version`, `artifact`
|
||||||
|
(`universal.zip`), `size`, `sha256`, `signature`. Hex strings are lowercase;
|
||||||
|
the public key is 32 bytes and the signature is 64 bytes. Unknown or repeated
|
||||||
|
fields are rejected. The signature payload is UTF-8 with LF line endings
|
||||||
|
and a final LF:
|
||||||
|
|
||||||
|
```text
|
||||||
|
impeccable-skill-bundle-v1
|
||||||
|
<keyId>
|
||||||
|
skill-v<version>
|
||||||
|
universal.zip
|
||||||
|
<size as decimal>
|
||||||
|
<sha256 as lowercase hex>
|
||||||
|
```
|
||||||
|
|
||||||
|
The Node signer and Rust verifier share a fixed test vector under
|
||||||
|
`tests/fixtures/bundle-signature.json`. Its deterministic test key must never
|
||||||
|
be added to the production keyring.
|
||||||
@@ -342,6 +342,16 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
|
|||||||
|
|
||||||
#### `impeccable help|install|link|update|check` and `impeccable skills <verb>` (`cli/bin/commands/skills.mjs`)
|
#### `impeccable help|install|link|update|check` and `impeccable skills <verb>` (`cli/bin/commands/skills.mjs`)
|
||||||
|
|
||||||
|
**Rust authenticity addition (#479):** the historical JS bundle flow below
|
||||||
|
is superseded for remote downloads. The Rust installer resolves the site's
|
||||||
|
redirect (301/302/303/307/308) to a versioned Impeccable GitHub release and verifies
|
||||||
|
`universal.zip.sig.json` against a compiled-in Ed25519 public key before ZIP
|
||||||
|
extraction. Missing/invalid signatures, unknown keys, mismatched metadata or
|
||||||
|
content, and failures fetching either asset exit nonzero, including when
|
||||||
|
`install` finds an existing installation. No downloaded content reaches the
|
||||||
|
installed skill or hook files. Explicit `IMPECCABLE_BUNDLE_PATH` and `link`
|
||||||
|
retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNING.md).
|
||||||
|
|
||||||
- **Invoked from**: README.md ("npx impeccable install / update"), README.npm.md Quick Start (`npx impeccable skills install`, `... install -y --providers=claude,codex --scope=project`, `... update`, `... install --no-hooks`, `... link --source=.impeccable --providers=claude,cursor`, `... skills help`), `README.md:360` (hook consent explanation).
|
- **Invoked from**: README.md ("npx impeccable install / update"), README.npm.md Quick Start (`npx impeccable skills install`, `... install -y --providers=claude,codex --scope=project`, `... update`, `... install --no-hooks`, `... link --source=.impeccable --providers=claude,cursor`, `... skills help`), `README.md:360` (hook consent explanation).
|
||||||
- `run(args)`: `args[0]` ∈ `undefined|help|--help|-h` → `showHelp()`; `install` → `install(rest)`; `link`; `update`; `check` (ignores flags); else `stderr> Unknown skills command: ${sub}` + `Run 'impeccable --help' for available commands.`, `exit 1`.
|
- `run(args)`: `args[0]` ∈ `undefined|help|--help|-h` → `showHelp()`; `install` → `install(rest)`; `link`; `update`; `check` (ignores flags); else `stderr> Unknown skills command: ${sub}` + `Run 'impeccable --help' for available commands.`, `exit 1`.
|
||||||
- Constants: `API_BASE = 'https://impeccable.style'`; `PROVIDER_DIRS = ['.claude','.cursor','.gemini','.agents','.agent','.github','.grok','.hermes','.kiro','.opencode','.pi','.qoder','.trae','.trae-cn','.rovodev','.vibe']`; aliases (`agent`→`.agent`, `agents`/`codex`→`.agents`, `antigravity`→`.agent`, `claude`/`claude-code`→`.claude`, `copilot`/`github`→`.github`, `cursor`, `gemini`, `grok`/`grok-build`/`xai`→`.grok`, `hermes`, `kiro`, `opencode`, `pi`, `qoder`, `rovo-dev`/`rovodev`→`.rovodev`, `trae`, `trae-cn`, `vibe`); leading `.` stripped and lowercased before alias lookup; a literal PROVIDER_DIR value is accepted as-is. `DEFAULT_TARGETS = ['.claude','.agents']`. User-scope skill dir overrides: `.agent`→`~/.gemini/config/skills`, `.hermes`→`$HERMES_HOME/skills` (only when HERMES_HOME under home) else `~/.hermes/skills`, `.pi`→`~/.pi/agent/skills`, `.opencode`→`$OPENCODE_CONFIG_DIR|$XDG_CONFIG_HOME/opencode|~/.config/opencode` + `/skills`; others `~/<provider>/skills`. Project scope: `<root>/<provider>/skills`.
|
- Constants: `API_BASE = 'https://impeccable.style'`; `PROVIDER_DIRS = ['.claude','.cursor','.gemini','.agents','.agent','.github','.grok','.hermes','.kiro','.opencode','.pi','.qoder','.trae','.trae-cn','.rovodev','.vibe']`; aliases (`agent`→`.agent`, `agents`/`codex`→`.agents`, `antigravity`→`.agent`, `claude`/`claude-code`→`.claude`, `copilot`/`github`→`.github`, `cursor`, `gemini`, `grok`/`grok-build`/`xai`→`.grok`, `hermes`, `kiro`, `opencode`, `pi`, `qoder`, `rovo-dev`/`rovodev`→`.rovodev`, `trae`, `trae-cn`, `vibe`); leading `.` stripped and lowercased before alias lookup; a literal PROVIDER_DIR value is accepted as-is. `DEFAULT_TARGETS = ['.claude','.agents']`. User-scope skill dir overrides: `.agent`→`~/.gemini/config/skills`, `.hermes`→`$HERMES_HOME/skills` (only when HERMES_HOME under home) else `~/.hermes/skills`, `.pi`→`~/.pi/agent/skills`, `.opencode`→`$OPENCODE_CONFIG_DIR|$XDG_CONFIG_HOME/opencode|~/.config/opencode` + `/skills`; others `~/<provider>/skills`. Project scope: `<root>/<provider>/skills`.
|
||||||
|
|||||||
@@ -232,6 +232,10 @@ this feature, replacing the `detectText` call it makes into the npm
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
Remote skill ZIPs require a pinned-key signature before extraction. See
|
||||||
|
[bundle signing](BUNDLE-SIGNING.md) for the 1Password setup and the required
|
||||||
|
signature-first rollout order.
|
||||||
|
|
||||||
Two release kinds touch the runtime, in this order:
|
Two release kinds touch the runtime, in this order:
|
||||||
|
|
||||||
1. **Engine** (`engine-v<ENGINE_VERSION>`): `bun run release:engine` verifies
|
1. **Engine** (`engine-v<ENGINE_VERSION>`): `bun run release:engine` verifies
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "impeccable",
|
"name": "impeccable",
|
||||||
"version": "4.0.0",
|
"version": "4.0.2",
|
||||||
"author": "Paul Bakaus",
|
"author": "Paul Bakaus",
|
||||||
"description": "Design skills, commands, and anti-pattern detection for AI coding agents",
|
"description": "Design skills, commands, and anti-pattern detection for AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -71,11 +71,11 @@
|
|||||||
"check:engine-release": "node scripts/check-engine-release.mjs"
|
"check:engine-release": "node scripts/check-engine-release.mjs"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@impeccable/cli-darwin-arm64": "0.1.0",
|
"@impeccable/cli-darwin-arm64": "0.1.1",
|
||||||
"@impeccable/cli-darwin-x64": "0.1.0",
|
"@impeccable/cli-darwin-x64": "0.1.1",
|
||||||
"@impeccable/cli-linux-x64": "0.1.0",
|
"@impeccable/cli-linux-x64": "0.1.1",
|
||||||
"@impeccable/cli-linux-arm64": "0.1.0",
|
"@impeccable/cli-linux-arm64": "0.1.1",
|
||||||
"@impeccable/cli-windows-x64": "0.1.0"
|
"@impeccable/cli-windows-x64": "0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ai-sdk/anthropic": "^4.0.7",
|
"@ai-sdk/anthropic": "^4.0.7",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"release-2026-09": "7433133bb92da2c0da4186925f36dbd36219c11192451df56f25fd6a23dd7db9"
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { checkEngineRelease } from './check-engine-release.mjs';
|
import { checkEngineRelease } from './check-engine-release.mjs';
|
||||||
import { readEngineVersion } from './fetch-engine.mjs';
|
import { readEngineVersion } from './fetch-engine.mjs';
|
||||||
|
import { signReleaseBundle } from './sign-bundle.mjs';
|
||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
@@ -255,6 +256,25 @@ for (const artifact of cfg.artifacts) {
|
|||||||
ok(artifact);
|
ok(artifact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sign the final rebuilt bytes before any tag or upload. Dry runs do not
|
||||||
|
// unlock 1Password or write a signature; they only show the publishing plan.
|
||||||
|
if (component === 'skill') {
|
||||||
|
const signatureArtifact = 'dist/universal.zip.sig.json';
|
||||||
|
step('Signing universal.zip with the trusted 1Password release key');
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(' [dry-run] Sign dist/universal.zip (1Password is not accessed)');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
signReleaseBundle({ zipPath: path.join(repoRoot, 'dist/universal.zip'), version });
|
||||||
|
} catch (error) {
|
||||||
|
fail(error.message);
|
||||||
|
}
|
||||||
|
if (!existsSync(path.join(repoRoot, signatureArtifact))) fail(`Missing artifact: ${signatureArtifact}`);
|
||||||
|
ok('signature verified locally');
|
||||||
|
}
|
||||||
|
cfg.artifacts.push(signatureArtifact);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('\n--- Release notes preview ---');
|
console.log('\n--- Release notes preview ---');
|
||||||
console.log(notes);
|
console.log(notes);
|
||||||
console.log('--- end preview ---\n');
|
console.log('--- end preview ---\n');
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Local release signing. Private key material travels from op through a pipe
|
||||||
|
// into crypto, never through argv, environment values, logs or temporary files.
|
||||||
|
import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync, writeFileSync, statSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||||
|
const MAX_BUNDLE_BYTES = 256 * 1024 * 1024;
|
||||||
|
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
|
||||||
|
function localSetting(name) {
|
||||||
|
try {
|
||||||
|
return execFileSync('git', ['config', '--local', '--get', name], {
|
||||||
|
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
} catch { return undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicKeyHex(key) {
|
||||||
|
if (key.asymmetricKeyType !== 'ed25519') throw new Error('Signing requires an Ed25519 key.');
|
||||||
|
const jwk = key.export({ format: 'jwk' });
|
||||||
|
return Buffer.from(jwk.x, 'base64url').toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared wire format with crates/skills/src/bundle_signature.rs. UTF-8, LF,
|
||||||
|
// trailing LF. Sign fields explicitly so JSON whitespace/order is irrelevant.
|
||||||
|
export function signaturePayload({ keyId, version, artifact, size, sha256 }) {
|
||||||
|
return Buffer.from(`impeccable-skill-bundle-v1\n${keyId}\nskill-v${version}\n${artifact}\n${size}\n${sha256}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTrustedKeys() {
|
||||||
|
return JSON.parse(readFileSync(new URL('./bundle-signing-keys.json', import.meta.url), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signBundle(bytes, version, privateKey, trustedKeys) {
|
||||||
|
if (typeof version !== 'string' || version.length > 128 || !VERSION_PATTERN.test(version)) throw new Error('Invalid skill version for signing.');
|
||||||
|
if (!bytes.length || bytes.length > MAX_BUNDLE_BYTES) throw new Error('Invalid bundle size for signing.');
|
||||||
|
const publicKey = createPublicKey(privateKey);
|
||||||
|
const publicHex = publicKeyHex(publicKey);
|
||||||
|
const keyId = Object.keys(trustedKeys).find(id => trustedKeys[id] === publicHex);
|
||||||
|
if (!keyId || !/^[a-z0-9-]{1,64}$/.test(keyId)) {
|
||||||
|
throw new Error('The signing key is not in the trusted bundle keyring.');
|
||||||
|
}
|
||||||
|
const envelope = {
|
||||||
|
schema: 1, keyId, version, artifact: 'universal.zip', size: bytes.length,
|
||||||
|
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||||
|
};
|
||||||
|
const payload = signaturePayload(envelope);
|
||||||
|
const signature = sign(null, payload, privateKey);
|
||||||
|
if (!verify(null, payload, publicKey, signature)) throw new Error('Signature self-check failed.');
|
||||||
|
return { ...envelope, signature: signature.toString('hex') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFrom1Password(reference, account) {
|
||||||
|
return execFileSync('op', ['read', reference, '--no-newline', ...(account ? ['--account', account] : [])], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'], timeout: 120000, maxBuffer: 16384,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signReleaseBundle({ zipPath, version, trustedKeys = readTrustedKeys(),
|
||||||
|
secretReference = process.env.IMPECCABLE_SIGNING_KEY_REF ?? localSetting('impeccable.signingKeyRef'),
|
||||||
|
account = process.env.OP_ACCOUNT ?? localSetting('impeccable.signingAccount'), readSecret = readFrom1Password }) {
|
||||||
|
if (!secretReference?.startsWith('op://')) {
|
||||||
|
throw new Error('Set IMPECCABLE_SIGNING_KEY_REF to the 1Password private-key reference (op://vault/item/field).');
|
||||||
|
}
|
||||||
|
if (typeof version !== 'string' || version.length > 128 || !VERSION_PATTERN.test(version)) throw new Error('Invalid skill version for signing.');
|
||||||
|
if (statSync(zipPath).size > MAX_BUNDLE_BYTES) throw new Error('Invalid bundle size for signing.');
|
||||||
|
const bytes = readFileSync(zipPath);
|
||||||
|
let pem;
|
||||||
|
try {
|
||||||
|
const secret = readSecret(secretReference, account);
|
||||||
|
pem = Buffer.isBuffer(secret) ? secret : Buffer.from(secret);
|
||||||
|
} catch {
|
||||||
|
// Child-process exceptions can contain stdout/stderr. Never propagate them.
|
||||||
|
throw new Error('Could not read the signing key from 1Password. Check CLI integration and unlock the vault.');
|
||||||
|
}
|
||||||
|
let privateKey;
|
||||||
|
try {
|
||||||
|
privateKey = createPrivateKey(pem);
|
||||||
|
} catch {
|
||||||
|
throw new Error('The 1Password field is not a valid PKCS#8 private key.');
|
||||||
|
} finally {
|
||||||
|
pem.fill(0);
|
||||||
|
}
|
||||||
|
const envelope = signBundle(bytes, version, privateKey, trustedKeys);
|
||||||
|
const output = `${zipPath}.sig.json`;
|
||||||
|
writeFileSync(output, `${JSON.stringify(envelope, null, 2)}\n`);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try {
|
||||||
|
const [version, zipPath, ...extra] = process.argv.slice(2);
|
||||||
|
if (!zipPath || extra.length || path.basename(zipPath) !== 'universal.zip') {
|
||||||
|
throw new Error('Usage: node scripts/sign-bundle.mjs <skill-version> <path/to/universal.zip>');
|
||||||
|
}
|
||||||
|
console.log(`Signed ${signReleaseBundle({ zipPath, version })}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ export const SUITES = {
|
|||||||
'tests/openai-plugin.test.mjs',
|
'tests/openai-plugin.test.mjs',
|
||||||
'tests/process-group.test.mjs',
|
'tests/process-group.test.mjs',
|
||||||
'tests/release.test.mjs',
|
'tests/release.test.mjs',
|
||||||
|
'tests/bundle-signing.test.mjs',
|
||||||
'tests/skill-reference.test.mjs',
|
'tests/skill-reference.test.mjs',
|
||||||
'tests/readme-gitignore.test.mjs',
|
'tests/readme-gitignore.test.mjs',
|
||||||
'tests/test-suites.test.mjs',
|
'tests/test-suites.test.mjs',
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.1.0
|
0.1.1
|
||||||
|
|||||||
@@ -7834,7 +7834,6 @@
|
|||||||
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
if (editBadgeEl && editBadgeEl.style.display !== 'none') renderEditBadge('idle-disabled');
|
||||||
showBar('generating');
|
showBar('generating');
|
||||||
saveSession();
|
saveSession();
|
||||||
sendCheckpoint('generate_started');
|
|
||||||
writeScrollY(window.scrollY);
|
writeScrollY(window.scrollY);
|
||||||
if (variantObserver) variantObserver.disconnect();
|
if (variantObserver) variantObserver.disconnect();
|
||||||
variantObserver = startVariantObserver(currentSessionId);
|
variantObserver = startVariantObserver(currentSessionId);
|
||||||
@@ -7916,7 +7915,6 @@
|
|||||||
showBar('generating');
|
showBar('generating');
|
||||||
startScrollTracking();
|
startScrollTracking();
|
||||||
saveSession();
|
saveSession();
|
||||||
sendCheckpoint('generate_started');
|
|
||||||
writeScrollY(window.scrollY);
|
writeScrollY(window.scrollY);
|
||||||
if (variantObserver) variantObserver.disconnect();
|
if (variantObserver) variantObserver.disconnect();
|
||||||
variantObserver = startVariantObserver(currentSessionId);
|
variantObserver = startVariantObserver(currentSessionId);
|
||||||
@@ -8238,7 +8236,8 @@
|
|||||||
// rasterization from delaying the fetch itself.
|
// rasterization from delaying the fetch itself.
|
||||||
if (!hasAnnotations) {
|
if (!hasAnnotations) {
|
||||||
basePayload.clientSentAt = Date.now();
|
basePayload.clientSentAt = Date.now();
|
||||||
await sendEvent(basePayload);
|
const created = await sendEvent(basePayload);
|
||||||
|
if (created?.ok && currentSessionId === basePayload.id) sendCheckpoint('generate_started');
|
||||||
}
|
}
|
||||||
|
|
||||||
let screenshotPath;
|
let screenshotPath;
|
||||||
@@ -8279,7 +8278,10 @@
|
|||||||
// is semantic input. Plain requests were already dispatched above.
|
// is semantic input. Plain requests were already dispatched above.
|
||||||
if (hasAnnotations) {
|
if (hasAnnotations) {
|
||||||
basePayload.clientSentAt = Date.now();
|
basePayload.clientSentAt = Date.now();
|
||||||
sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
|
const created = await sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
|
||||||
|
// Capture/upload can take seconds. Progress before this acknowledgment
|
||||||
|
// refers to an unknown session and would clear our own active work.
|
||||||
|
if (created?.ok && currentSessionId === basePayload.id) sendCheckpoint('generate_started');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { generateKeyPairSync, createPrivateKey, createPublicKey, verify } from 'node:crypto';
|
||||||
|
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { signBundle, signaturePayload, publicKeyHex, signReleaseBundle, readTrustedKeys } from '../scripts/sign-bundle.mjs';
|
||||||
|
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
||||||
|
const trustedKeys = { 'test-only': publicKeyHex(publicKey) };
|
||||||
|
|
||||||
|
test('production keyring is populated and excludes the public test key', () => {
|
||||||
|
const keys = readTrustedKeys();
|
||||||
|
const fixture = JSON.parse(readFileSync(new URL('./fixtures/bundle-signature.json', import.meta.url)));
|
||||||
|
assert.ok(Object.keys(keys).length > 0);
|
||||||
|
for (const [id, key] of Object.entries(keys)) {
|
||||||
|
assert.match(id, /^[a-z0-9-]{1,64}$/);
|
||||||
|
assert.match(key, /^[0-9a-f]{64}$/);
|
||||||
|
assert.notEqual(key, fixture.keys['test-only']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches the shared Node/Rust interoperability vector (public test seed)', () => {
|
||||||
|
const fixture = JSON.parse(readFileSync(new URL('./fixtures/bundle-signature.json', import.meta.url)));
|
||||||
|
const key = createPrivateKey({
|
||||||
|
key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), Buffer.alloc(32, 7)]),
|
||||||
|
format: 'der', type: 'pkcs8',
|
||||||
|
});
|
||||||
|
assert.deepEqual(signBundle(Buffer.from(fixture.bundle), '4.2.0', key, fixture.keys), fixture.envelope);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signs the exact bundle with version, size, digest, artifact and domain bound', () => {
|
||||||
|
const bundle = Buffer.from('test bundle');
|
||||||
|
const envelope = signBundle(bundle, '4.2.0', privateKey, trustedKeys);
|
||||||
|
assert.equal(envelope.keyId, 'test-only');
|
||||||
|
assert.equal(envelope.version, '4.2.0');
|
||||||
|
assert.equal(envelope.size, bundle.length);
|
||||||
|
assert.equal(envelope.artifact, 'universal.zip');
|
||||||
|
assert.equal(envelope.schema, 1);
|
||||||
|
assert.match(signaturePayload(envelope).toString(), /^impeccable-skill-bundle-v1\n/);
|
||||||
|
assert.ok(verify(null, signaturePayload(envelope), publicKey, Buffer.from(envelope.signature, 'hex')));
|
||||||
|
for (const changed of [
|
||||||
|
{ version: '4.2.1' }, { size: 1 }, { sha256: '0'.repeat(64) },
|
||||||
|
{ artifact: 'other.zip' }, { keyId: 'other-key' },
|
||||||
|
]) {
|
||||||
|
assert.equal(verify(null, signaturePayload({ ...envelope, ...changed }), publicKey,
|
||||||
|
Buffer.from(envelope.signature, 'hex')), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unknown or non-Ed25519 keys and invalid versions', () => {
|
||||||
|
assert.throws(() => signBundle(Buffer.from('zip'), '4.2.0', privateKey, {}), /trusted/);
|
||||||
|
for (const version of ['4.2.0\nother', '../4.2.0', '', '04.2.0', '4.2']) {
|
||||||
|
assert.throws(() => signBundle(Buffer.from('zip'), version, privateKey, trustedKeys), /version/);
|
||||||
|
}
|
||||||
|
const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
assert.throws(() => publicKeyHex(rsa.publicKey), /Ed25519/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('1Password read uses a pipe, checks the pinned key, writes only a public signature', () => {
|
||||||
|
const root = mkdtempSync(path.join(tmpdir(), 'impeccable-sign-test-'));
|
||||||
|
try {
|
||||||
|
const zipPath = path.join(root, 'universal.zip');
|
||||||
|
writeFileSync(zipPath, 'test bundle');
|
||||||
|
const secretReference = 'op://test-vault/test-item/private-key';
|
||||||
|
let called = false;
|
||||||
|
signReleaseBundle({ zipPath, version: '4.2.0', trustedKeys, secretReference,
|
||||||
|
readSecret(reference) {
|
||||||
|
called = true;
|
||||||
|
assert.equal(reference, secretReference);
|
||||||
|
return privateKey.export({ type: 'pkcs8', format: 'pem' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(called);
|
||||||
|
const envelope = JSON.parse(readFileSync(`${zipPath}.sig.json`, 'utf8'));
|
||||||
|
assert.ok(verify(null, signaturePayload(envelope), createPublicKey(privateKey),
|
||||||
|
Buffer.from(envelope.signature, 'hex')));
|
||||||
|
assert.doesNotMatch(readFileSync(`${zipPath}.sig.json`, 'utf8'), /PRIVATE KEY/);
|
||||||
|
assert.throws(() => signReleaseBundle({ zipPath, version: '4.2.0', trustedKeys,
|
||||||
|
secretReference, readSecret() { throw new Error('SECRET that must not leak'); },
|
||||||
|
}), /Could not read.*1Password/);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -174,6 +174,17 @@ describe('npm shim download verification', { skip: process.platform === 'win32'
|
|||||||
assert.deepEqual(cacheEntries(res.home), []);
|
assert.deepEqual(cacheEntries(res.home), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('answers --version and -v from its own package.json without touching a binary', async () => {
|
||||||
|
const expected = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8')).version;
|
||||||
|
for (const args of [['--version'], ['-v'], ['--version', 'extra']]) {
|
||||||
|
const flag = args.join(' ');
|
||||||
|
const res = await runShim(args);
|
||||||
|
assert.equal(res.status, 0, `${flag} exits 0`);
|
||||||
|
assert.equal(res.stdout, `${expected}\n`);
|
||||||
|
assert.deepEqual(requests, [], 'no download was attempted');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('prefers IMPECCABLE_BIN and never downloads', async () => {
|
it('prefers IMPECCABLE_BIN and never downloads', async () => {
|
||||||
sidecar = { status: 404, body: '' };
|
sidecar = { status: 404, body: '' };
|
||||||
const { dir, shim } = stageShim();
|
const { dir, shim } = stageShim();
|
||||||
|
|||||||
Vendored
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"bundle": "test bundle",
|
||||||
|
"keys": {
|
||||||
|
"test-only": "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c"
|
||||||
|
},
|
||||||
|
"envelope": {
|
||||||
|
"schema": 1,
|
||||||
|
"keyId": "test-only",
|
||||||
|
"version": "4.2.0",
|
||||||
|
"artifact": "universal.zip",
|
||||||
|
"size": 11,
|
||||||
|
"sha256": "9df2a47bee5f48b9752b2cbd2d6075076556ee293adc97f66f0e0a916e4f6471",
|
||||||
|
"signature": "35dfe147573341b1fdcb4bc4054b0e96c5d07b9069bd1257f745d6ad6c3eca72f52876e7d0403cffeac2e60ef2dea41879ca4cb9cecaebb463332aaf9d15620b"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,12 @@
|
|||||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||||
"readyTimeoutMs": 120000,
|
"readyTimeoutMs": 120000,
|
||||||
"steer": false,
|
"steer": false,
|
||||||
|
"liveChrome": {
|
||||||
|
"annotations": {
|
||||||
|
"selector": "h1.hero-title",
|
||||||
|
"uploadDelayMs": 300
|
||||||
|
}
|
||||||
|
},
|
||||||
"pickSelector": "ul.expense-list",
|
"pickSelector": "ul.expense-list",
|
||||||
"pickPosition": {
|
"pickPosition": {
|
||||||
"x": 10,
|
"x": 10,
|
||||||
|
|||||||
@@ -2,12 +2,53 @@ import { describe, it } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
|
|
||||||
const SOURCE = readFileSync(join(process.cwd(), 'skill/scripts/live-browser.js'), 'utf-8');
|
const SOURCE = readFileSync(join(process.cwd(), 'skill/scripts/live-browser.js'), 'utf-8');
|
||||||
const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\(\) \{[\s\S]*?\n \}/)?.[0] || '';
|
const PENDING_DOCK_POSITION_SOURCE = SOURCE.match(/function positionPendingDock\(\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||||
const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || '';
|
const CAPTURE_AND_EMIT_SOURCE = SOURCE.match(/async function captureAndEmit\([\s\S]*?\n \}/)?.[0] || '';
|
||||||
|
|
||||||
describe('live-browser source contracts', () => {
|
describe('live-browser source contracts', () => {
|
||||||
|
it('does not checkpoint a generation before captureAndEmit creates its session', () => {
|
||||||
|
for (const name of ['handleGo', 'handleInsertCreate']) {
|
||||||
|
const body = SOURCE.match(new RegExp(`function ${name}\\(\\) \\{[\\s\\S]*?\\n \\}`))?.[0];
|
||||||
|
assert.ok(body);
|
||||||
|
assert.doesNotMatch(body, /sendCheckpoint\('generate_started'\)/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const annotated of [false, true]) {
|
||||||
|
for (const outcome of ['created', 'failed', 'superseded']) {
|
||||||
|
it(`${annotated ? 'annotated' : 'plain'} generation checkpoints only its acknowledged current session (${outcome})`, async () => {
|
||||||
|
const capture = Promise.withResolvers();
|
||||||
|
const creation = Promise.withResolvers();
|
||||||
|
const events = [];
|
||||||
|
const context = {
|
||||||
|
currentSessionId: 'session-a', state: 'GENERATING', PORT: 1234, TOKEN: 'test',
|
||||||
|
console, Date,
|
||||||
|
captureElementToBlob: () => capture.promise,
|
||||||
|
showShaderOverlay() {},
|
||||||
|
fetch: async () => ({ ok: true, json: async () => ({ path: '/annotation.png' }) }),
|
||||||
|
sendEvent: async (payload) => { events.push(payload.type); return creation.promise; },
|
||||||
|
sendCheckpoint: (reason) => events.push(reason),
|
||||||
|
};
|
||||||
|
const emit = runInNewContext(`(${CAPTURE_AND_EMIT_SOURCE})`, context);
|
||||||
|
const pending = emit({}, { type: 'generate', id: 'session-a' }, {
|
||||||
|
comments: annotated ? [{ text: 'change title' }] : [], strokes: [],
|
||||||
|
}, {});
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(events, annotated ? [] : ['generate']);
|
||||||
|
capture.resolve({ blob: {}, paper: 'white' });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(events, ['generate'], 'capture/upload must not checkpoint before creation is acknowledged');
|
||||||
|
if (outcome === 'superseded') context.currentSessionId = 'session-b';
|
||||||
|
creation.resolve(outcome === 'failed' ? null : { ok: true });
|
||||||
|
await pending;
|
||||||
|
assert.deepEqual(events, outcome === 'created' ? ['generate', 'generate_started'] : ['generate']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
it('reports foreground poll connectivity without a background worker dependency', () => {
|
it('reports foreground poll connectivity without a background worker dependency', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
SOURCE,
|
SOURCE,
|
||||||
@@ -29,7 +70,7 @@ describe('live-browser source contracts', () => {
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
CAPTURE_AND_EMIT_SOURCE,
|
CAPTURE_AND_EMIT_SOURCE,
|
||||||
/if \(hasAnnotations\) \{[\s\S]*?basePayload\.clientSentAt = Date\.now\(\);\s*sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);\s*\}/,
|
/if \(hasAnnotations\) \{[\s\S]*?basePayload\.clientSentAt = Date\.now\(\);\s*const created = await sendEvent\(screenshotPath \? \{ \.\.\.basePayload, screenshotPath \} : basePayload\);/,
|
||||||
'annotated generation should dispatch exactly after capture and upload resolve',
|
'annotated generation should dispatch exactly after capture and upload resolve',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
MANUAL_EDIT_SYSTEM_INSTRUCTIONS,
|
MANUAL_EDIT_SYSTEM_INSTRUCTIONS,
|
||||||
VARIANT_SYSTEM_INSTRUCTIONS,
|
VARIANT_SYSTEM_INSTRUCTIONS,
|
||||||
createLlmAgent,
|
createLlmAgent,
|
||||||
|
llmRequestSettings,
|
||||||
parseManualEditResponse,
|
parseManualEditResponse,
|
||||||
parseVariantResponse,
|
parseVariantResponse,
|
||||||
progressiveVariantGuidance,
|
progressiveVariantGuidance,
|
||||||
@@ -19,6 +20,19 @@ import {
|
|||||||
validateVariantVisibleCopy,
|
validateVariantVisibleCopy,
|
||||||
} from './live-e2e/agents/llm-agent.mjs';
|
} from './live-e2e/agents/llm-agent.mjs';
|
||||||
|
|
||||||
|
describe('live-e2e LLM request settings', () => {
|
||||||
|
it('explicitly selects low-effort DeepSeek thinking for bounded JSON edit requests', () => {
|
||||||
|
assert.deepEqual(llmRequestSettings('deepseek'), {
|
||||||
|
thinking: { type: 'enabled' }, output_config: { effort: 'low' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves other providers unchanged', () => {
|
||||||
|
assert.deepEqual(llmRequestSettings('anthropic'), {});
|
||||||
|
assert.deepEqual(llmRequestSettings('openai'), {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('live-e2e LLM agent provider config', () => {
|
describe('live-e2e LLM agent provider config', () => {
|
||||||
it('defaults to OpenAI gpt-5.6-terra at medium reasoning effort', () => {
|
it('defaults to OpenAI gpt-5.6-terra at medium reasoning effort', () => {
|
||||||
const config = resolveLlmAgentConfig({}, {});
|
const config = resolveLlmAgentConfig({}, {});
|
||||||
|
|||||||
+10
-6
@@ -1283,17 +1283,21 @@ for (const { name, fixture } of fixtures) {
|
|||||||
const pickSelector = annotation.selector || fixture.runtime.pickSelector || 'h1.hero-title';
|
const pickSelector = annotation.selector || fixture.runtime.pickSelector || 'h1.hero-title';
|
||||||
try {
|
try {
|
||||||
await waitForHandshake(page);
|
await waitForHandshake(page);
|
||||||
|
if (annotation.uploadDelayMs) {
|
||||||
|
await page.route('**/annotation?*', async (route) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, annotation.uploadDelayMs));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
}
|
||||||
if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions);
|
if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions);
|
||||||
await pickElement(page, pickSelector, { resetPickMode: true });
|
await pickElement(page, pickSelector, { resetPickMode: true });
|
||||||
await drawAnnotationPinAndStroke(page, {
|
await drawAnnotationPinAndStroke(page, {
|
||||||
comment: annotation.comment || 'Make this selected element easier to scan',
|
comment: annotation.comment || 'Make this selected element easier to scan',
|
||||||
});
|
});
|
||||||
await clickGo(page);
|
await clickGo(page);
|
||||||
await waitForCyclingRobust(page, 3, {
|
// A reload would mask a checkpoint-before-creation race by adopting
|
||||||
agentMode,
|
// the session again. Annotated generation must complete in this tab.
|
||||||
preActions: fixture.runtime.preActions,
|
await waitForCycling(page, 3, { timeout: agentMode === 'llm' ? 180_000 : 30_000 });
|
||||||
log: (m) => t.diagnostic(m),
|
|
||||||
});
|
|
||||||
|
|
||||||
const generateEvent = recordedGenerateEvents.at(-1);
|
const generateEvent = recordedGenerateEvents.at(-1);
|
||||||
await assertAnnotationUploadEvent(generateEvent);
|
await assertAnnotationUploadEvent(generateEvent);
|
||||||
@@ -1302,7 +1306,7 @@ for (const { name, fixture } of fixtures) {
|
|||||||
|
|
||||||
const sourceFile = await locateSessionFile(session.appRoot);
|
const sourceFile = await locateSessionFile(session.appRoot);
|
||||||
const svelteComponentTarget = svelteComponentTargetFor(sourceFile);
|
const svelteComponentTarget = svelteComponentTargetFor(sourceFile);
|
||||||
await clickNext(page);
|
await cycleToVariant(page, 2, 3);
|
||||||
assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible after annotated generate');
|
assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible after annotated generate');
|
||||||
await clickAccept(page, { expectedVariant: 2 });
|
await clickAccept(page, { expectedVariant: 2 });
|
||||||
await waitForBarHidden(page);
|
await waitForBarHidden(page);
|
||||||
|
|||||||
@@ -248,6 +248,17 @@ function resolveProvider(opts, env) {
|
|||||||
return 'openai';
|
return 'openai';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function llmRequestSettings(provider) {
|
||||||
|
// DeepSeek defaults to high-effort thinking, which can consume the entire
|
||||||
|
// bounded response before emitting the JSON these edit tests exercise.
|
||||||
|
// Low effort retains planning for the full live spec without inheriting
|
||||||
|
// the provider's high-effort default.
|
||||||
|
// https://api-docs.deepseek.com/guides/thinking_mode/
|
||||||
|
return provider === 'deepseek'
|
||||||
|
? { thinking: { type: 'enabled' }, output_config: { effort: 'low' } }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anthropic-SDK-shaped shim over the `ai` SDK for OpenAI models, so the
|
* Anthropic-SDK-shaped shim over the `ai` SDK for OpenAI models, so the
|
||||||
* three text-only call sites in this file stay provider-agnostic. system
|
* three text-only call sites in this file stay provider-agnostic. system
|
||||||
@@ -331,6 +342,7 @@ export async function createLlmAgent(opts = {}) {
|
|||||||
try {
|
try {
|
||||||
response = await client.messages.create(
|
response = await client.messages.create(
|
||||||
{
|
{
|
||||||
|
...llmRequestSettings(provider),
|
||||||
model,
|
model,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
max_tokens: 16000,
|
max_tokens: 16000,
|
||||||
@@ -476,6 +488,7 @@ export async function createLlmAgent(opts = {}) {
|
|||||||
try {
|
try {
|
||||||
response = await client.messages.create(
|
response = await client.messages.create(
|
||||||
{
|
{
|
||||||
|
...llmRequestSettings(provider),
|
||||||
model,
|
model,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
max_tokens: 16000,
|
max_tokens: 16000,
|
||||||
@@ -605,11 +618,15 @@ export async function createLlmAgent(opts = {}) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const response = await client.messages.create({
|
const response = await client.messages.create({
|
||||||
|
...llmRequestSettings(provider),
|
||||||
model,
|
model,
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
system: systemBlocks(STEER_SYSTEM_INSTRUCTIONS),
|
system: systemBlocks(STEER_SYSTEM_INSTRUCTIONS),
|
||||||
messages: [{ role: 'user', content: userMessage }],
|
messages: [{ role: 'user', content: userMessage }],
|
||||||
});
|
}, provider === 'deepseek' ? {
|
||||||
|
maxRetries: LLM_REQUEST_MAX_RETRIES,
|
||||||
|
timeout: MANUAL_EDIT_REQUEST_TIMEOUT_MS,
|
||||||
|
} : {});
|
||||||
|
|
||||||
const cacheRead = response.usage?.cache_read_input_tokens ?? 0;
|
const cacheRead = response.usage?.cache_read_input_tokens ?? 0;
|
||||||
const inputTokens = response.usage?.input_tokens ?? 0;
|
const inputTokens = response.usage?.input_tokens ?? 0;
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ import {
|
|||||||
clickEditCopy,
|
clickEditCopy,
|
||||||
clickExitLiveMode,
|
clickExitLiveMode,
|
||||||
clickGo,
|
clickGo,
|
||||||
clickNext,
|
cycleToVariant,
|
||||||
clickPrev,
|
|
||||||
clickSaveEdit,
|
clickSaveEdit,
|
||||||
drawAnnotationPinAndStroke,
|
drawAnnotationPinAndStroke,
|
||||||
editTextLeaf,
|
editTextLeaf,
|
||||||
@@ -40,6 +39,7 @@ import {
|
|||||||
waitForBarHidden,
|
waitForBarHidden,
|
||||||
waitForCycling,
|
waitForCycling,
|
||||||
waitForHandshake,
|
waitForHandshake,
|
||||||
|
waitForVariantSettled,
|
||||||
} from './live-e2e/ui.mjs';
|
} from './live-e2e/ui.mjs';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -281,7 +281,7 @@ async function runAnnotationGenerateFlow({ page, tmp, evidence }) {
|
|||||||
const generateEvent = latestJournalEvent(tmp, (event) => event.type === 'generate' && event.screenshotPath);
|
const generateEvent = latestJournalEvent(tmp, (event) => event.type === 'generate' && event.screenshotPath);
|
||||||
await assertAnnotationUploadEvent(generateEvent);
|
await assertAnnotationUploadEvent(generateEvent);
|
||||||
assert.ok(existsSync(generateEvent.screenshotPath), 'annotation screenshot file exists');
|
assert.ok(existsSync(generateEvent.screenshotPath), 'annotation screenshot file exists');
|
||||||
await clickNext(page);
|
await cycleTo(page, 2);
|
||||||
await assertVariantCounter(page, 2, 3);
|
await assertVariantCounter(page, 2, 3);
|
||||||
await evidence.capture('annotation-cycle');
|
await evidence.capture('annotation-cycle');
|
||||||
await clickDiscard(page);
|
await clickDiscard(page);
|
||||||
@@ -514,14 +514,10 @@ async function clickPendingTrash(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cycleTo(page, target) {
|
async function cycleTo(page, target) {
|
||||||
for (let i = 0; i < 6; i++) {
|
// Component imports finish after the counter changes. Do not send the next
|
||||||
const visible = await getVisibleVariant(page);
|
// click (or reload) while the previous variant is still mounting.
|
||||||
if (visible === target) return;
|
await cycleToVariant(page, target, 3);
|
||||||
if (visible == null) await page.waitForTimeout(250);
|
await waitForVariantSettled(page, target, 3);
|
||||||
else if (visible < target) await clickNext(page);
|
|
||||||
else await clickPrev(page);
|
|
||||||
}
|
|
||||||
assert.equal(await getVisibleVariant(page), target, `variant ${target} visible`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForVisibleCycling(page, count, { timeout }) {
|
async function waitForVisibleCycling(page, count, { timeout }) {
|
||||||
|
|||||||
@@ -155,3 +155,12 @@ file, the file set, or the printed lines differs from the JS.
|
|||||||
|
|
||||||
- `pin-opencode-project`, `pin-opencode-user-scope`, `pin-opencode-skips-foreign-command`, `pin-opencode-then-unpin`, `pin-opencode-unpin-skips-foreign`.
|
- `pin-opencode-project`, `pin-opencode-user-scope`, `pin-opencode-skips-foreign-command`, `pin-opencode-then-unpin`, `pin-opencode-unpin-skips-foreign`.
|
||||||
|
|
||||||
|
|
||||||
|
## Recorded 2026-09-04: `--version` follows the npm package to 4.0.0
|
||||||
|
|
||||||
|
The npm shim answers `--version` / `-v` itself from its own `package.json`
|
||||||
|
(docs/CLI-CONTRACT.md), so the number users see tracks the package they
|
||||||
|
installed. The binary's `CLI_VERSION` moves from `3.6.0` to `4.0.0` with the
|
||||||
|
CLI 4.0.0 release; it is what the binary prints when run directly.
|
||||||
|
|
||||||
|
- `cli-version`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"stdout": "3.6.0\n",
|
"stdout": "4.0.0\n",
|
||||||
"stderr": "",
|
"stderr": "",
|
||||||
"exit": 0,
|
"exit": 0,
|
||||||
"signal": null,
|
"signal": null,
|
||||||
|
|||||||
+52
-1
@@ -88,7 +88,7 @@ describe('release.mjs guards', () => {
|
|||||||
// (and check-engine-release.mjs imports fetch-engine.mjs), so stage them
|
// (and check-engine-release.mjs imports fetch-engine.mjs), so stage them
|
||||||
// too or the dry runs fail to resolve the modules instead of exercising
|
// too or the dry runs fail to resolve the modules instead of exercising
|
||||||
// the guard.
|
// the guard.
|
||||||
for (const dep of ['check-engine-release.mjs', 'fetch-engine.mjs']) {
|
for (const dep of ['check-engine-release.mjs', 'fetch-engine.mjs', 'sign-bundle.mjs', 'bundle-signing-keys.json']) {
|
||||||
fs.copyFileSync(path.join(REPO_ROOT, 'scripts', dep), path.join(workDir, 'scripts', dep));
|
fs.copyFileSync(path.join(REPO_ROOT, 'scripts', dep), path.join(workDir, 'scripts', dep));
|
||||||
}
|
}
|
||||||
write('.claude-plugin/plugin.json', JSON.stringify({ name: 'impeccable', version: '1.2.3' }));
|
write('.claude-plugin/plugin.json', JSON.stringify({ name: 'impeccable', version: '1.2.3' }));
|
||||||
@@ -171,6 +171,57 @@ describe('release.mjs guards', () => {
|
|||||||
assert.match(stdout, /tag is free/);
|
assert.match(stdout, /tag is free/);
|
||||||
assert.match(stdout, /\[dry-run\] git tag -a skill-v1\.2\.3/);
|
assert.match(stdout, /\[dry-run\] git tag -a skill-v1\.2\.3/);
|
||||||
assert.match(stdout, /\[dry-run\] gh release create skill-v1\.2\.3/);
|
assert.match(stdout, /\[dry-run\] gh release create skill-v1\.2\.3/);
|
||||||
|
assert.match(stdout, /1Password is not accessed/);
|
||||||
|
assert.match(stdout, /gh release create[^\n]+universal\.zip\.sig\.json/);
|
||||||
|
assert.equal(fs.existsSync(path.join(workDir, 'dist/universal.zip.sig.json')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a real release before tagging when signing is not configured', () => {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(workDir, 'package.json'), 'utf8'));
|
||||||
|
pkg.scripts = { 'build:release': 'node -e "process.exit(0)"' };
|
||||||
|
write('package.json', JSON.stringify(pkg));
|
||||||
|
git(workDir, 'add', 'package.json');
|
||||||
|
git(workDir, 'commit', '-m', 'fixture build command');
|
||||||
|
git(workDir, 'push', 'origin', 'main');
|
||||||
|
assert.throws(() => execFileSync(process.execPath, ['scripts/release.mjs', 'skill'], {
|
||||||
|
cwd: workDir, encoding: 'utf8', stdio: 'pipe',
|
||||||
|
env: { ...process.env, IMPECCABLE_SKIP_ENGINE_CHECK: '1', IMPECCABLE_SIGNING_KEY_REF: '' },
|
||||||
|
}), error => {
|
||||||
|
assert.match(error.stderr, /Set IMPECCABLE_SIGNING_KEY_REF/);
|
||||||
|
assert.doesNotMatch(error.stdout, /Creating annotated tag|Creating GitHub release/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
assert.equal(git(workDir, 'tag'), '');
|
||||||
|
assert.equal(git(workDir, 'ls-remote', '--tags', 'origin'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses before tagging when the signer returns without creating the sidecar', () => {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(workDir, 'package.json'), 'utf8'));
|
||||||
|
pkg.scripts = { 'build:release': 'node -e "process.exit(0)"' };
|
||||||
|
write('package.json', JSON.stringify(pkg));
|
||||||
|
// Stub only inside this disposable repository. No 1Password access, tags,
|
||||||
|
// or real GitHub publication can occur even if the assertion regresses.
|
||||||
|
write('scripts/sign-bundle.mjs', 'export function signReleaseBundle() {}\n');
|
||||||
|
const releaseSource = fs.readFileSync(RELEASE_SCRIPT, 'utf8');
|
||||||
|
const tagStep = 'step(`Creating annotated tag ${tag}`);';
|
||||||
|
assert.ok(releaseSource.includes(tagStep), 'fixture must intercept the tag step');
|
||||||
|
write('scripts/release.mjs', releaseSource.replace(
|
||||||
|
tagStep,
|
||||||
|
'throw new Error("UNEXPECTED_TAG_STEP");'
|
||||||
|
));
|
||||||
|
git(workDir, 'add', 'package.json', 'scripts/sign-bundle.mjs', 'scripts/release.mjs');
|
||||||
|
git(workDir, 'commit', '-m', 'fixture signer with missing output');
|
||||||
|
git(workDir, 'push', 'origin', 'main');
|
||||||
|
assert.throws(() => execFileSync(process.execPath, ['scripts/release.mjs', 'skill'], {
|
||||||
|
cwd: workDir, encoding: 'utf8', stdio: 'pipe',
|
||||||
|
env: { ...process.env, IMPECCABLE_SKIP_ENGINE_CHECK: '1' },
|
||||||
|
}), error => {
|
||||||
|
assert.match(error.stderr, /Missing artifact: dist\/universal\.zip\.sig\.json/);
|
||||||
|
assert.doesNotMatch(error.stderr, /UNEXPECTED_TAG_STEP/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
assert.equal(git(workDir, 'tag'), '');
|
||||||
|
assert.equal(git(workDir, 'ls-remote', '--tags', 'origin'), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts the changelog entry to markdown release notes', () => {
|
it('converts the changelog entry to markdown release notes', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user