diff --git a/.github/workflows/addon-author-packages.yml b/.github/workflows/addon-author-packages.yml new file mode 100644 index 00000000..a7f510e1 --- /dev/null +++ b/.github/workflows/addon-author-packages.yml @@ -0,0 +1,36 @@ +name: Independent add-on author distributions +on: + workflow_dispatch: + pull_request: + paths: + - 'packages/plugin-*/**' + - 'examples/addons/**' + - 'scripts/addons/prepare-release.mjs' + - '.github/workflows/addon-author-packages.yml' +permissions: + contents: read +jobs: + prepare: + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + with: + # A pull request's default checkout is a synthetic merge commit that no + # release tag can reference. Record the proposed head commit instead. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-node@v5 + with: + node-version: 22 + - name: Build independent distributions from committed source + run: node scripts/addons/prepare-release.mjs + - name: Verify exact distribution checksums + working-directory: addon-author-release + run: sha256sum --check SHA256SUMS + - uses: actions/upload-artifact@v4 + with: + # Pull request output is only a review candidate. Release assets come + # from a dispatch (or local run) on the exact commit that will be tagged. + name: ${{ github.event_name == 'pull_request' && format('addon-author-candidate-pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha) || format('addon-author-release-{0}', github.sha) }} + path: addon-author-release/ + if-no-files-found: error diff --git a/.github/workflows/addon-catalog.yml b/.github/workflows/addon-catalog.yml index 5d6b20b6..46260064 100644 --- a/.github/workflows/addon-catalog.yml +++ b/.github/workflows/addon-catalog.yml @@ -5,6 +5,14 @@ on: push: branches: [main] paths: ['catalog/addons/**'] + # Merging only validates. Publishing a revision is a separate maintainer + # dispatch on main, approved through the addon-catalog environment. + workflow_dispatch: + inputs: + revision: + description: Catalog revision to publish; must equal the revision in catalog/addons/catalog-v1.json on main + required: true + type: string permissions: contents: read jobs: @@ -25,20 +33,39 @@ jobs: - name: Check accepted history and inert release provenance env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + # Read-only token for the validator's GitHub API provenance lookups, + # which would otherwise share the anonymous per-IP rate limit. + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: | - if git show "$BASE_SHA:catalog/addons/catalog-v1.json" > /tmp/previous-catalog.json 2>/dev/null; then - cargo run -j 2 --locked --manifest-path src-tauri/addon-catalog/Cargo.toml -- online catalog/addons/catalog-v1.json /tmp/previous-catalog.json - else - cargo run -j 2 --locked --manifest-path src-tauri/addon-catalog/Cargo.toml -- online catalog/addons/catalog-v1.json + previous=() + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then + # A publication must succeed the newest revision already published. + published=$(gh release list --limit 1000 --json tagName --jq '[.[].tagName | select(test("^addons-catalog-r[0-9]+$")) | ltrimstr("addons-catalog-r") | tonumber] | max // empty') + if [ -n "$published" ]; then + gh release download "addons-catalog-r$published" --pattern catalog-v1.json --output /tmp/previous-catalog.json + previous=(/tmp/previous-catalog.json) + fi + elif [ -n "$BASE_SHA" ] && git show "$BASE_SHA:catalog/addons/catalog-v1.json" > /tmp/previous-catalog.json 2>/dev/null; then + previous=(/tmp/previous-catalog.json) fi + cargo run -j 2 --locked --manifest-path src-tauri/addon-catalog/Cargo.toml -- online catalog/addons/catalog-v1.json "${previous[@]}" - uses: actions/upload-artifact@v4 with: name: reviewed-addon-catalog path: catalog/addons/catalog-v1.json publish: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # Never runs for a pull request or merge; only an explicit dispatch on main. + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' needs: validate runs-on: ubuntu-22.04 + # Configure required reviewers for this environment in the repository + # settings so that a dispatch alone cannot publish. + environment: addon-catalog + concurrency: + group: addon-catalog-publish + cancel-in-progress: false permissions: contents: write steps: @@ -49,15 +76,35 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} + REQUESTED_REVISION: ${{ inputs.revision }} run: | revision=$(jq -er '.revision' catalog-v1.json) + if [ "$revision" != "$REQUESTED_REVISION" ]; then + echo "::error::main contains catalog revision $revision, not the requested revision $REQUESTED_REVISION" + exit 1 + fi tag="addons-catalog-r$revision" sha256sum catalog-v1.json > catalog-v1.json.sha256 + # The desktop updater, install.sh and hosted-client deploys all read + # the repository's Latest release, which must stay a desktop v* release. + latest=$(gh api "repos/$GH_REPO/releases/latest" --jq .tag_name) + if [[ ! $latest =~ ^v[0-9] ]]; then + echo "::error::The Latest release is $latest, not a desktop release; refusing to publish" + exit 1 + fi # Existing tags are never overwritten, even on workflow reruns. if gh release view "$tag" >/dev/null 2>&1; then mkdir accepted gh release download "$tag" --pattern catalog-v1.json --dir accepted cmp catalog-v1.json accepted/catalog-v1.json else - gh release create "$tag" catalog-v1.json catalog-v1.json.sha256 --target "$GITHUB_SHA" --title "Add-on catalog revision $revision" --notes "Reviewed feature add-on catalog. Package releases remain independent of the desktop app." + gh release create "$tag" catalog-v1.json catalog-v1.json.sha256 --target "$GITHUB_SHA" --latest=false --title "Add-on catalog revision $revision" --notes "Reviewed feature add-on catalog. Package releases remain independent of the desktop app." + fi + after=$(gh api "repos/$GH_REPO/releases/latest" --jq .tag_name) + # A desktop release published meanwhile rightly takes Latest; restore + # only when Latest moved to the catalog or another non-desktop release. + if [[ ! $after =~ ^v[0-9] ]]; then + gh release edit "$latest" --latest + echo "::error::Publishing $tag changed the Latest release to $after; restored $latest" + exit 1 fi diff --git a/.github/workflows/addon-native-ui.yml b/.github/workflows/addon-native-ui.yml new file mode 100644 index 00000000..2541ff2d --- /dev/null +++ b/.github/workflows/addon-native-ui.yml @@ -0,0 +1,117 @@ +name: Retry native add-on UI acceptance +on: + pull_request: + paths: + - scripts/addons/native-ui.mjs + - scripts/addons/build-ci-fixtures.sh + - scripts/addons/fixtures/context-races/** + - scripts/addons/fixtures/activation-race/** + - scripts/addons/reversion-fixture.mjs + - scripts/addons/install-webdriver.ps1 + - scripts/addons/windows-*.ps1 + - .github/workflows/addon-native-ui.yml + workflow_dispatch: + inputs: + build_run_id: + description: Packaged add-on workflow run containing the installer input artifacts + required: true + type: string + platform: + description: Run either ready installer artifact, or both platforms + required: true + type: choice + default: both + options: [both, Linux, Windows] +permissions: + contents: read + actions: read +jobs: + harness-syntax: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - run: node --check scripts/addons/native-ui.mjs + - name: Verify refusal outside disposable CI + run: | + node --input-type=module <<'JS' + import assert from 'node:assert/strict'; + import { spawnSync } from 'node:child_process'; + const result = spawnSync(process.execPath, ['scripts/addons/native-ui.mjs'], { + env: { ...process.env, GITHUB_ACTIONS: 'false' }, encoding: 'utf8' + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Requires disposable GitHub CI/); + JS + native-ui: + if: github.event_name == 'workflow_dispatch' + strategy: + fail-fast: false + matrix: + os: ${{ inputs.platform == 'Linux' && fromJSON('["ubuntu-22.04"]') || inputs.platform == 'Windows' && fromJSON('["windows-latest"]') || fromJSON('["ubuntu-22.04", "windows-latest"]') }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - name: Validate installer source run + shell: bash + env: + GH_TOKEN: ${{ github.token }} + BUILD_RUN_ID: ${{ inputs.build_run_id }} + run: | + node --input-type=module <<'JS' + import assert from 'node:assert/strict'; + const id = process.env.BUILD_RUN_ID; + assert.match(id, /^\d+$/); + const response = await fetch(`https://api.github.com/repos/${process.env.GITHUB_REPOSITORY}/actions/runs/${id}`, { + headers: { Authorization: `Bearer ${process.env.GH_TOKEN}`, Accept: 'application/vnd.github+json' } + }); + assert.equal(response.status, 200); + const run = await response.json(); + assert.equal(run.repository.full_name, process.env.GITHUB_REPOSITORY); + assert.equal(run.head_repository.full_name, process.env.GITHUB_REPOSITORY); + assert.equal(run.path, '.github/workflows/addon-packaged.yml'); + console.log(`Testing installers from ${run.head_sha}; harness ${process.env.GITHUB_SHA}`); + JS + - uses: actions/download-artifact@v4 + with: + name: addon-native-ui-inputs-${{ runner.os }} + run-id: ${{ inputs.build_run_id }} + github-token: ${{ github.token }} + - name: Build CI race fixtures from the harness revision + shell: bash + run: bash scripts/addons/build-ci-fixtures.sh + - name: Native Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-0 libayatana-appindicator3-1 webkit2gtk-driver xvfb dbus-x11 imagemagick + - uses: dtolnay/rust-toolchain@stable + - name: Install external WebDriver + run: cargo install tauri-driver --version 2.0.6 --locked + - name: Verify native Windows dependency toolchain + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/addons/windows-native-perl.ps1 + - name: Install matching Microsoft WebView2 driver + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/addons/install-webdriver.ps1 + - name: Native desktop UI acceptance (Linux) + if: runner.os == 'Linux' + run: dbus-run-session -- xvfb-run -a node scripts/addons/native-ui.mjs + - name: Native desktop UI acceptance (Windows) + if: runner.os == 'Windows' + run: node scripts/addons/native-ui.mjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: addon-native-ui-evidence-${{ runner.os }} + path: addon-native-ui-evidence/ + if-no-files-found: warn diff --git a/.github/workflows/addon-packaged.yml b/.github/workflows/addon-packaged.yml new file mode 100644 index 00000000..d4f15676 --- /dev/null +++ b/.github/workflows/addon-packaged.yml @@ -0,0 +1,263 @@ +name: Packaged add-on runtime +on: + workflow_dispatch: + pull_request: + paths: + [ + "scripts/addons/**", + "packages/plugin-*/**", + "examples/addons/**", + "src/components/addons/**", + "src/components/settings/addons-settings*", + "src/components/settings/addon-*", + "src/components/chat/ComposerCommandMenu.tsx", + "src/components/terminal/pty-input.ts", + "src/components/ui/dialog.tsx", + "src/components/ui/switch.tsx", + "src/components/ui/badge.tsx", + "src/components/chat/Composer.tsx", + "src/components/chat/ComposerFooter.tsx", + "src/components/chat/AgentChatPane.tsx", + "src/components/chat/DraftChatSurface.tsx", + "src/components/layout/right-panel.tsx", + "src/components/layout/right-panel/pane-registry.ts", + "src/components/layout/right-panel/pane-picker.tsx", + "src/components/layout/right-panel/pane-tab-strip.tsx", + "src/components/overlays/command-palette.tsx", + "src/components/settings/settings-view.tsx", + "src/lib/addons/**", + "src/lib/settings-sections.ts", + "src/stores/addons-store.ts", + "src/stores/ui-store.ts", + "src-tauri/src/addons/**", + "src-tauri/src/commands/addons.rs", + "src-tauri/src/web_remote/dispatch.rs", + "src-tauri/addon-protocol/**", + "src-tauri/addon-host/**", + "src-tauri/Cargo.lock", + "src-tauri/tauri.conf.json", + "scripts/build-addon-host.sh", + ".github/workflows/addon-packaged.yml", + ".github/workflows/release.yml", + ] +permissions: + contents: read +concurrency: + group: addon-packaged-${{ github.ref }} + # Preserve installer evidence and populated caches from an in-flight build. + # Harness-only iterations can reuse its artifacts through addon-native-ui.yml. + cancel-in-progress: false +jobs: + bundle: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Linux system dependencies + if: matrix.os == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev \ + build-essential \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + libfuse2 \ + file libarchive-tools webkit2gtk-driver xvfb dbus-x11 imagemagick + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install standalone add-on host Windows GNU target + if: runner.os == 'Windows' + run: rustup target add x86_64-pc-windows-gnu + + - name: Install add-on host C compiler + if: runner.os == 'Windows' + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + install: mingw-w64-x86_64-gcc + path-type: inherit + + - name: Select add-on host compiler + if: runner.os == 'Windows' + shell: pwsh + run: '"C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append' + + - name: Select native Perl for MSVC dependencies + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/addons/windows-native-perl.ps1 + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + cache-on-failure: true + key: addon-packaged-${{ matrix.os }} + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - name: Install frontend dependencies + run: npm ci + + - name: Build and verify independent add-on packages and release host + shell: bash + run: | + bash scripts/addons/build-examples.sh + bash scripts/build-addon-host.sh --profile release + if [ "$RUNNER_OS" = Windows ]; then + addon_host=src-tauri/binaries/codemux-addon-host-windows-x64.exe + else + addon_host=src-tauri/binaries/codemux-addon-host-linux-x64 + fi + node scripts/addons/sdk-native.mjs "$addon_host" + + - name: Verify AppImage provenance checks + if: runner.os == 'Linux' + run: node --test scripts/addons/elf-provenance-check.mjs + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.2.21" + + - name: Stage claude-agent sidecar binary + shell: bash + run: | + bash scripts/build-claude-sidecar.sh --strict + TARGET="${CARGO_BUILD_TARGET:-$(rustc -vV | grep host | cut -d' ' -f2)}" + case "$TARGET" in + *windows*) DEST="src-tauri/binaries/codemux-claude-sidecar-$TARGET.exe" ;; + *) DEST="src-tauri/binaries/codemux-claude-sidecar-$TARGET" ;; + esac + if [ ! -s "$DEST" ]; then + echo "::error::build-claude-sidecar.sh did not produce a non-empty binary at $DEST" + echo "::error::Target: $TARGET" + ls -la src-tauri/binaries/ || true + exit 1 + fi + + - name: Stage agent-browser sidecar binary + shell: bash + run: | + bash scripts/copy-agent-browser.sh + TARGET="${CARGO_BUILD_TARGET:-$(rustc -vV | grep host | cut -d' ' -f2)}" + case "$TARGET" in + *windows*) DEST="src-tauri/binaries/agent-browser-$TARGET.exe" ;; + *) DEST="src-tauri/binaries/agent-browser-$TARGET" ;; + esac + if [ ! -s "$DEST" ]; then + echo "::error::copy-agent-browser.sh did not produce a non-empty binary at $DEST" + echo "::error::Target: $TARGET" + ls -la src-tauri/binaries/ || true + ls -la node_modules/agent-browser/bin/ || true + exit 1 + fi + + - name: Build codemux-remote binary (release profile) + shell: bash + run: | + bash scripts/build-codemux-remote.sh --profile release + + TARGET="${CARGO_BUILD_TARGET:-$(rustc -vV | grep host | cut -d' ' -f2)}" + case "$TARGET" in + *windows*) DEST="src-tauri/binaries/codemux-remote-$TARGET.exe" ;; + *) DEST="src-tauri/binaries/codemux-remote-$TARGET" ;; + esac + STAMP="src-tauri/binaries/.codemux-remote-$TARGET.profile" + + if [ ! -s "$DEST" ]; then + echo "::error::codemux-remote build did not produce a non-empty binary at $DEST" + exit 1 + fi + + if [ ! -f "$STAMP" ]; then + echo "::error::missing provenance stamp $STAMP — build-codemux-remote.sh did not complete" + exit 1 + fi + cat "$STAMP" + if ! grep -qx 'profile=release' "$STAMP"; then + echo "::error::codemux-remote was staged from a non-release profile — refusing to publish" + exit 1 + fi + ls -la src-tauri/binaries/codemux-remote-* + + - name: Configure git identity + shell: bash + run: | + git config --global user.email "release@codemux.dev" + git config --global user.name "Codemux Release" + + - name: Pre-cache stable linuxdeploy + plugin-appimage + if: matrix.os == 'ubuntu-22.04' + run: | + mkdir -p ~/.cache/tauri + curl -fsSL -o ~/.cache/tauri/linuxdeploy-plugin-appimage.AppImage \ + https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/1-alpha-20250213-1/linuxdeploy-plugin-appimage-x86_64.AppImage + chmod +x ~/.cache/tauri/linuxdeploy-plugin-appimage.AppImage + ls -la ~/.cache/tauri/ + + # The same build-then-verify command that the release workflow gives + # tauri-action, so every installer format it publishes is gated here first. + - name: Build installers and verify bundled host and runtime deadlines + shell: bash + env: + NO_STRIP: "true" + run: | + if [ "$RUNNER_OS" = Windows ]; then formats=nsis; else formats=deb,rpm,appimage; fi + node scripts/addons/verified-tauri-build.mjs build --verbose --bundles "$formats" --config '{"bundle":{"createUpdaterArtifacts":false}}' + - name: Record installer build revision + run: node -e "require('node:fs').writeFileSync('addon-native-ui-build.json', JSON.stringify({commit:process.env.GITHUB_SHA,run:process.env.GITHUB_RUN_ID,platform:process.platform}))" + - name: Preserve built acceptance inputs for harness-only retries + uses: actions/upload-artifact@v4 + with: + name: addon-native-ui-inputs-${{ runner.os }} + retention-days: 7 + path: | + addon-native-ui-build.json + src-tauri/target/release/bundle/deb/*.deb + src-tauri/target/release/bundle/nsis/*.exe + examples/addons/project-brief/*.cmxaddon + examples/addons/issue-companion/*.cmxaddon + scripts/addons/fixtures/fault-isolation/*.cmxaddon + scripts/addons/fixtures/context-races/*.cmxaddon + scripts/addons/fixtures/activation-race/*.cmxaddon + - name: Install external WebDriver + run: cargo install tauri-driver --version 2.0.6 --locked + - name: Install matching Microsoft WebView2 driver + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/addons/install-webdriver.ps1 + - name: Native desktop UI acceptance (Linux) + if: runner.os == 'Linux' + run: dbus-run-session -- xvfb-run -a node scripts/addons/native-ui.mjs + - name: Native desktop UI acceptance (Windows) + if: runner.os == 'Windows' + run: node scripts/addons/native-ui.mjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: addon-native-ui-evidence-${{ runner.os }} + path: addon-native-ui-evidence/ + if-no-files-found: warn + - uses: actions/upload-artifact@v4 + if: always() + with: + name: addon-packaged-evidence-${{ matrix.os }} + path: addon-packaged-evidence.json + if-no-files-found: warn diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c3820b7..fd41b2eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ on: pull_request: branches: - main + - 'codex/addons-*' # Cancel in-flight runs for the same branch when a new commit is pushed. concurrency: @@ -62,6 +63,12 @@ jobs: - name: Install npm dependencies run: npm ci + - name: Release policy guard + # Catalog and add-on releases must not take the desktop's Latest + # release or v* tags; desktop releases upload only gated bundles. + if: matrix.os == 'ubuntu-latest' + run: node --test scripts/addons/release-policy-check.mjs + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -444,3 +451,25 @@ jobs: if [ "$RUNNER_OS" = Windows ]; then suffix=windows-x64.exe; fi export CODEMUX_TEST_ADDON_HOST="$PWD/src-tauri/binaries/codemux-addon-host-$suffix" cargo test -j 2 --locked --manifest-path src-tauri/Cargo.toml --lib addons::manager::tests::native_ -- --ignored --test-threads=2 + + - name: Native full-filesystem add-on recovery + if: runner.os == 'Linux' + run: bash scripts/addons/full-filesystem.sh + + - name: Install Linux Secret Service + if: runner.os == 'Linux' + run: sudo apt-get install -y --no-install-recommends gnome-keyring + + - name: Linux native credential backend + if: runner.os == 'Linux' + # A private session bus with a throwaway, unlocked login collection + # proves a real Secret Service save/read/delete. The installed-app + # harness covers only the missing-service fallback on Linux. + shell: dbus-run-session -- bash --noprofile --norc -eo pipefail {0} + run: | + printf '%s' ci-only-keyring-password | gnome-keyring-daemon --unlock --components=secrets --daemonize > /dev/null + cargo test -j 2 --locked --manifest-path src-tauri/Cargo.toml --lib addons::credentials::tests::native_os_backend_roundtrip_and_deletion -- --ignored --test-threads=2 + + - name: Windows native credential backend + if: runner.os == 'Windows' + run: cargo test -j 2 --locked --manifest-path src-tauri/Cargo.toml --lib addons::credentials::tests::native_os_backend_roundtrip_and_deletion -- --ignored --test-threads=2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcfb7a45..99a1e78c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,10 +40,12 @@ jobs: librsvg2-dev \ patchelf \ libfuse2 \ - file + file \ + libarchive-tools # libfuse2 is required by tauri build's AppImage packager; kept here # (unlike ci.yml which excludes it) because this workflow produces - # actual bundles, not just type-checks. + # actual bundles, not just type-checks. libarchive-tools (bsdtar) + # unpacks the rpm for the add-on runtime gate. - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -68,6 +70,11 @@ jobs: shell: pwsh run: '"C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append' + - name: Select native Perl for MSVC dependencies + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/addons/windows-native-perl.ps1 + - name: Rust cache uses: Swatinem/rust-cache@v2 with: @@ -311,6 +318,17 @@ jobs: chmod +x ~/.cache/tauri/linuxdeploy-plugin-appimage.AppImage ls -la ~/.cache/tauri/ + # ── Add-on runtime gate ────────────────────────────────────────── + # + # Both Build and release steps set `tauriScript` to + # scripts/addons/verified-tauri-build.mjs. tauri-action runs it in place + # of `tauri build` and uploads what it leaves in the bundle directory. + # The script builds once, then runs packaged-smoke.mjs against those + # exact deb/rpm/AppImage or NSIS files: bundled host provenance, SDK + # callbacks and hostile-runtime deadlines. A failed check fails the step + # before tauri-action creates the release or uploads any asset, so the + # published installers are the verified bytes, not a separate rebuild. + - name: Build and release (Linux) if: matrix.os == 'ubuntu-22.04' uses: tauri-apps/tauri-action@action-v0.6.2 @@ -330,6 +348,9 @@ jobs: releaseDraft: false prerelease: false includeUpdaterJson: true + # Builds, then verifies the exact bundles before any upload. See the + # add-on runtime gate note above. + tauriScript: node scripts/addons/verified-tauri-build.mjs # Diagnostic: tauri build's bundler captures linuxdeploy's stderr # but only prints it when verbose. Without this flag, linuxdeploy # failures appear as the opaque "failed to run linuxdeploy" with @@ -359,6 +380,9 @@ jobs: # Build and release steps for the merge mechanics and race # analysis. includeUpdaterJson: true + # Builds, then verifies the exact bundle before any upload. See the + # add-on runtime gate note above. + tauriScript: node scripts/addons/verified-tauri-build.mjs # Restrict to the NSIS bundle. `bundle.targets = "all"` in # tauri.conf.json would otherwise build both NSIS and MSI on # Windows, and MSI requires the WiX Toolset which is NOT @@ -379,6 +403,14 @@ jobs: # Ed25519 is what the Tauri auto-updater checks before applying # an update. + - name: Preserve add-on runtime gate evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-addon-runtime-evidence-${{ matrix.os }} + path: addon-packaged-evidence.json + if-no-files-found: warn + hosted-web: name: hosted web release asset # Wait for both desktop matrix legs so this job never races tauri-action diff --git a/.gitignore b/.gitignore index bdaf731f..c6e12660 100644 --- a/.gitignore +++ b/.gitignore @@ -75,5 +75,8 @@ docs/addons/research-probe/probe-ui.bundle.js # Independent add-on build products examples/addons/*/plugin.js +scripts/addons/fixtures/*/plugin.js +addon-native-ui-evidence/ +addon-native-ui-build.json src-tauri/addon-catalog/target/ diff --git a/catalog/addons/README.md b/catalog/addons/README.md index 9531554c..978e347f 100644 --- a/catalog/addons/README.md +++ b/catalog/addons/README.md @@ -23,9 +23,24 @@ Run: ```sh cargo run -j 2 --locked --manifest-path src-tauri/addon-catalog/Cargo.toml -- generate catalog/addons/catalog-v1.json catalog/addons/entries -cargo run -j 2 --locked --manifest-path src-tauri/addon-catalog/Cargo.toml -- online catalog/addons/catalog-v1.json +cargo run -j 2 --locked --manifest-path src-tauri/addon-catalog/Cargo.toml -- online catalog/addons/catalog-v1.json [previous-catalog-v1.json] ``` +`online` downloads and hashes every unblocked release. With a previous catalog it +resolves tag provenance only for releases added since then, because accepted +releases are immutable. Set `GITHUB_TOKEN` (or `GH_TOKEN`) to authenticate those +GitHub API requests and avoid the anonymous rate limit; the token is sent only to +`api.github.com`, never with release downloads. + +Every shipped v1 desktop parses `catalog-v1.json` strictly and rejects the whole +file, including its blocklist, if it contains an unknown field, platform, +permission, HTTP method, credential type, or tier. `catalog-v1.json` must +therefore stay readable by v1 desktops. Publish releases that need new fields or +values in a new `catalog-vN.json` that newer desktops fetch, and keep publishing +`catalog-v1.json` with its v1-compatible releases and the complete blocklist. The +protocol contract test `catalog_v1_fields_and_values_are_pinned` fails when the +v1 shape changes. + Increment the envelope revision for every change, including revocations. Never reuse a catalog revision or release version with changed bytes. Keep historical release records so ownership and digest continuity can be checked. To revoke a @@ -34,8 +49,11 @@ and an RFC3339 date. Desktop refresh disables matching installations; offline devices cannot learn new revocations until they reconnect. Local imports are also checked against the last accepted blocklist. -After a reviewed merge, CI publishes `catalog-v1.json` and its SHA-256 as assets on -the immutable `addons-catalog-r` release. The website pins that artifact +A reviewed merge is validated but never published by itself. A maintainer then +dispatches the catalog workflow on `main` with that revision, through its approved +`addon-catalog` environment, to publish `catalog-v1.json` and its SHA-256 as assets +on the immutable `addons-catalog-r` release. That release is never marked +Latest, which stays the desktop `v*` release. The website pins that artifact and digest in a separate reviewed change; it never reads a mutable branch during page requests. New desktop installs and updates require a successful online refresh. diff --git a/catalog/addons/schema/catalog-v1.json b/catalog/addons/schema/catalog-v1.json index 80a289be..3d9722d6 100644 --- a/catalog/addons/schema/catalog-v1.json +++ b/catalog/addons/schema/catalog-v1.json @@ -105,13 +105,18 @@ ], "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "origin": { - "type": "string" + "type": "string", + "pattern": "^https://(?!.*\\.(?:[0-9]+|0x[0-9a-f]*)$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" }, "type": { "$ref": "#/definitions/CredentialType" @@ -143,10 +148,13 @@ "type": "array", "items": { "$ref": "#/definitions/HttpMethod" - } + }, + "minItems": 1, + "uniqueItems": true }, "origin": { - "type": "string" + "type": "string", + "pattern": "^https://(?!.*\\.(?:[0-9]+|0x[0-9a-f]*)$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" } }, "additionalProperties": false diff --git a/docs/addons/ACCEPTANCE.md b/docs/addons/ACCEPTANCE.md new file mode 100644 index 00000000..e70a28ba --- /dev/null +++ b/docs/addons/ACCEPTANCE.md @@ -0,0 +1,58 @@ +# Acceptance evidence map + +This maps chapter 12 of [the requirements](BUILD-SPEC.md) to executable checks. +Recorded revisions, results and the remaining publication steps are in +[the ledger](IMPLEMENTATION.md). Installed-app evidence below comes from the +stock installers built from the final source ([run 35916722628](https://github.com/Zeus-Deus/codemux/actions/runs/35916722628); [Linux](evidence/native-ui-linux-final-81b4e666.json) and [Windows](evidence/native-ui-windows-final-81b4e666.json) results). Browser mocks +and a native broker test that supplies frontend effect results are unit +coverage only; they are never counted as desktop evidence. + +| Requirement | Checks | Installed-app evidence on Linux and Windows | +| --- | --- | --- | +| Core independence | Full desktop CI; Settings tests; pause/resume and registry tests | Zero hosts at clean start and while paused; lazy start; terminal, draft typing and projects keep working; themes; read-only official updater check with and without add-ons; core panes restored after pause and removal; no empty accessory space | +| Functional SDK | Real-host package tests in `manager.rs` (panels, commands, composer actions, composer view, links); `sdk-native.mjs` | Both examples install from their packages; Project Brief's command, panel and composer action; Issue Companion's public HTTPS, accessory, attributed link and draft insertion; setting and private preference survive restart | +| Author independence | Both examples and all CI fixtures built only with the packed SDK/CLI; starter built outside the checkout (`author.test.mjs`) | The installed examples and fixtures are those packed builds | +| Runtime failure | `addon-host/tests` (CPU, timers, stack, heap, quotas, forged frames); `protocol.rs`; manager fault isolation and unexpected exit | Throw, loop, endless promises, recursion and allocation each quarantine only the fixture while terminal, draft and Project Brief keep working; packaged deadlines for deb, rpm, AppImage and NSIS | +| UI hostility | `addon-protocol/tests/ui.rs`, manager UI limits (batch rate, queue, views, callbacks per plugin, malformed IDs), renderer and view tests | 500-row virtualized list with frame budget and ARIA positions; stale events stay inline | +| Authority | `permissions.rs`; broker tests for forged context, wrong generation, expired or reused interaction, undeclared method and denied capability | Paired remote client cannot invoke or subscribe to add-ons | +| Context races | Real-host delayed-effect transitions, Git child reaping, HTTP cancellation, uninstall during activation; adapter and platform tests | Typing during a delayed append keeps user text; project switch, thread close and composer replacement reject with `CONTEXT_STALE`; disable cancels; removal during activation leaves no host and no insertion | +| Package validation | `package.rs` raw archive fixtures, expansion limits, digest and identity; shared manifest case corpus for desktop and CLI | Native import, review and source-replacement confirmation | +| Network | `http.rs`, `http_native_tests.rs`: real TLS, pinned DNS, private and mapped addresses, redirects, header and body limits, timeout, cancellation, public HTTPS | Production HTTPS request, rendering and draft insertion | +| Secrets | `credentials.rs`: OS backends (Windows in CI, Linux Secret Service in CI), locked store, session-only fallback, isolation, cleanup tombstones, clearing | Credential states in Settings, explicit session-only fallback on Linux, OS save and delete on Windows, redaction of driver logs, removal | +| Persistence | Storage, retained-source, registry corruption and reset, unclean-session tests | Restart keeps installations, grants, settings and data; corrupt registry keeps core startup and offers reset; reset restores management | +| Updates | Lifecycle tests: eight interruption points, SQLite full, candidate failure, rollback with matching data, blocklist disable, catalog cache and fresh-fetch rules; six real ENOSPC checkpoints on Linux | Active-host update, expanded-access review and cancellation, rollback with matching private data | +| UI and access | Renderer, view, composer and Settings tests (keyboard, focus, dialogs, errors inside dialogs, screen-reader labels) | Keyboard dialog focus, light and dark themes, chat GUI off, small window, core pane restoration | +| Remote boundary | `addon_*` rejection before rewriting and event forwarding; remote client and remote workspace tests | Paired loopback HTTP/WebSocket client with active add-ons | +| Website/catalog | Protocol and catalog-crate validation (duplicates, ownership, rollback, blocked, schema); website tests and build | Publication deferred: the first catalog artifact and website-to-desktop digest check follow a maintainer release | +| Packaged platforms | Exact host provenance, clean-environment SDK callbacks and hostile deadlines for every built installer | The same installers run the full installed-app suite above | + +## Running the installed-app harness + +`addon-packaged.yml` builds the normal release installers and independent +packages, verifies their payloads, then runs external WebDriver against the +installed desktop (`tauri-driver` on Linux; Microsoft WebView2 attach on Windows). It does not enable a production test server or change the +application binary. The harness refuses local and self-hosted runners. + +The account API is a synthetic loopback fixture. The single-use native file +chooser seam returns the chosen archive path; all package validation, review, +permissions, installation, native host execution and effects remain real. +Issue Companion uses unauthenticated read-only public GitHub HTTPS through the +production broker. A rate limit is a failed gate, not a substituted success. + +`addon-native-ui.yml` can retry the harness against the same run's saved +installer inputs, choosing Linux, Windows or both. Its ordinary PR job checks +only syntax and the refusal guard; that green check is **not native UI evidence**. +Every execution records the installer build revision, harness revision and +installer SHA-256 separately, plus per-step screenshots and text. A harness-only +retry never implies a newly compiled app was tested. Failure artifacts must be +read before changing either the harness or the implementation. + +The race fixtures (`scripts/addons/fixtures/context-races` and +`activation-race`) are ordinary packages built with the packed SDK and CLI by +`build-examples.sh`, or by `build-ci-fixtures.sh` for harness-only retries. +They report outcomes in their own panel, so the notification limit cannot hide +a result. On Linux the harness puts a logging `xdg-open` first on the app's +`PATH` to prove an attributed link reached the system opener; on Windows only +the attribution toast is asserted. Clicks the driver did not dispatch (a stale, +covered or scrolled-away element) are retried; every other failure is a failed +gate. diff --git a/docs/addons/IMPLEMENTATION.md b/docs/addons/IMPLEMENTATION.md index db83dced..1a2384a2 100644 --- a/docs/addons/IMPLEMENTATION.md +++ b/docs/addons/IMPLEMENTATION.md @@ -1,58 +1,423 @@ # Plugin platform implementation ledger Requirements: [engineering specification](BUILD-SPEC.md), revision 2, all 14 chapters. -Desktop baseline: `797a834c`. Website baseline: `27cfa19` on the existing +Desktop integration baseline: `09966161` after the isolated ordered rebase +(original baseline `797a834c`). Website baseline: `27cfa19` on the existing `feat/site-revamp` branch (includes the latest main). Both use isolated worktrees; pre-existing Hermes work and website changes are preserved. -**Unreleased implementation. The acceptance matrix is not complete.** Passing -standalone host tests, browser mocks, or the research probe is not release approval. +**Implemented and verified; unpublished by maintainer decision.** Every chapter +12 row now has native evidence on Linux and Windows, including installed-app +runs of the final source (see [Completion pass](#completion-pass-2026-09-23)). +Nothing is published: SDK/CLI npm packages, example package releases, the first +catalog artifact and the website pin wait for an explicit maintainer release, +as described in [RELEASING.md](RELEASING.md). Passing standalone host tests, +browser mocks, or the research probe is not release approval on its own. The +[acceptance evidence map](ACCEPTANCE.md) connects each matrix row to its checks. ## Ordered delivery | PR | Scope | State | | --- | --- | --- | | 1 | Contracts, independent QuickJS host, public SDK and CLI | Draft [#390](https://github.com/Zeus-Deus/codemux/pull/390); Linux and Windows GNU CI passed | -| 2 | Native manager, scoped broker, private persistence, installer foundations | Implemented; focused native verification ongoing | -| 3 | Trusted UI, palette, panel deck, controlled composer | Implemented; focused frontend checks pass | -| 4 | Settings, lifecycle/recovery, review, developer watch | Implemented; focused lifecycle verification ongoing | -| 5 | Independent examples and native resource packaging | Packages built and installed in native tests; packaged release gates pending | -| 6 | Reviewed catalog schema, validator, immutable artifact publication | Implemented with an empty revision-1 seed; no example releases published | -| 7 | Website pinned catalog, search/detail/handoff, author documentation | Implemented in separate website worktree; build and affected tests pass | -| 8 | Release hardening and complete acceptance evidence | In progress; see unresolved gates below | - -Later PRs stack on their specified prerequisites. The native catalog reader and -transactional installer are included in the manager foundation because its grant, +| 2 | Native manager, scoped broker, private persistence, installer foundations | Draft [#392](https://github.com/Zeus-Deus/codemux/pull/392) | +| 3 | Trusted UI, palette, panel deck, controlled composer | Draft [#393](https://github.com/Zeus-Deus/codemux/pull/393) | +| 4 | Settings, lifecycle/recovery, review, developer watch | Draft [#394](https://github.com/Zeus-Deus/codemux/pull/394) | +| 5 | Independent examples and native resource packaging | Draft [#395](https://github.com/Zeus-Deus/codemux/pull/395); packaged gates pass on the final source in PR 8 | +| 6 | Reviewed catalog schema, validator, immutable artifact publication | Draft [#396](https://github.com/Zeus-Deus/codemux/pull/396); empty seed, publication gated behind a maintainer dispatch | +| 7 | Website pinned catalog, search/detail/handoff, author documentation | Draft [website #7](https://github.com/Zeus-Deus/codemux-sitev2/pull/7); 50 tests and production build pass | +| 8 | Release hardening, completion fixes and complete acceptance evidence | Draft [#397](https://github.com/Zeus-Deus/codemux/pull/397); all gates pass, publication deferred | + +Later PRs stack on their specified prerequisites. Full desktop CI was enabled +for the stack in PR 8; earlier draft heads do not each have full desktop CI +evidence and must be revalidated as they are prepared for merging. The native +catalog reader and transactional installer are included in the manager foundation because its grant, activation, removal and recovery paths must share one authority boundary. Catalog publication and Settings remain separate deliverables. +## Completion pass (2026-09-23) + +The stack stopped at `b5377a1d` with green CI but open gates. The completion +pass audited all 14 chapters against that code, fixed what it found, and +re-ran every acceptance gate on the final source. + +**Audit.** Eleven area audits (host/protocol, UI validation, SDK/CLI, broker, +UI integration, packages, HTTP/credentials, lifecycle, Settings/remote, +catalog/website, examples/packaging/CI) reported 113 gaps with file-level +evidence. An independent skeptic per area refuted 6, leaving 107 (6 high, 52 +medium, 48 low, 1 uncertain). The fixes were made in ownership-scoped packages, +each reviewed independently, then merged; a final whole-diff review with seven +lenses and two skeptics per finding confirmed 17 further defects, all fixed. + +**High-severity gaps and their fixes** + +- The host's timer scheduler ran plugin-replaceable builtins outside the + accounted CPU window, and module evaluation got its own 1 s. Timers, the + 128-timer cap, the 100 ms minimum and wake computation now live in Rust; + evaluation and activation share one 1 s budget; bootstrap intrinsics are + captured at load. +- A failed source-replacement install left a journal that the next launch + replayed, resurrecting removed add-ons or reverting later updates. Failure + restores by plugin ID; recovery applies a journal only to its exact tuple. +- Merging the catalog would have published `addons-catalog-r1` as the + repository's Latest release, breaking the desktop updater, `install.sh` and + the hosted-client deploy. Publication is a maintainer dispatch behind the + `addon-catalog` environment, always `--latest=false`, with Latest verified + before and after; package releases use non-`v*` tags. +- The desktop never rechecked the catalog, so revocations reached only users + who opened Browse. A background task (after the startup checkpoint, only with + installed add-ons, at most once per 24 h) applies blocks without activating code. +- Composer actions and the composer accessory had no test at any layer. They + now have real-host, Vitest and installed-app coverage. +- The delayed-draft race harness exceeded the three-per-minute notification + limit, so the plugin was quarantined before any race was observed. Race + fixtures are now ordinary SDK packages reporting outcomes in their own panel. + +**Other behavior changes (selected).** Stable host errors from handlers and UI +callbacks no longer quarantine a plugin; plain throws still do. First quota +excesses get `RESOURCE_LIMIT` replies and the SDK paces itself; only repeated +violations stop a generation. `panels.open` needs a live interaction. Workspace +storage is keyed by a hash of the authorized root, not a reusable counter. +Credential state is visible in Settings and can be cleared. Reviews show the +installed release, added and removed access, and use **Install**, **Install & +enable** or **Update to **; updates keep current enablement. Rows show +update availability, publisher/tier, compatibility and bounded diagnostics +(counts only, never log text). A corrupt registry offers **Reset add-on +registry**. Pause, registry errors and diagnostic launches no longer delete +saved add-on panes. Disable and removal revoke broker access before waiting for +the plugin's lock. + +**Defects found only by installed-app testing** + +- Removal requested during a lazy activation let the command queued behind the + activation append to the draft on Windows. Disable and removal now withdraw + access immediately; the real-host uninstall test checks that window. +- Terminal input sent as independent IPC calls arrived reordered on a loaded + Windows runner (`echo CODEMUX_CORE_4` became `echo CODEMUXE__4COR`). Desktop + input now keeps one write in flight per session and coalesces the rest; + remote clients, whose transport is already ordered, send at once. This core + fix affects every terminal user, with or without add-ons. +- Ubuntu 22.04's `rpm2cpio` exits 1 on Tauri's rpm after writing it completely + (reproduced in an `ubuntu:22.04` container against the published 0.22.8 rpm); + the rpm gate uses `bsdtar`. +- Vitest discovered the author CLI's `node:test` suites; they are excluded. + +**Final evidence** + +- Local, Ryzen 5 7600 / Linux: 86 focused, 34 real-host and 2 remote-boundary + native add-on tests; 17 host, 16 catalog and 22 protocol crate tests; 88 CLI + tests; 11 SDK native callback checks; TypeScript and 50 affected frontend test + files (964 tests); website 50 tests and production build. +- Hosted CI on the final source: [CI](https://github.com/Zeus-Deus/codemux/actions/runs/35916722612) (Linux and Windows frontend and Rust, including the Linux Secret Service and Windows credential round trips), [contracts and native host](https://github.com/Zeus-Deus/codemux/actions/runs/35916722583), [catalog validation](https://github.com/Zeus-Deus/codemux/actions/runs/35916722616) and [author distributions](https://github.com/Zeus-Deus/codemux/actions/runs/35916722580) all pass on `81b4e666`; later commits change documentation only. +- Installed desktops built from the final source: [Packaged add-on runtime](https://github.com/Zeus-Deus/codemux/actions/runs/35916722628) builds the installers from `81b4e666`, verifies every bundle (deb and rpm exact host SHA-256, AppImage ELF provenance, NSIS exact SHA-256; hostile-workload deadlines at most 1,019 ms), then passes all 42 installed-app checks on [Linux](evidence/native-ui-linux-final-81b4e666.json) and [Windows](evidence/native-ui-windows-final-81b4e666.json) on 4-vCPU EPYC runners. Highlights: both examples from their packages, including the composer action, the accessory and an attributed link (Linux logs the exact URL handed to `xdg-open`; Windows opened the browser); project switch, thread close and composer replacement reject late appends with `CONTEXT_STALE`; removal during activation finishes in 601 ms and 742 ms with no host or insertion left; five hostile workloads each contained while terminal and draft keep working; 500-row list p95 frame gap 17 ms and 15.7 ms; restart, update review, rollback with matching data, credentials, paired remote denial, chat GUI off, corrupt registry and registry reset. [Packaged Linux](evidence/packaged-linux-81b4e666.json) and [packaged Windows](evidence/packaged-windows-81b4e666.json) evidence record the payload checks. +- Before any fix, the `b5377a1d` installers already passed the ported race + suite on [Linux](evidence/native-ui-linux-races-b5377a1d.json) and + [Windows](evidence/native-ui-windows-races-b5377a1d.json). The intermediate + [Linux installer `8eb46caa`](evidence/native-ui-linux-8eb46caa.json) passed + 37 checks, including removal during activation (696 ms against a 700 ms + activation) with no host or draft insertion left behind. + ## Verified evidence (Linux x86_64 unless stated otherwise) +The entries below record the original implementation's runs on earlier +installers, newest first. They remain valid for those revisions. + +- Rebuilt Windows `312321e3` passes the [entire expanded native GUI flow](https://github.com/Zeus-Deus/codemux/actions/runs/35397531415), + including active-host update, expanded-access review/cancellation and rollback + with matching private data. This repeats the formerly failing old-installer + case using the backpressure-fixed SDK packages. [Native evidence](evidence/native-ui-windows-updates-312321e3.json) + records installer SHA-256 `e6342ccb5010295443ff4a2d262b202013000ffa2cb2f8423059d50d22cad3b9`. + [Windows bundle evidence](evidence/packaged-windows-312321e3.json) records exact + host payload identity and all five hostile deadlines. The native Strawberry + Perl selection is now verified by an actual successful MSVC installer build, + not merely its preflight. The original run's Linux UI step used the earlier + removal-dialog harness and failed; its saved installer subsequently passes the + expanded harness as recorded above. + +- [Full desktop CI at `1e628eec`](https://github.com/Zeus-Deus/codemux/actions/runs/35396377918) + passes all five jobs, including the corrected Windows real-child + activation/removal test and explicit SQLite disposal. The subsequent + credential-recovery UI regression fails before and passes after clearing its + stale error on successful save. All four Settings tests and TypeScript pass; + the actual Settings component was checked at localhost with synthetic IPC, + including visible failure, opt-in session recovery, empty password field and + removed alert. The temporary fixture, tab and server were removed. Final + installer assertions additionally require correct virtual-row ARIA positions + and keyboard access to row 500; those assertions remain pending on final source. + +- [Linux native active-update acceptance](https://github.com/Zeus-Deus/codemux/actions/runs/35397049892) + passes same-access watched-package update while its real panel is active, + expanded-permission review/cancellation, and Settings rollback restoring the + previous private checkbox value after it was changed in the updated release. + Source identity stays fixed; previous/current data generations are checked. + The test uses the public Developer mode and normal review/rollback controls, + with the existing single-use chooser seam. [Exact evidence](evidence/native-ui-linux-updates-312321e3.json) + records all other native gates passing on installer `312321e3` too. + Windows's older `da835efb` installer failed during rollback with a real + `UI update queue overflow`, invalidating its palette option. This installer + and its saved examples predate the SDK backpressure fix; the gate must repeat + with rebuilt Windows inputs. The harness only reacquires driver-rejected + stale elements and does not suppress plugin failures. + +- Native pane restoration, no empty accessory/footer spacing, credential + removal/redaction and paired remote denial now all pass on + [Linux](https://github.com/Zeus-Deus/codemux/actions/runs/35396542696) and + [Windows](https://github.com/Zeus-Deus/codemux/actions/runs/35396547958). + Both preserve the same core pane order and activate a core pane after pause + and removal. The Windows run verifies actual OS credential save and deletion; + Linux verifies missing-service failure followed by explicit session-only + fallback. Warning-only driver logging passes the same secret scan. + [Linux provenance](evidence/native-ui-linux-panes-312321e3.json) and + [Windows provenance](evidence/native-ui-windows-panes-da835efb.json) retain the + exact saved installer and harness revisions. Final-source repeat remains. + +- `prepare-release.mjs` builds the SDK/CLI tarballs and both example packages + from a committed export in a fresh directory outside the app checkout. + The local four-asset preparation at `1e628eec` passes type/build/package checks, + repeat-pack byte identity and every SHA256SUMS entry. No distribution is + published. [Release instructions](RELEASING.md) explain the review/provenance, + normal authorized publication and website catalog pinning sequence. + +- The expanded [Linux installed-app run](https://github.com/Zeus-Deus/codemux/actions/runs/35395837249) + passes on the newer `312321e3` installer, including masked credential entry, + missing Secret Service with explicit session-only fallback, removal/redaction, + and an actual paired loopback HTTP/WebSocket client. Core RPC/events remain + available while every tested `addon_*` command and plugin event is denied. + [Exact evidence](evidence/native-ui-linux-credentials-312321e3.json) also records + the successful public HTTPS, five hostile workloads, rendering budget, + restart, GUI-off and corrupt-registry checks. This is a saved installer, not + the final source. Windows passed OS credential save/delete and remote checks, + but verbose WebDriver logging captured its synthetic SendKeys value; the + harness now uses warning-only diagnostics; its later successful rerun is recorded above. + +- Final-source Windows CI at `ff086c77` exposed a private SQLite file handle + surviving runtime stop while an activation caller retained `Arc`. + Removal committed safely but reported pending file cleanup. Stop now explicitly + closes storage under its operation mutex, and stale broker access fails closed. + The concurrent native activation/removal regression keeps that stale reference + alive and requires warning-free deletion. All six real-host tests pass locally; + Windows verification of this correction is pending. + +- The full expanded saved-installer GUI harness passes on + [Linux](https://github.com/Zeus-Deus/codemux/actions/runs/35393863385) and + [Windows](https://github.com/Zeus-Deus/codemux/actions/runs/35393865596), including + successful HTTPS, all five hostile callbacks, keyboard/themes/updater checks, + restart, GUI-off, removal and corrupt-registry startup. The 500-path rendering + workload mounts 14 actual rows, refreshes real Git five times while typing, + and records frame gaps on the specified runner hardware. Linux p95/max are + 16/28 ms (681 samples); Windows 15.7/15.8 ms (344 samples). Both meet the + recorded shared-runner 100 ms p95 frame-gap ceiling; this is not a universal + frame-rate guarantee or a substitute for the native validation budget. + [Linux evidence](evidence/native-ui-linux-render-91bd6a2b.json) and + [Windows evidence](evidence/native-ui-windows-render-da835efb.json) identify + their exact source/digest and separate harness revision. + +- Accessibility regressions failed before and pass after virtualized list items + expose total size/absolute position and table row counts/indices include the + header. TypeScript and all seven renderer tests pass. A temporary localhost + fixture using the actual trusted renderer and bundled fonts was visually + inspected; keyboard scrolling reached file 500, with correct absolute ARIA + positions and bounded DOM rows. The preview and server were removed afterward. + +- [Windows native keyboard/theme/updater acceptance](https://github.com/Zeus-Deus/codemux/actions/runs/35392870675) + passes the full expanded harness on the saved `da835efb` installer: real dialog + autofocus/Escape/focus restoration, light/dark theme changes, official updater + checks with no plugins and while paused, plus the previously recorded example, + fault, restart, GUI-off, removal and corruption cases. + [Exact evidence](evidence/native-ui-windows-access-da835efb.json) is retained. + The [Linux retry](https://github.com/Zeus-Deus/codemux/actions/runs/35392866707) + passes those added checks, restart/removal/corruption and all five faults on + `91bd6a2b`, but its public GitHub request hit a rate limit. That run is explicitly + [failed](evidence/native-ui-linux-access-91bd6a2b.json); the earlier same-installer + run provides the successful real HTTPS/draft evidence. +- Native Linux removal exposed a spurious credential-cleanup warning when an + optional credential had never been saved. A regression fails before and passes + after using the existing pre-write credential index for OS cleanup and clearing + session-only values separately. Actual saved/retired credentials still retain + retryable deletion tombstones. Five focused credential tests, 41 native add-on + tests and all six real-host integrations pass; nine environment-dependent tests + are reported separately from the focused run. The new real-child activation + race proves removal waits for activation, reaps its child, deletes private state, + and cannot resurrect the package after restart. + +- The [Linux installer run](https://github.com/Zeus-Deus/codemux/actions/runs/35389012944) + at merge `91bd6a2b` passes both example flows and all five hostile SDK callbacks + after the acknowledgement/backpressure correction. The healthy Project Brief, + controlled draft and real terminal remain usable after each fault. Measured + GUI observations are 819–1029 ms on the recorded 4-vCPU Xeon runner. + [Provenance](evidence/native-ui-linux-91bd6a2b.json) identifies the exact installer; + this run predates the restart/corruption extensions and final design-token changes. +- The [Windows classic-interface run](https://github.com/Zeus-Deus/codemux/actions/runs/35391899586) + additionally verifies that Settings and Project Brief's native Git panel work + after restarting with chat GUI disabled, no composer/accessory is present, + Add to draft reports `No chat composer is available`, the core terminal works, + and enabling chat GUI and restarting restores the composer. It also repeats + restart, removal and corrupted-plugin-registry startup checks; + [exact evidence](evidence/native-ui-windows-classic-da835efb.json) is retained. + +- The [Windows installed-app run](https://github.com/Zeus-Deus/codemux/actions/runs/35391050285) + passes all five hostile SDK callback workloads on installer `da835efb`, plus + restart with identical installation/grant/source/data-generation/settings, + removal of all packages, and core startup with a deliberately corrupted plugin + registry. The terminal and controlled draft remain usable with zero plugin + hosts. No provider CLI is installed in this synthetic VM, so typing/persistence + checks do not claim successful provider inference. GUI observations were + 707–794 ms including driver/input overhead on the recorded 4-vCPU EPYC runner. + [Exact provenance and checks](evidence/native-ui-windows-da835efb.json) and + [corrupt-registry core screenshot](evidence/native-corrupt-registry-windows.png) + are retained. This saved installer predates SDK acknowledgement backpressure; + the equivalent Linux five-workload run exposed the queue overflow that fix addresses. +- A real-host broker regression passes all six delayed-effect transitions: + workspace change, composer close/replacement, disable, removal and pause. + Forged-generation claims and late claims/results cannot complete the old draft + operation; contexts and pending effects are disposed. This complements the + frontend's delayed-claim/latest-draft tests, without claiming a GUI race run. + A standalone native-process test also passes hostile shutdown cleanup + (synchronous loop, endless promise jobs and throw), with termination within 2 s. +- After rebasing onto main, Windows packaging selected Git Bash's Perl, whose + missing OpenSSL modules stopped the MSVC dependency build. Packaging and the + ordinary desktop release workflow now explicitly select the runner's native + Strawberry Perl through `OPENSSL_SRC_PERL`; the separate GNU plugin-host + compiler is unchanged. The native Windows preflight passes in the run above; + an actual rebuilt installer remains the verification gate. + +- Stock installed-app GUI flows pass on [Linux](https://github.com/Zeus-Deus/codemux/actions/runs/35388617257) + and [Windows](https://github.com/Zeus-Deus/codemux/actions/runs/35387729205), + using saved installer `603a7352`. Both independently import the example archives, + configure Issue Companion, render actual Git and public GitHub HTTPS results, + append to an existing controlled draft without changing persisted messages, + and verify zero hosts on clean startup, lazy enablement, and pause. + Core Appearance and real terminal commands work while paused and after a + blocking plugin fault; the healthy plugin and composer remain usable. + GUI fault observations including input/driver overhead were 1195.5 ms Linux + and 643.7 ms Windows. [Linux provenance](evidence/native-ui-linux-603a7352.json), + [Windows provenance](evidence/native-ui-windows-603a7352.json), + [native Linux HTTPS/draft screenshot](evidence/native-issue-companion-linux.png). + These installers have one hostile command and predate subsequent fixes. + Neither run establishes the final-source five-workload release gate. +- [Linux CI at `278df894`](https://github.com/Zeus-Deus/codemux/actions/runs/35386904270) + passes actual ENOSPC injection in a disposable 16 MiB tmpfs at package-write, + package-staged, journal-saved, data-snapshotted, registry-switched and activated. + Each failed transaction recovers the previous release/grant/private-state tuple. + The strengthened exact data-generation assertion passes in + [CI at `9dfda479`](https://github.com/Zeus-Deus/codemux/actions/runs/35390059608), + which also passes both full frontend and Rust platform suites. The Windows + same-size Git edit regression now passes with the original index timestamp + preserved; all five jobs in that full CI run are green. +- The rebase preserves current mobile Settings, responsive panel, remote command + policy and composer delivery logic. TypeScript and 166 focused integration/UI + tests pass. Native cargo check and 40 focused tests pass (7 explicit ignored + environment/integration tests are counted separately). All four real-host + manager integrations pass with the freshly packed SDK/example archives. + Current main's design-token contract exposed raw plugin font/radius classes; + these now use the shared tokens in their original UI/Settings milestones. + TypeScript and 22 affected/token-contract tests pass, and the localhost mock + Settings/catalog layout was visually checked with the bundled fonts. +- Real native SDK regression failed before and passes after render backpressure: + two completed callbacks can wait behind an unacknowledged render without + exhausting the host queue. Trusted acknowledgements release one ordered batch; + closing a view discards pending mutations and ignores late acknowledgements. + Native two-batch, 1000-mutation, traffic and memory bounds remain unchanged. + The wire permits `ui.ack` only from host to child for the current generation. + +- Full desktop [CI at `3045f47f`](https://github.com/Zeus-Deus/codemux/actions/runs/35376157828) + passes on Linux and Windows, including 5,610 frontend tests on Linux. The same + revision passes [all installer payload checks](https://github.com/Zeus-Deus/codemux/actions/runs/35376157749): + Linux deb/AppImage and Windows NSIS. Maximum observed fault latencies are + 1007.0 ms and 1014.7 ms respectively. Recorded [Linux](evidence/packaged-linux-3045.json) + and [Windows](evidence/packaged-windows-3045.json) results include source-run + provenance. Commits through `33acb551` subsequently changed the CI harness, + not production app code. A later renderer correction makes Markdown link + controls explicitly non-submitting: its containing-form regression failed + before the fix, then all six renderer tests and TypeScript passed. Installed + desktop UI acceptance of the final revision remains separate and pending. + - PR 1 hosted CI: Linux and Windows GNU host, manifest contracts, SDK callback integration all passed. Schema comparison normalizes Windows CRLF only. -- Protocol: 10 focused tests pass, including atomic malformed UI rejection, - callback disposal, catalog ownership/release history and strict manifests. +- Protocol: 14 focused tests pass, including atomic malformed UI rejection, + callback disposal, mutation/tree limits, catalog ownership/release history and + strict manifests. Raw archive validation covers traversal, absolute/Windows/UNC + paths, alternate data streams, case collisions, links and special entries. - Independent host: 3 real-process tests pass, covering absent ambient authority, parent EOF, stale/oversized IPC and hostile synchronous/asynchronous workloads. - SDK native integration passes: Preact view, batched Remote DOM updates, callback, scoped request, property removal and unmount. -- Desktop manager: 20 focused native tests pass at the rollback checkpoint. - Additional native watcher and cleanup changes are awaiting their final rerun. -- Three explicitly enabled real-child tests passed before that checkpoint: +- Desktop manager: 39 focused native tests pass, including repository filter + isolation, dropped/unresponsive child supervision, real SQLite page-limit + failure, and recovery after interruption at eight durable update transitions. + Git cancellation also owns and reaps the child before releasing its permit, + and metadata snapshots reject nonregular files and symlinks. + The interruption matrix preserves the old tuple before completion and the new + tuple after the atomic completion marker; matching private data is checked. + Uninstall waits for an update at three transaction stages, then removes the + committed candidate and private state; restart does not restore it. +- Four explicitly enabled real-child tests passed after the final changes: broker scope/disposal, Project Brief installer/Git/view/composer request, and - Issue Companion installer/view/recorded HTTP states/explicit draft action. - These tests supply the frontend effect result; they are not stock-app GUI E2E. -- Frontend TypeScript passes. 86 affected tests passed; the subsequent renderer - keyboard test passes (5 renderer tests). Composer tests include preservation of - user input. No theme or footer customization subsystem was repurposed. + Issue Companion installer/view/recorded HTTP states/explicit draft action, + and quarantine of five hostile workloads plus unexpected child exit while a + second native plugin remains usable. Each failing generation is reaped within + two seconds, its context is revoked, and implicit restart is denied. + The example tests supply the frontend effect result; they are not stock-app GUI E2E. +- Frontend TypeScript passes. 87 affected tests passed, plus 10 new tests for + delayed effects during disable/remove/pause/workspace/thread/failure, stale + inventory responses, update/rollback remounting and explicit retry. Composer + tests include preservation of user input. Six repository theme/UUID checks and + Settings dialog Escape/focus and late composer-registration regression tests + also pass. Panels react to replacement or ambiguity in their workspace + composer registry. No theme or footer customization subsystem was repurposed. - Both examples build/check/pack with packed SDK/CLI tarballs. Issue Companion also builds/checks/packs outside the app checkout using those packages only. +- Actual Linux Secret Service round-trip and deletion passed using a synthetic + token in a random installation namespace. A public GitHub /zen request through + the production HTTP broker passed with pinned DNS/TLS and no credential/data. +- Five native loopback TLS tests pass through the real HTTP client: pinned + hostname/credential request, trust and hostname rejection, mixed/private DNS + rejection, redirect/encoding/declared/streamed body limits, timeout/cancellation, + concurrency release and private rolling quotas. The local fixture CA is trusted + only by the test's client; no system trust or real credentials are used. +- Hosted native desktop jobs passed on Linux and Windows, including Windows native OS + keyring and the installed independent packages. Initial frontend CI identified + semantic-color and UUID-helper violations; both are corrected and checked. + The next run exposed Vitest discovering a Node-only ELF test: it is now named + outside Vitest's discovery pattern and still runs explicitly with Node. The + native TLS/recovery tests passed on Windows at `5a643feb`. +- Windows NSIS installation, exact bundled-host digest, clean-environment SDK + callbacks and five hostile workloads passed in [packaged CI](https://github.com/Zeus-Deus/codemux/actions/runs/35371843758). + Maximum measured fault latency was 1016.5 ms on the 4-vCPU AMD EPYC runner. + See [Windows payload evidence](evidence/packaged-windows.json), collected from + commit `5a643feb`; this does not establish desktop GUI behavior. +- Linux deb and AppImage payloads passed at `5a643feb` in + [packaged CI](https://github.com/Zeus-Deus/codemux/actions/runs/35371843758): + exact deb digest, AppImage ELF provenance, SDK callbacks and five hostile + workloads in each. Maximum fault latency was 1005.4 ms on the 4-vCPU AMD EPYC + runner. See [Linux payload evidence](evidence/packaged-linux.json). + This resolves the linuxdeploy RPATH check failure; it is not desktop GUI E2E. +- Standalone release-host timing: 20 samples each of five hostile workloads; + maximum 1010.4 ms, activation-loop p95 1007.2 ms on Ryzen 5 7600 / Linux. + See [machine-readable evidence](evidence/host-timing-linux.json). These are + standalone activation measurements, not GUI or packaged-platform evidence. - Website: 5 catalog tests and production build pass. The existing waitlist module needs a Resend key at build time; verification supplied a synthetic non-secret placeholder, without calling its email route. - Browser evidence: Settings empty/paused/offline states; desktop catalog; mobile catalog at 390px without horizontal overflow; synthetic detail fixture - and exact-version install-link UI. Fixtures are removed from the catalog seed. + and exact-version install-link UI. Add-on Settings works at 800×600 in light + and dark themes, remains accessible with chat GUI off, and preserves keyboard + focus when Escape closes its install dialog. These remain browser-preview + checks, not native screen-reader/desktop E2E. Fixtures are removed from the seed. + +- Settings configuration controls wait for the stored values before accepting + edits or submission. A disposed release's late response/error is ignored. + TypeScript and three focused Settings tests pass for this guard. + +- Native Git exclude regression failed before the fix and passes afterward for + both ordinary and linked worktrees. All seven focused native Git tests and + `cargo check -j 2` pass. The snapshot copies only bounded regular-file exclude + data; repository programs remain disabled. +- The expanded public-SDK hostile fixture builds and packs independently. Its + five commands terminate real local hosts in 1.7–253.0 ms (blocking loop, throw, + endless promises, recursion, allocation). These standalone timings do not + establish installed-app interactivity. ## Evidence-backed implementation clarifications @@ -73,29 +438,147 @@ publication and Settings remain separate deliverables. - Catalog history cannot delete accepted ownership/release records: otherwise a later revision could reassign them without detection. Withdrawals use blocked entries while retaining their history. No mutable release replacement is allowed. +- Catalog and package releases share the app repository, whose Latest release + feeds the desktop updater, `install.sh` and hosted-client deploys. Merging the + committed revision 1 catalog would otherwise have published it as Latest. The + catalog workflow now publishes only on an approved dispatch from `main`, with + `--latest=false` and a check that Latest is unchanged; package releases use + non-`v*` tags and `--latest=false` ([release procedure](RELEASING.md)). - Rollback copies the recorded private data snapshot into a fresh writable generation, probes, switches the tuple, and normally activates through a durable journal. Failed activation preserves the original rollback snapshot. - OS keyring operations keep their serialization lock inside the blocking task; cancelling IPC cannot let an uninstall race ahead of an unfinished OS write. + Keys bind credential ID to exact origin, so an update cannot redirect a saved + bearer token. Uninstall queues all indexed historical keys, including credentials + removed from later manifests; locked-service cleanup remains retryable. +- A full 2 s watchdog left no time for reaping. The response/queue watchdog now + fires at 1.5 s, reserving cleanup within the 2 s ceiling. A cancelled instance + remains registered if the OS delays termination; no new generation can overlap. + The final host handle owns a cancellation lease, including failed initialization. +- A native fixture proved that ordinary `git status` executes repository clean + filters despite disabled hooks/fsmonitor. The bounded runner now discovers inert + metadata, snapshots the index/refs into a private Git directory, and copies only + safe status/tracking configuration. No filter, hook, include, remote URL, promisor, + or external helper configuration reaches the status child. The whole operation + retains a 5 s deadline; index snapshots are bounded to 128 MiB. Linked/split-index, + detached and unborn repositories are covered. A workspace below the repository + root is rejected rather than expanding authority outside its authorized root. + Status compares file bytes without custom filter transformations; repositories + whose filters transform content can therefore report different changed files. +- linuxdeploy rewrites an AppImage host's ELF RPATH, so its packaged bytes cannot + equal the staged release digest. AppImages instead require identical program + sections and symbol/dynamic-link semantics, allowing only the `$ORIGIN` RPATH + relocation. Tests accept a real patchelf rewrite and reject changed program + data or added dependencies. Deb and NSIS retain exact SHA-256 comparison. +- A dropped Git broker future cancels a separately owned job; its concurrency + permit remains held until the child is killed and reaped. Unix metadata opens + reject symlinks and use nonblocking mode to prevent a swapped FIFO from + stranding the filesystem worker. +- Release-mode validation of 1,000 text mutations in a valid 1,999-node, + 222,790-byte tree measured 302–499 ms on this Linux Ryzen 5 7600 host. + Revalidating and serializing the entire tree after each content change made + the advertised batch ceiling too expensive. Content updates now account for + exact serialized-size deltas and validate only the changed value. Structural + and callback changes still validate the whole candidate. A 50 ms wall-clock + validation budget rejects an expensive batch atomically and quarantines the + generation through the existing resource-failure path. This extra bound is + justified by the measured native CPU cost; it does not replace desktop frame + timing or claim a universal latency on all machines. The same workload now + measures 2.5–3.9 ms; [before/after samples](evidence/ui-validation-linux.json) + and the reproducible protocol example are included. +- Frontend stop actions fence effects synchronously until inventory refresh + completes. Older inventory responses cannot restore stale enabled state. + Native generation changes remount views; failed releases still need explicit Retry. + +- Eleven lifecycle tests and `cargo check` pass with a new ignored real-ENOSPC + test. CI mounts a dedicated 16 MiB tmpfs and fills it at six transaction + boundaries. Both script and test refuse local/persistent runners, and the + test rejects a non-tmpfs or volume larger than 32 MiB. The actual hosted six-checkpoint run passed at `278df894`, as recorded above; + the strengthened data-generation assertion is tracked separately. + +### Completion pass clarifications + +- **Handled errors.** A handler or UI callback that rejects with a stable host + error code (for example `CONTEXT_STALE` after a project switch) is logged + through the bounded log channel and the plugin keeps running. Plain throws and + other rejections remain runtime faults, so the hostile fixtures still + quarantine. A UI event for a released callback is ignored, not a fault. +- **Quotas.** The host answers the first excess `host.request` with + `RESOURCE_LIMIT` and holds excess UI batches (at most eight) instead of + dropping them; five violations within 10 s stop the generation. The parent's + transport and manager backstops allow twice the host limits and exist only + to bound a broken host. The SDK paces requests (18 per 1.2 s, 95 per 62 s), UI + batches (about 16/s) and logs below those limits. +- **Timers and CPU.** Plugin JavaScript runs only inside accounted windows; + evaluation plus activation share the 1 s activation budget. Timer ticks no + longer emit `ready`, and plugin code cannot forge `ready` or responses: the + host counts them as violations, and a child error response stops the plugin + with a fixed reason, never plugin text. +- **Stop reasons.** The host prints one reason from a fixed allowlist on stderr + before exiting; nothing else from stderr reaches diagnostics. Diagnostics + keep counts, levels, sizes and times per installation for the session, never + log text. +- **Inherited handles.** On Linux the host closes every descriptor above 2 at + startup, before any thread or plugin code runs. Stable Rust cannot restrict handle + inheritance on Windows without replacing process supervision; std creates its + own handles non-inheritable, so the residual risk is inheritable handles that + third-party native code in the app might create. +- **Interactions.** `panels.open`, `composerViews.open`, `composer.appendText` + and `links.open` each consume a live single-use interaction (10 s). A claimed + effect may finish up to 2 s after its claim. Unprompted `ui.notify` effects are + bounded to 12 s so they always finish inside the 15 s request timeout. +- **Revocation.** Disable and removal withdraw broker access (contexts, + commands, views, UI events, host requests and effect claims) before waiting + for the plugin's operation lock; the lock still serializes registry changes. +- **Workspace storage.** Workspace-scoped keys use a hash of the authorized + canonical root instead of the reusable in-app workspace counter. Rows written + under the old key cannot be attributed to one project and are discarded once. +- **HTTP.** Responses no longer expose `etag`, and conditional request headers + are refused, following the header rules of chapter 6. A result that cannot be + framed is answered with `RESOURCE_LIMIT` instead of never being answered. +- **Credentials.** v1 manifests have no optional flag: an unconfigured + credential sends unauthenticated requests, and a configured but unreadable one + fails with `CREDENTIAL_REQUIRED`. Credential IDs use the same lowercase ID + grammar as settings, so a declaration like the specification's `apiToken` + example is written in lowercase, as Issue Companion's `github-token` is. A cleared credential stays cleared after a failed re-save and a + restart; retrying cleanup never deletes a session-only value saved later. +- **Manifests and catalog.** Origins must be multi-label hostnames without + wildcards, underscores or trailing dots; IDs cannot contain a Windows device + name. The published JSON Schemas are generated from the Rust types and are + never stricter than the desktop validator; the CLI shares a case corpus with + it. Catalog selection reports incompatible API, unsupported platform, blocked + release and missing version separately. +- **Updates and recovery.** Updates keep the installation's enablement; fresh + installs and source replacements enable only on **Install & enable**. While + paused, install, update and rollback commit without a probe; the first start + after Resume is the normal activation. `incompatible-disabled` is derived from + the running app on every listing. A reset registry is moved aside whole; OS + keyring entries of the old installations stay in their installation-specific + namespaces, unreachable by new installations. An unreaped (quarantined) + generation blocks update and rollback instead of being mistaken for a probe. +- **Background catalog recheck.** It starts 30 s after the manager opens, runs + only while an add-on is installed, fetches only a missing, damaged or 24 h old + snapshot, retries hourly after a failure, and never runs under + `--disable-addons`. It only applies blocks; it never activates code. +- **Release builds.** Desktop releases run tauri-action with + `scripts/addons/verified-tauri-build.mjs` as its build command, so the + packaged runtime gate checks the exact deb, rpm, AppImage or NSIS files that + are uploaded; the packaged workflow uses the same wrapper on every run. +- **Context races through the GUI.** Disabling during an in-flight HTTP or Git + request is covered by real-host tests (Git child reaping, HTTP cancellation, + delayed-effect transitions) rather than WebDriver: shared runners cannot hold + a public request open deterministically. All other chapter 12 race cases run + in the installed desktop. ## Unresolved release gates -- Stock built-app E2E for both examples, including real frontend/native IPC, - actual controlled draft insertion and core UI operation during hostile plugins. -- Linux AppImage/deb and Windows NSIS installation/launch and bundled-host smoke - evidence, with no dependency on a development directory or compiler. -- Complete failure-injection matrix: disk-full boundaries, every journal/crash - point, unexpected child exit/ignored shutdown, concurrent update/uninstall, - and delayed workspace/thread/composer races through the actual desktop. -- OS credential backend integration, locked/missing service, durable deletion - retry; complete public HTTPS/TLS/DNS-rebinding/decoded-body/timeout fixtures. -- Complete keyboard/screen-reader, light/dark, small-window, chat-GUI-off and - core pane restoration evidence; measured runtime fault and UI budgets with - hardware/workload details. -- Publish reviewed independent package releases and the first immutable catalog - artifact through the normal release workflow; then pin that published artifact - in the website and verify website-to-desktop digest identity. The current seed - deliberately contains no fabricated release URLs or installable listings. - -No milestone with an unresolved exit gate is represented as complete. +- **Publication, deferred by maintainer decision.** Publish the reviewed SDK/CLI + tarballs and example packages, dispatch the first catalog revision, pin it in + the website and verify website-to-desktop digest identity, following + [RELEASING.md](RELEASING.md). Nothing has been published, merged or listed. +- **Repository setting.** Add required reviewers to the `addon-catalog` + environment before the first catalog dispatch; until then, write access to + run the workflow is the only gate. + +See [the acceptance evidence map](ACCEPTANCE.md) and [release procedure](RELEASING.md). diff --git a/docs/addons/RELEASING.md b/docs/addons/RELEASING.md new file mode 100644 index 00000000..f9e9ffda --- /dev/null +++ b/docs/addons/RELEASING.md @@ -0,0 +1,129 @@ +# Releasing independent feature plugins + +The desktop, author tools, feature packages and catalog are separate releases. +Themes and footer presets do not use this pipeline. The implementation remains +unreleased while any acceptance gate in [the ledger](IMPLEMENTATION.md) is open. +Merging to `main` publishes nothing; every publication below is an explicit +maintainer action. + +## Release namespaces + +Desktop releases own the `v*` tag namespace and the repository's **Latest** +release. The desktop updater (`releases/latest/download/latest.json`), +`scripts/install.sh` and the hosted-client deploy all read Latest, and pushing a +`v*` tag starts the desktop Release workflow. Every other release in this +repository uses its own tag prefix and is created with `--latest=false`: + +| Release | Tag | +| --- | --- | +| Feature package | `addon--v`, e.g. `addon-codemux.project-brief-v1.0.0` | +| Author tool | `plugin-sdk-v` and `plugin-cli-v` | +| Catalog | `addons-catalog-r`, created only by the catalog workflow | + +After creating any of them, confirm that +`gh api repos/Zeus-Deus/codemux/releases/latest --jq .tag_name` still prints the +current desktop `v*` tag. Never pass `--latest` for these releases. + +## Prepare reviewable assets + +From a committed checkout of the exact `main` commit you intend to tag, run: + +```sh +node scripts/addons/prepare-release.mjs /absolute/path/to/new-release-directory +``` + +The destination must not exist. The script exports the exact commit into a new +temporary directory, builds and packs the public SDK and CLI, then builds both +examples outside the app checkout using those tarballs. It typechecks each +example and verifies repeat packing gives identical bytes. It never builds the +desktop, changes the checkout, executes a plugin, or publishes an asset. It +warns when the commit is not on a fetched remote `main`. + +The **Independent add-on author distributions** workflow performs the same build. +A `workflow_dispatch` run on `main` retains its output as +`addon-author-release-`. A pull-request run builds the proposed head +commit, never the synthetic merge commit, and retains it as +`addon-author-candidate-pr--` for review only. Release assets +come only from a dispatch run, or a local run, on the exact commit that will be +tagged, and that commit must be reachable from `main`. + +The output contains four distributions: the SDK and CLI npm tarballs, Project +Brief, and Issue Companion. `provenance.json` records the full source commit, +versions, package manifests, SHA-256 hashes and sizes; `SHA256SUMS` permits an +independent byte check. The hostile test fixture is excluded. A CI artifact is a +review candidate, not an approved catalog release. + +## Review and publish + +1. Require the final-source native Linux and Windows gates, including the exact + candidate example packages on the stock installers. Review source, dependencies, + permissions, licenses and the recorded provenance. +2. Through the repository's normal authorized release process, create one GitHub + Release per distribution at the recorded full source commit, using the tags + above. Attach the exact reviewed bytes with `provenance.json` and `SHA256SUMS`. + Do not rebuild or overwrite accepted version bytes while publishing. For + example: + + ```sh + dir=/absolute/path/to/reviewed-release-directory + commit=$(jq -er .sourceCommit "$dir/provenance.json") + (cd "$dir" && sha256sum --check SHA256SUMS) + gh release create addon-codemux.project-brief-v1.0.0 \ + "$dir/codemux.project-brief-1.0.0.cmxaddon" "$dir/provenance.json" "$dir/SHA256SUMS" \ + --target "$commit" --latest=false --title "Project Brief 1.0.0" \ + --notes "Reviewed feature add-on package. Not a desktop release." + gh api repos/Zeus-Deus/codemux/releases/latest --jq .tag_name + ``` + + Verify a downloaded release with `sha256sum --check --ignore-missing SHA256SUMS`. +3. Publishing the SDK/CLI to npm is a separate authorized operation; before + that, authors install the exact tarballs as documented in their READMEs. + Publish the reviewed tarballs, never a fresh pack, SDK first because the CLI + starter depends on it, then read the registry bytes back: + + ```sh + dir=/absolute/path/to/reviewed-release-directory + (cd "$dir" && sha256sum --check SHA256SUMS) + npm publish "$dir/codemux-plugin-sdk-1.0.0.tgz" --access public --ignore-scripts + npm publish "$dir/codemux-plugin-cli-1.0.0.tgz" --access public --ignore-scripts + cd "$(mktemp -d)" + npm pack @codemux/plugin-sdk@1.0.0 @codemux/plugin-cli@1.0.0 + sha256sum --check --ignore-missing "$dir/SHA256SUMS" + ``` + + `--access public` is required for the first publication of a scoped + package. A maintainer-machine publication has no npm provenance attestation; + `--provenance` works only from a configured CI publisher, and none exists here. +4. Submit catalog entries with the actual public release asset URLs, exact source + commit, hashes, sizes and normalized capabilities. Follow + [catalog review](../../catalog/addons/README.md). The validator downloads the + assets and inspects them inertly; it does not grant publisher approval. +5. After the reviewed catalog PR merges, the **Reviewed add-on catalog** workflow + validates `main` but does not publish. To publish, run that workflow from + `main` with **Run workflow**, entering the catalog revision. Configure the + `addon-catalog` environment with required reviewers so that a dispatch also + needs approval. The job checks the catalog against the newest published + revision, creates the immutable `addons-catalog-r` release with + `--latest=false`. It refuses to start unless Latest is a desktop `v*` + release, and fails, restoring the previous Latest, if publishing moved Latest + to anything but a desktop release; a desktop release published at the same + time keeps it. In `codemux-sitev2`, run + `node scripts/pin-addon-catalog.mjs `, run affected + catalog tests and the site build, and review the pin change. +6. Verify that the website's download and copied install link resolve to the same + release digest in native Settings review. Complete the final release ledger. + +## Desktop releases + +A desktop `v*` release verifies its own installers. The Release workflow builds +each platform once through `scripts/addons/verified-tauri-build.mjs`, which runs +the packaged add-on runtime gate against those exact deb/rpm/AppImage or NSIS +files before tauri-action creates the release or uploads them. Before tagging, +confirm that **Packaged add-on runtime** passed for the release commit; it runs +for pull requests touching add-on and core integration files, and can be +dispatched on `main` otherwise. + +No workflow here uploads npm packages or publishes feature releases automatically. +The catalog publisher runs only on an approved dispatch from `main`. +Keep the catalog empty until real reviewed release assets exist; do not create +placeholder download URLs or claim author-provided metadata proves ownership. diff --git a/docs/addons/evidence/README.md b/docs/addons/evidence/README.md index caa89060..b5883d9c 100644 --- a/docs/addons/evidence/README.md +++ b/docs/addons/evidence/README.md @@ -1,10 +1,31 @@ -# Browser evidence +# Verification artifacts -All images use the repository's synthetic Tauri mock. They establish visual -layout only, not native plugin operation. +The `core-*` and `settings-*` PNGs below use the repository's synthetic Tauri +mock. They establish visual layout only, not native plugin operation. - `core-before.png`: baseline workspace without the plugin platform. - `core-after.png`: implementation workspace with zero installed add-ons. - `settings-after.png`: permanent Add-ons Settings, default empty state. +- `settings-small-dark.png`, `settings-small-light.png`: 800×600 Settings. +- `settings-chat-gui-off.png`: Add-ons Settings remains available with chat GUI off. + +`host-timing-linux.json` contains standalone host timing samples. +`packaged-windows.json` records installed NSIS payload checks from `5a643feb`. +`packaged-linux.json` records deb/AppImage payload checks from `5a643feb`. +These JSON files cover runtime containment, not stock desktop GUI behavior. Native and packaged verification is tracked separately in ../IMPLEMENTATION.md. + +`ui-validation-linux.json` compares native validation before and after incremental +content validation. Reproduce the workload with +`cargo run --release -j 2 --manifest-path src-tauri/addon-protocol/Cargo.toml --example ui-validation-benchmark`. +It excludes IPC and desktop rendering. + +Files named `native-*.png` capture the actual stock installed app in disposable +GitHub runners using synthetic account/project data. `native-ui-*.json` records +installer SHA-256/build revision, harness revision, hardware, completed checks +and failures separately. A `status: failed` run is never an overall passing gate; +its individual completed checks can supplement another run on the same exact +installer. Provider CLIs are intentionally absent from these runners, so core +chat checks cover controlled typing and no submission, not provider inference. +The implementation ledger links each run and explains fixture seams. diff --git a/docs/addons/evidence/host-timing-linux.json b/docs/addons/evidence/host-timing-linux.json new file mode 100644 index 00000000..639592ef --- /dev/null +++ b/docs/addons/evidence/host-timing-linux.json @@ -0,0 +1,42 @@ +{ + "profile": "release", + "packaged": false, + "os": "linux 7.2.5-3-omarchy", + "cpu": "AMD Ryzen 5 7600 6-Core Processor", + "logicalCpus": 12, + "sha256": "7f8079496c2a1e669d16c7f257a2ad31689f03203c47e2fe4bcd9094165eff87", + "samplesPerWorkload": 20, + "deadlineMs": 2000, + "workloads": [ + { + "workload": "while(true){}", + "minMs": 1002.5891979999979, + "p95Ms": 1007.2198379999991, + "maxMs": 1010.3881799999999 + }, + { + "workload": "function f(){f()} f()", + "minMs": 1.9228750000002037, + "p95Ms": 2.757315999999264, + "maxMs": 2.8019269999967946 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "minMs": 1.6830640000007406, + "p95Ms": 2.847866999996768, + "maxMs": 3.255298000000039 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "minMs": 1002.2652180000005, + "p95Ms": 1003.2288259999987, + "maxMs": 1003.2643519999983 + }, + { + "workload": "throw Error('synthetic')", + "minMs": 1.6045930000036606, + "p95Ms": 2.2726150000016787, + "maxMs": 2.5721560000019963 + } + ] +} diff --git a/docs/addons/evidence/native-corrupt-registry-windows.png b/docs/addons/evidence/native-corrupt-registry-windows.png new file mode 100644 index 00000000..166f8485 Binary files /dev/null and b/docs/addons/evidence/native-corrupt-registry-windows.png differ diff --git a/docs/addons/evidence/native-issue-companion-linux.png b/docs/addons/evidence/native-issue-companion-linux.png new file mode 100644 index 00000000..3ea0cbe7 Binary files /dev/null and b/docs/addons/evidence/native-issue-companion-linux.png differ diff --git a/docs/addons/evidence/native-issue-companion-windows.png b/docs/addons/evidence/native-issue-companion-windows.png new file mode 100644 index 00000000..bb202d7a Binary files /dev/null and b/docs/addons/evidence/native-issue-companion-windows.png differ diff --git a/docs/addons/evidence/native-package-review.png b/docs/addons/evidence/native-package-review.png new file mode 100644 index 00000000..852fff86 Binary files /dev/null and b/docs/addons/evidence/native-package-review.png differ diff --git a/docs/addons/evidence/native-project-brief-windows.png b/docs/addons/evidence/native-project-brief-windows.png new file mode 100644 index 00000000..aeee6bb0 Binary files /dev/null and b/docs/addons/evidence/native-project-brief-windows.png differ diff --git a/docs/addons/evidence/native-settings-configuration.png b/docs/addons/evidence/native-settings-configuration.png new file mode 100644 index 00000000..2c705e3e Binary files /dev/null and b/docs/addons/evidence/native-settings-configuration.png differ diff --git a/docs/addons/evidence/native-ui-linux-603a7352.json b/docs/addons/evidence/native-ui-linux-603a7352.json new file mode 100644 index 00000000..ad88ac3a --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-603a7352.json @@ -0,0 +1,43 @@ +{ + "commit": "a9cbe22b1c807f8c8d6481b7db8ea7847c068842", + "platform": "linux", + "hardware": { + "osRelease": "6.8.0-1064-azure", + "cpu": "AMD EPYC 7763 64-Core Processor", + "logicalCpus": 4, + "memoryBytes": 16765411328 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-no-auto-submit", + "08-hostile-block-core-interactivity" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "603a73527c98883937296904e66b20c4744a1abe", + "run": "35379118363", + "platform": "linux" + }, + "installerSha256": "6bdfb6e2d5551e0790ea80edbe83becd2c7ee9b91756c39ade363197059e7d8a", + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 1195.4971750000004 + } + ], + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-linux-8eb46caa.json b/docs/addons/evidence/native-ui-linux-8eb46caa.json new file mode 100644 index 00000000..d541605f --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-8eb46caa.json @@ -0,0 +1,170 @@ +{ + "commit": "8eb46caa8d9726a56d6fc397d6e9989b8936f9dd", + "platform": "linux", + "hardware": { + "osRelease": "6.8.0-1064-azure", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "logicalCpus": 4, + "memoryBytes": 16765411328 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-credential-success-clears-error", + "06-host-credential-settings-and-explicit-fallback", + "07-paired-remote-cannot-invoke-or-subscribe-to-plugins", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-virtual-list-native-accessibility", + "08-bounded-list-rendering-and-frame-budget", + "09-native-update-review-and-matching-data-rollback", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "10-context-typing-preserved", + "10-context-project-switch-cancels", + "10-context-thread-close-cancels", + "10-context-composer-replacement-cancels", + "10-context-disable-cancels", + "10-delayed-public-sdk-context-races", + "10-remove-during-activation-leaves-no-host-or-draft", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native", + "CI-only delayed public-SDK package built with the packed author tools; normal native package import, SDK broker and plugin-rendered status panel" + ], + "installerBuild": { + "commit": "8eb46caa8d9726a56d6fc397d6e9989b8936f9dd", + "run": "35886894172", + "platform": "linux" + }, + "installerSha256": "0763a6ca09fe9a08812c932a6deb3b1e76a611a453982736d2ded1626e308eea", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "credentials": { + "maskedInput": true, + "noSecretInInventoryOrSettings": true, + "mode": "missing Secret Service, explicit session-only fallback", + "removalAndFileRedaction": true + }, + "remoteBoundary": { + "transport": "paired loopback HTTP/WebSocket against stock desktop", + "approval": "explicit desktop command", + "coreRpc": true, + "addonRpcDenied": true, + "coreEvents": true, + "addonEventsDenied": true + }, + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 928.8876689999997 + }, + { + "command": "throw", + "uiObservationMs": 930.2417499999974 + }, + { + "command": "promises", + "uiObservationMs": 1053.0882659999988 + }, + { + "command": "recurse", + "uiObservationMs": 1035.1542859999972 + }, + { + "command": "allocate", + "uiObservationMs": 1024.1948370000027 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 621, + "p95FrameGapMs": 16, + "maxFrameGapMs": 30, + "refreshMs": [ + 52.55008700000326, + 335.03392699999677, + 296.7323329999999, + 297.07913499999995, + 302.09543100000155 + ], + "p95FrameGapBudgetMs": 100 + }, + "virtualListAccessibility": { + "label": "Add-on list", + "setSize": 500, + "keyboardEndPosition": 500, + "boundedRows": true + }, + "nativeUpdates": { + "sameAccess": true, + "activeHost": true, + "expandedAccessReview": true, + "cancellation": true, + "rollbackMatchingPrivateData": true, + "developerModeOff": true + }, + "corePaneRestoration": { + "preserved": [ + "Files", + "Changes", + "Review" + ], + "paused": true, + "removed": true, + "noEmptyAccessorySpace": true + }, + "contextRaces": { + "fixtureSha256": "20a13540b16c4f78a3aa65d7e0a8047f1ab9b262f6dbc31ea80bed44fbfd0cfc", + "delayMs": 4000, + "completed": [ + "typing", + "workspace", + "thread", + "replacement", + "disable" + ], + "rejections": { + "workspace": "CONTEXT_STALE", + "thread": "CONTEXT_STALE", + "replacement": "CONTEXT_STALE" + } + }, + "removalDuringActivation": { + "fixtureSha256": "31779d80176af3f3551817f8fa9b7a3129723da40dd5eecc8acf60629a7ba263", + "activationDelayMs": 700, + "removalCompletedMs": 696 + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-linux-91bd6a2b.json b/docs/addons/evidence/native-ui-linux-91bd6a2b.json new file mode 100644 index 00000000..2ab4c1ae --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-91bd6a2b.json @@ -0,0 +1,63 @@ +{ + "commit": "91bd6a2be47665e04b7c9d452072b83bc6ccd6fe", + "platform": "linux", + "hardware": { + "osRelease": "6.8.0-1064-azure", + "cpu": "Intel(R) Xeon(R) 6973P-C", + "logicalCpus": 4, + "memoryBytes": 16764375040 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "91bd6a2be47665e04b7c9d452072b83bc6ccd6fe", + "run": "35389012944", + "platform": "linux" + }, + "installerSha256": "d17576c8d31ef6de506589c4524488dc5b7f8e00deddb76175a7d425dcdda9c4", + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 833.0666830000009 + }, + { + "command": "throw", + "uiObservationMs": 818.5374860000011 + }, + { + "command": "promises", + "uiObservationMs": 898.8788729999978 + }, + { + "command": "recurse", + "uiObservationMs": 890.9920199999979 + }, + { + "command": "allocate", + "uiObservationMs": 1029.0748960000055 + } + ], + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-linux-access-91bd6a2b.json b/docs/addons/evidence/native-ui-linux-access-91bd6a2b.json new file mode 100644 index 00000000..8eacb183 --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-access-91bd6a2b.json @@ -0,0 +1,95 @@ +{ + "commit": "40593c6dca7524b851d4b3887e473ed10b0353f3", + "platform": "linux", + "hardware": { + "osRelease": "6.8.0-1064-azure", + "cpu": "AMD EPYC 7763 64-Core Processor", + "logicalCpus": 4, + "memoryBytes": 16765411328 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [ + { + "name": "06-issue-companion-native-https", + "error": "Error: Timed out: section[aria-label=\"Add-on view\"] select: POST /session/9a21a12b-9d9f-4003-8211-d564e2775730/element: {\"value\":{\"error\":\"no such element\",\"message\":\"\",\"stacktrace\":\"\"}}" + } + ], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "91bd6a2be47665e04b7c9d452072b83bc6ccd6fe", + "run": "35389012944", + "platform": "linux" + }, + "installerSha256": "d17576c8d31ef6de506589c4524488dc5b7f8e00deddb76175a7d425dcdda9c4", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 979.9746540000051 + }, + { + "command": "throw", + "uiObservationMs": 986.9138780000067 + }, + { + "command": "promises", + "uiObservationMs": 1089.8586819999982 + }, + { + "command": "recurse", + "uiObservationMs": 1104.6862760000004 + }, + { + "command": "allocate", + "uiObservationMs": 1068.487857 + } + ], + "status": "failed", + "error": "Error: One or more native acceptance gates failed; see failedChecks", + "failureInventory": { + "installed": [], + "paused": true, + "error": "Add-on registry is unavailable; plugins are paused", + "developmentPackage": null, + "warnings": [], + "developerMode": false + } +} diff --git a/docs/addons/evidence/native-ui-linux-credentials-312321e3.json b/docs/addons/evidence/native-ui-linux-credentials-312321e3.json new file mode 100644 index 00000000..06075b4a --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-credentials-312321e3.json @@ -0,0 +1,114 @@ +{ + "commit": "cddb92f0575b6758c616b681f933741ca9491997", + "platform": "linux", + "hardware": { + "osRelease": "6.8.0-1064-azure", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "logicalCpus": 4, + "memoryBytes": 16765415424 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "06-host-credential-settings-and-explicit-fallback", + "07-paired-remote-cannot-invoke-or-subscribe-to-plugins", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-bounded-list-rendering-and-frame-budget", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "312321e3e72985f637b02b0621526e7d6a24fd69", + "run": "35392514725", + "platform": "linux" + }, + "installerSha256": "fcaa802640ca730f9af687ddd583433e05f97055fa469bf89663be7936d58d59", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "credentials": { + "maskedInput": true, + "noSecretInInventoryOrSettings": true, + "mode": "missing Secret Service, explicit session-only fallback", + "removalAndFileRedaction": true + }, + "remoteBoundary": { + "transport": "paired loopback HTTP/WebSocket against stock desktop", + "approval": "explicit desktop command", + "coreRpc": true, + "addonRpcDenied": true, + "coreEvents": true, + "addonEventsDenied": true + }, + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 1024.1605830000008 + }, + { + "command": "throw", + "uiObservationMs": 1019.1787729999996 + }, + { + "command": "promises", + "uiObservationMs": 1085.4084719999955 + }, + { + "command": "recurse", + "uiObservationMs": 1128.1113160000023 + }, + { + "command": "allocate", + "uiObservationMs": 1113.6135060000015 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 652, + "p95FrameGapMs": 16, + "maxFrameGapMs": 37, + "refreshMs": [ + 330.3493879999951, + 308.118730999995, + 303.03867199999513, + 304.1580830000021, + 304.37860999999975 + ], + "p95FrameGapBudgetMs": 100 + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-linux-final-81b4e666.json b/docs/addons/evidence/native-ui-linux-final-81b4e666.json new file mode 100644 index 00000000..8248ee45 --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-final-81b4e666.json @@ -0,0 +1,227 @@ +{ + "commit": "9b807e9bc16f5d1078d6074526b47699517540e5", + "platform": "linux", + "hardware": { + "osRelease": "6.8.0-1064-azure", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "logicalCpus": 4, + "memoryBytes": 16765411328 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "05-project-brief-composer-action", + "06-issue-companion-native-https", + "06-issue-companion-composer-accessory-and-link", + "07-credential-success-clears-error", + "06-host-credential-settings-and-explicit-fallback", + "07-paired-remote-cannot-invoke-or-subscribe-to-plugins", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-virtual-list-native-accessibility", + "08-bounded-list-rendering-and-frame-budget", + "09-native-update-review-and-matching-data-rollback", + "09-project-brief-setting-and-private-preference", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "10-project-brief-reinstall-follows-data-choice", + "10-context-typing-preserved", + "10-context-project-switch-cancels", + "10-context-thread-close-cancels", + "10-context-composer-replacement-cancels", + "10-context-disable-cancels", + "10-delayed-public-sdk-context-races", + "10-remove-during-activation-leaves-no-host-or-draft", + "11-corrupt-plugin-registry-does-not-block-core-startup", + "11-registry-reset-restores-add-on-management" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native", + "Linux xdg-open on the launched app's PATH is a logger; the native opener path runs, no browser starts", + "CI-only delayed public-SDK package built with the packed author tools; normal native package import, SDK broker and plugin-rendered status panel" + ], + "installerBuild": { + "commit": "9b807e9bc16f5d1078d6074526b47699517540e5", + "run": "35916722628", + "platform": "linux" + }, + "installerSha256": "f5b9a3000d7af9f65fb010d110e37a9bb435a42c1fbf3b41bd50378c6a066a6f", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "composerActions": { + "projectBrief": { + "menuGroup": "Add-ons", + "includeFiles": true, + "appended": true + }, + "issueAccessory": { + "menuGroup": "Add-ons", + "aboveFooter": true, + "appended": true, + "closed": true + }, + "externalLink": { + "attributionToast": true, + "launch": "exact HTTPS URL logged by xdg-open on the app's PATH" + } + }, + "keyboardActivations": 1, + "credentials": { + "maskedInput": true, + "noSecretInInventoryOrSettings": true, + "mode": "missing Secret Service, explicit session-only fallback", + "removalAndFileRedaction": true + }, + "remoteBoundary": { + "transport": "paired loopback HTTP/WebSocket against stock desktop", + "approval": "explicit desktop command", + "coreRpc": true, + "addonRpcDenied": true, + "coreEvents": true, + "addonEventsDenied": true + }, + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 1099.8560060000018 + }, + { + "command": "throw", + "uiObservationMs": 1112.7843609999982 + }, + { + "command": "promises", + "uiObservationMs": 1115.3377170000022 + }, + { + "command": "recurse", + "uiObservationMs": 1138.4511979999952 + }, + { + "command": "allocate", + "uiObservationMs": 1148.367766000003 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 664, + "p95FrameGapMs": 17, + "maxFrameGapMs": 28, + "refreshMs": [ + 349.146219000002, + 316.0295160000096, + 314.996312999996, + 330.25101699998777, + 317.74096599999757 + ], + "p95FrameGapBudgetMs": 100, + "page": { + "start": { + "visibility": "visible", + "focused": true + }, + "end": { + "visibility": "visible", + "focused": true + } + } + }, + "virtualListAccessibility": { + "label": "Add-on list", + "setSize": 500, + "keyboardEndPosition": 500, + "boundedRows": true + }, + "nativeUpdates": { + "sameAccess": true, + "activeHost": true, + "expandedAccessReview": true, + "cancellation": true, + "rollbackMatchingPrivateData": true, + "developerModeOff": true + }, + "projectBriefPreferences": { + "includeFilesOffOmitsChangedPaths": true, + "privatePreferencePersisted": true, + "survivedRestart": { + "includeFiles": false, + "showPaths": false + }, + "reinstall": { + "keptDataRestored": { + "includeFiles": false, + "showPaths": false + }, + "removedDataNotOffered": { + "includeFiles": true, + "showPaths": true + } + } + }, + "terminalFocusFallbacks": 2, + "corePaneRestoration": { + "preserved": [ + "Files", + "Changes", + "Review" + ], + "paused": true, + "removed": true, + "noEmptyAccessorySpace": true + }, + "contextRaces": { + "fixtureSha256": "7338d165ae241eaac6b2d001d454752a7d58993aa81f5f99c75b8700adcd136f", + "delayMs": 4000, + "completed": [ + "typing", + "workspace", + "thread", + "replacement", + "disable" + ], + "rejections": { + "workspace": "CONTEXT_STALE", + "thread": "CONTEXT_STALE", + "replacement": "CONTEXT_STALE" + } + }, + "removalDuringActivation": { + "fixtureSha256": "d4ca6723b6f451f2eb2e69e415d8a3728acff313042fef915f74de1c2f4f9447", + "activationDelayMs": 700, + "removalCompletedMs": 601 + }, + "registryReset": { + "backupKept": true, + "freshRegistry": true + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-linux-panes-312321e3.json b/docs/addons/evidence/native-ui-linux-panes-312321e3.json new file mode 100644 index 00000000..ffc83cf4 --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-panes-312321e3.json @@ -0,0 +1,124 @@ +{ + "commit": "d159f4d9155014409887f47ce03881f8accda9c6", + "platform": "linux", + "hardware": { + "osRelease": "6.8.0-1064-azure", + "cpu": "AMD EPYC 7763 64-Core Processor", + "logicalCpus": 4, + "memoryBytes": 16765411328 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "06-host-credential-settings-and-explicit-fallback", + "07-paired-remote-cannot-invoke-or-subscribe-to-plugins", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-bounded-list-rendering-and-frame-budget", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "312321e3e72985f637b02b0621526e7d6a24fd69", + "run": "35392514725", + "platform": "linux" + }, + "installerSha256": "fcaa802640ca730f9af687ddd583433e05f97055fa469bf89663be7936d58d59", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "credentials": { + "maskedInput": true, + "noSecretInInventoryOrSettings": true, + "mode": "missing Secret Service, explicit session-only fallback", + "removalAndFileRedaction": true + }, + "remoteBoundary": { + "transport": "paired loopback HTTP/WebSocket against stock desktop", + "approval": "explicit desktop command", + "coreRpc": true, + "addonRpcDenied": true, + "coreEvents": true, + "addonEventsDenied": true + }, + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 1094.055357000001 + }, + { + "command": "throw", + "uiObservationMs": 1020.3438630000019 + }, + { + "command": "promises", + "uiObservationMs": 1120.559577 + }, + { + "command": "recurse", + "uiObservationMs": 1069.9110290000026 + }, + { + "command": "allocate", + "uiObservationMs": 1119.883388000002 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 669, + "p95FrameGapMs": 16, + "maxFrameGapMs": 22, + "refreshMs": [ + 367.17189099999814, + 303.63689400000294, + 345.8361010000008, + 345.2866120000035, + 342.9177019999988 + ], + "p95FrameGapBudgetMs": 100 + }, + "corePaneRestoration": { + "preserved": [ + "Files", + "Changes", + "Review" + ], + "paused": true, + "removed": true, + "noEmptyAccessorySpace": true + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-linux-partial.json b/docs/addons/evidence/native-ui-linux-partial.json new file mode 100644 index 00000000..cac2aa66 --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-partial.json @@ -0,0 +1,28 @@ +{ + "commit": "4f54b31430f7ba1dd4ae44aa85a7a3c2806367cc", + "platform": "linux", + "checks": [ + "01-settings", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-disable-enable", + "03-issue-configuration" + ], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "603a73527c98883937296904e66b20c4744a1abe", + "run": "35379118363", + "platform": "linux" + }, + "installerSha256": "6bdfb6e2d5551e0790ea80edbe83becd2c7ee9b91756c39ade363197059e7d8a", + "status": "failed", + "error": "Error: Timed out: 1 untracked", + "captureError": "TimeoutError: The operation was aborted due to timeout", + "workflowRun": "35382610101", + "artifactId": "10562902030", + "note": "Partial native UI evidence only. The run stopped at the Git count assertion: repository info/exclude was not copied into the private metadata snapshot. Later steps did not run." +} diff --git a/docs/addons/evidence/native-ui-linux-races-b5377a1d.json b/docs/addons/evidence/native-ui-linux-races-b5377a1d.json new file mode 100644 index 00000000..6dd3b22c --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-races-b5377a1d.json @@ -0,0 +1,170 @@ +{ + "commit": "1c9e3bd1128d934bdcd22a75ce73886d4b05221b", + "platform": "linux", + "hardware": { + "osRelease": "6.8.0-1064-azure", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "logicalCpus": 4, + "memoryBytes": 16765411328 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-credential-success-clears-error", + "06-host-credential-settings-and-explicit-fallback", + "07-paired-remote-cannot-invoke-or-subscribe-to-plugins", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-virtual-list-native-accessibility", + "08-bounded-list-rendering-and-frame-budget", + "09-native-update-review-and-matching-data-rollback", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "10-context-typing-preserved", + "10-context-project-switch-cancels", + "10-context-thread-close-cancels", + "10-context-composer-replacement-cancels", + "10-context-disable-cancels", + "10-delayed-public-sdk-context-races", + "10-remove-during-activation-leaves-no-host-or-draft", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native", + "CI-only delayed public-SDK package built with the packed author tools; normal native package import, SDK broker and plugin-rendered status panel" + ], + "installerBuild": { + "commit": "fe79468311d9482ecfd5d38b6573703af9a4abcb", + "run": "35398102950", + "platform": "linux" + }, + "installerSha256": "6fc0e008fdb6b0edc93a9c2393d13c34ffc5a06b86f97d7885ef10f58e8ab631", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "credentials": { + "maskedInput": true, + "noSecretInInventoryOrSettings": true, + "mode": "missing Secret Service, explicit session-only fallback", + "removalAndFileRedaction": true + }, + "remoteBoundary": { + "transport": "paired loopback HTTP/WebSocket against stock desktop", + "approval": "explicit desktop command", + "coreRpc": true, + "addonRpcDenied": true, + "coreEvents": true, + "addonEventsDenied": true + }, + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 1087.647159 + }, + { + "command": "throw", + "uiObservationMs": 1108.9837659999976 + }, + { + "command": "promises", + "uiObservationMs": 1232.5089799999987 + }, + { + "command": "recurse", + "uiObservationMs": 1124.1479699999982 + }, + { + "command": "allocate", + "uiObservationMs": 1225.3498699999982 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 684, + "p95FrameGapMs": 17, + "maxFrameGapMs": 23, + "refreshMs": [ + 336.3466039999985, + 308.68212300000596, + 308.97810000000027, + 310.7354419999974, + 307.25568299999577 + ], + "p95FrameGapBudgetMs": 100 + }, + "virtualListAccessibility": { + "label": "Add-on list", + "setSize": 500, + "keyboardEndPosition": 500, + "boundedRows": true + }, + "nativeUpdates": { + "sameAccess": true, + "activeHost": true, + "expandedAccessReview": true, + "cancellation": true, + "rollbackMatchingPrivateData": true, + "developerModeOff": true + }, + "corePaneRestoration": { + "preserved": [ + "Files", + "Changes", + "Review" + ], + "paused": true, + "removed": true, + "noEmptyAccessorySpace": true + }, + "contextRaces": { + "fixtureSha256": "872fb9f893cc0b8e77d94bdbb231be568993f2e510f519d398718c265936f7be", + "delayMs": 4000, + "completed": [ + "typing", + "workspace", + "thread", + "replacement", + "disable" + ], + "rejections": { + "workspace": "CONTEXT_STALE", + "thread": "CONTEXT_STALE", + "replacement": "CONTEXT_STALE" + } + }, + "removalDuringActivation": { + "fixtureSha256": "27ddb0c99ad0539d83c2bdac9c089b28da544e41525a3b1e48e0b8ae94775520", + "activationDelayMs": 700, + "removalCompletedMs": 694 + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-linux-render-91bd6a2b.json b/docs/addons/evidence/native-ui-linux-render-91bd6a2b.json new file mode 100644 index 00000000..80a431a6 --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-render-91bd6a2b.json @@ -0,0 +1,98 @@ +{ + "commit": "4419e446a62f6ca6f320226b2775afe9e0e2458e", + "platform": "linux", + "hardware": { + "osRelease": "6.8.0-1064-azure", + "cpu": "INTEL(R) XEON(R) PLATINUM 8573C", + "logicalCpus": 4, + "memoryBytes": 16764379136 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-bounded-list-rendering-and-frame-budget", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "91bd6a2be47665e04b7c9d452072b83bc6ccd6fe", + "run": "35389012944", + "platform": "linux" + }, + "installerSha256": "d17576c8d31ef6de506589c4524488dc5b7f8e00deddb76175a7d425dcdda9c4", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 1027.7271499999988 + }, + { + "command": "throw", + "uiObservationMs": 962.5377569999982 + }, + { + "command": "promises", + "uiObservationMs": 1095.9846929999985 + }, + { + "command": "recurse", + "uiObservationMs": 1065.817302000003 + }, + { + "command": "allocate", + "uiObservationMs": 1086.9667390000031 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 681, + "p95FrameGapMs": 16, + "maxFrameGapMs": 28, + "refreshMs": [ + 326.94924500000343, + 355.58245899999747, + 341.2502270000041, + 338.0904079999964, + 337.87451000000146 + ], + "p95FrameGapBudgetMs": 100 + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-linux-updates-312321e3.json b/docs/addons/evidence/native-ui-linux-updates-312321e3.json new file mode 100644 index 00000000..b9a49810 --- /dev/null +++ b/docs/addons/evidence/native-ui-linux-updates-312321e3.json @@ -0,0 +1,133 @@ +{ + "commit": "a1141577e1efb8ef3f3c5cef3ab4509ae962e04b", + "platform": "linux", + "hardware": { + "osRelease": "6.8.0-1064-azure", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "logicalCpus": 4, + "memoryBytes": 16765411328 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "06-host-credential-settings-and-explicit-fallback", + "07-paired-remote-cannot-invoke-or-subscribe-to-plugins", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-bounded-list-rendering-and-frame-budget", + "09-native-update-review-and-matching-data-rollback", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "312321e3e72985f637b02b0621526e7d6a24fd69", + "run": "35392514725", + "platform": "linux" + }, + "installerSha256": "fcaa802640ca730f9af687ddd583433e05f97055fa469bf89663be7936d58d59", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "credentials": { + "maskedInput": true, + "noSecretInInventoryOrSettings": true, + "mode": "missing Secret Service, explicit session-only fallback", + "removalAndFileRedaction": true + }, + "remoteBoundary": { + "transport": "paired loopback HTTP/WebSocket against stock desktop", + "approval": "explicit desktop command", + "coreRpc": true, + "addonRpcDenied": true, + "coreEvents": true, + "addonEventsDenied": true + }, + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 949.7270820000012 + }, + { + "command": "throw", + "uiObservationMs": 974.5817419999985 + }, + { + "command": "promises", + "uiObservationMs": 1026.974457999997 + }, + { + "command": "recurse", + "uiObservationMs": 991.8235429999986 + }, + { + "command": "allocate", + "uiObservationMs": 1008.2087790000005 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 640, + "p95FrameGapMs": 16, + "maxFrameGapMs": 19, + "refreshMs": [ + 326.7626820000005, + 296.81468500000483, + 338.3849380000029, + 336.94675899999856, + 337.7967339999959 + ], + "p95FrameGapBudgetMs": 100 + }, + "nativeUpdates": { + "sameAccess": true, + "activeHost": true, + "expandedAccessReview": true, + "cancellation": true, + "rollbackMatchingPrivateData": true, + "developerModeOff": true + }, + "corePaneRestoration": { + "preserved": [ + "Files", + "Changes", + "Review" + ], + "paused": true, + "removed": true, + "noEmptyAccessorySpace": true + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-windows-603a7352.json b/docs/addons/evidence/native-ui-windows-603a7352.json new file mode 100644 index 00000000..aa99091b --- /dev/null +++ b/docs/addons/evidence/native-ui-windows-603a7352.json @@ -0,0 +1,44 @@ +{ + "commit": "98081809efacfa1142d06e48bb2d33186d2a0f62", + "platform": "win32", + "hardware": { + "osRelease": "10.0.26100", + "cpu": "Intel(R) Xeon(R) 6973P-C", + "logicalCpus": 4, + "memoryBytes": 17174360064 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-no-auto-submit", + "08-hostile-block-core-interactivity" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "603a73527c98883937296904e66b20c4744a1abe", + "run": "35379118363", + "platform": "win32" + }, + "installerSha256": "d589dd5dd4bab1fae45fd2c5706fd6dc3bb9383ab503b5f5533b25beee979fca", + "windowsDriverMode": "Microsoft WebView2 attach (app-specific disposable runner policy)", + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 643.6728999999978 + } + ], + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-windows-access-da835efb.json b/docs/addons/evidence/native-ui-windows-access-da835efb.json new file mode 100644 index 00000000..0682db41 --- /dev/null +++ b/docs/addons/evidence/native-ui-windows-access-da835efb.json @@ -0,0 +1,83 @@ +{ + "commit": "40593c6dca7524b851d4b3887e473ed10b0353f3", + "platform": "win32", + "hardware": { + "osRelease": "10.0.26100", + "cpu": "AMD EPYC 7763 64-Core Processor ", + "logicalCpus": 4, + "memoryBytes": 17174360064 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "da835efb4571b600465920b78ea946c980bc8f1a", + "run": "35386904258", + "platform": "win32" + }, + "installerSha256": "5ee93418900dd3b7c0333e27676913afafd786d19fc65d64d348afe741d1a288", + "windowsDriverMode": "Microsoft WebView2 attach (app-specific disposable runner policy)", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 734.7642000000051 + }, + { + "command": "throw", + "uiObservationMs": 743.5630999999994 + }, + { + "command": "promises", + "uiObservationMs": 858.625 + }, + { + "command": "recurse", + "uiObservationMs": 838.3570000000036 + }, + { + "command": "allocate", + "uiObservationMs": 816.6590999999971 + } + ], + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-windows-classic-da835efb.json b/docs/addons/evidence/native-ui-windows-classic-da835efb.json new file mode 100644 index 00000000..a0c0c327 --- /dev/null +++ b/docs/addons/evidence/native-ui-windows-classic-da835efb.json @@ -0,0 +1,68 @@ +{ + "commit": "6ace5b4ef84e1aa1ad8868223dc68e278fe6615e", + "platform": "win32", + "hardware": { + "osRelease": "10.0.26100", + "cpu": "AMD EPYC 7763 64-Core Processor ", + "logicalCpus": 4, + "memoryBytes": 17174360064 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "da835efb4571b600465920b78ea946c980bc8f1a", + "run": "35386904258", + "platform": "win32" + }, + "installerSha256": "5ee93418900dd3b7c0333e27676913afafd786d19fc65d64d348afe741d1a288", + "windowsDriverMode": "Microsoft WebView2 attach (app-specific disposable runner policy)", + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 763.242600000005 + }, + { + "command": "throw", + "uiObservationMs": 717.2088999999978 + }, + { + "command": "promises", + "uiObservationMs": 801.9558000000034 + }, + { + "command": "recurse", + "uiObservationMs": 825.0218999999997 + }, + { + "command": "allocate", + "uiObservationMs": 800.0722999999998 + } + ], + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-windows-da835efb.json b/docs/addons/evidence/native-ui-windows-da835efb.json new file mode 100644 index 00000000..79ff0fce --- /dev/null +++ b/docs/addons/evidence/native-ui-windows-da835efb.json @@ -0,0 +1,67 @@ +{ + "commit": "8500a8c2f8881c7fbba3e44fd0efcbe493648793", + "platform": "win32", + "hardware": { + "osRelease": "10.0.26100", + "cpu": "AMD EPYC 7763 64-Core Processor ", + "logicalCpus": 4, + "memoryBytes": 17178693632 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "09-paused-restart-preserves-installations-and-settings", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "da835efb4571b600465920b78ea946c980bc8f1a", + "run": "35386904258", + "platform": "win32" + }, + "installerSha256": "5ee93418900dd3b7c0333e27676913afafd786d19fc65d64d348afe741d1a288", + "windowsDriverMode": "Microsoft WebView2 attach (app-specific disposable runner policy)", + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 787.6915000000008 + }, + { + "command": "throw", + "uiObservationMs": 707.0866999999998 + }, + { + "command": "promises", + "uiObservationMs": 793.947699999997 + }, + { + "command": "recurse", + "uiObservationMs": 778.914499999999 + }, + { + "command": "allocate", + "uiObservationMs": 779.0352000000057 + } + ], + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-windows-final-81b4e666.json b/docs/addons/evidence/native-ui-windows-final-81b4e666.json new file mode 100644 index 00000000..16242d89 --- /dev/null +++ b/docs/addons/evidence/native-ui-windows-final-81b4e666.json @@ -0,0 +1,248 @@ +{ + "commit": "9b807e9bc16f5d1078d6074526b47699517540e5", + "platform": "win32", + "hardware": { + "osRelease": "10.0.26100", + "cpu": "AMD EPYC 7763 64-Core Processor ", + "logicalCpus": 4, + "memoryBytes": 17174360064 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "05-project-brief-composer-action", + "06-issue-companion-native-https", + "06-issue-companion-composer-accessory-and-link", + "07-credential-success-clears-error", + "06-host-credential-settings-and-explicit-fallback", + "07-paired-remote-cannot-invoke-or-subscribe-to-plugins", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-virtual-list-native-accessibility", + "08-bounded-list-rendering-and-frame-budget", + "09-native-update-review-and-matching-data-rollback", + "09-project-brief-setting-and-private-preference", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "10-project-brief-reinstall-follows-data-choice", + "10-context-typing-preserved", + "10-context-project-switch-cancels", + "10-context-thread-close-cancels", + "10-context-composer-replacement-cancels", + "10-context-disable-cancels", + "10-delayed-public-sdk-context-races", + "10-remove-during-activation-leaves-no-host-or-draft", + "11-corrupt-plugin-registry-does-not-block-core-startup", + "11-registry-reset-restores-add-on-management" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native", + "Windows WebView2 test switch --disable-backgrounding-occluded-windows: the runner browser an add-on link opens cannot pause the covered app's rendering", + "CI-only delayed public-SDK package built with the packed author tools; normal native package import, SDK broker and plugin-rendered status panel" + ], + "installerBuild": { + "commit": "9b807e9bc16f5d1078d6074526b47699517540e5", + "run": "35916722628", + "platform": "win32" + }, + "installerSha256": "fb45fb4f814da89d483efea7062f1f2cfa1ee9e68326e04f269f8fd60d99bbcc", + "windowsDriverMode": "Microsoft WebView2 attach (app-specific disposable runner policy)", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "composerActions": { + "projectBrief": { + "menuGroup": "Add-ons", + "includeFiles": true, + "appended": true + }, + "issueAccessory": { + "menuGroup": "Add-ons", + "aboveFooter": true, + "appended": true, + "closed": true + }, + "externalLink": { + "attributionToast": true, + "launch": "system handler; not observable on the runner", + "openFailureToast": false, + "foreground": { + "before": { + "window": true, + "processName": "codemux", + "title": "Codemux", + "ownApp": true + }, + "after": { + "window": true, + "processName": "msedge", + "title": "", + "ownApp": false + } + } + } + }, + "credentials": { + "maskedInput": true, + "noSecretInInventoryOrSettings": true, + "mode": "native Windows credential store", + "removalAndFileRedaction": true + }, + "remoteBoundary": { + "transport": "paired loopback HTTP/WebSocket against stock desktop", + "approval": "explicit desktop command", + "coreRpc": true, + "addonRpcDenied": true, + "coreEvents": true, + "addonEventsDenied": true + }, + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 752.6326999999947 + }, + { + "command": "throw", + "uiObservationMs": 773.6938000000009 + }, + { + "command": "promises", + "uiObservationMs": 1125.0247999999992 + }, + { + "command": "recurse", + "uiObservationMs": 794.1506000000008 + }, + { + "command": "allocate", + "uiObservationMs": 824.1682999999975 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 392, + "p95FrameGapMs": 15.700000000004366, + "maxFrameGapMs": 15.700000000004366, + "refreshMs": [ + 330.1137000000017, + 344.77489999998943, + 347.4285999999993, + 604.7620000000024, + 340.02219999999215 + ], + "p95FrameGapBudgetMs": 100, + "page": { + "start": { + "focused": true, + "visibility": "visible", + "foreground": { + "window": true, + "processName": "msedge", + "title": "", + "ownApp": false + } + }, + "end": { + "focused": true, + "visibility": "visible" + } + } + }, + "virtualListAccessibility": { + "label": "Add-on list", + "setSize": 500, + "keyboardEndPosition": 500, + "boundedRows": true + }, + "nativeUpdates": { + "sameAccess": true, + "activeHost": true, + "expandedAccessReview": true, + "cancellation": true, + "rollbackMatchingPrivateData": true, + "developerModeOff": true + }, + "projectBriefPreferences": { + "includeFilesOffOmitsChangedPaths": true, + "privatePreferencePersisted": true, + "survivedRestart": { + "includeFiles": false, + "showPaths": false + }, + "reinstall": { + "keptDataRestored": { + "includeFiles": false, + "showPaths": false + }, + "removedDataNotOffered": { + "includeFiles": true, + "showPaths": true + } + } + }, + "terminalFocusFallbacks": 1, + "corePaneRestoration": { + "preserved": [ + "Files", + "Changes", + "Review" + ], + "paused": true, + "removed": true, + "noEmptyAccessorySpace": true + }, + "contextRaces": { + "fixtureSha256": "d5bd5066a2fb2541e0d7363fc3463da3962b746d2f9b65bc4399eca547ac3734", + "delayMs": 4000, + "completed": [ + "typing", + "workspace", + "thread", + "replacement", + "disable" + ], + "rejections": { + "workspace": "CONTEXT_STALE", + "thread": "CONTEXT_STALE", + "replacement": "CONTEXT_STALE" + } + }, + "removalDuringActivation": { + "fixtureSha256": "134058dd99368fa93676488c4d6ed1a413164daab0897c75faf32d9324b49d5f", + "activationDelayMs": 700, + "removalCompletedMs": 742 + }, + "registryReset": { + "backupKept": true, + "freshRegistry": true + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-windows-panes-da835efb.json b/docs/addons/evidence/native-ui-windows-panes-da835efb.json new file mode 100644 index 00000000..42a50d3e --- /dev/null +++ b/docs/addons/evidence/native-ui-windows-panes-da835efb.json @@ -0,0 +1,125 @@ +{ + "commit": "d159f4d9155014409887f47ce03881f8accda9c6", + "platform": "win32", + "hardware": { + "osRelease": "10.0.26100", + "cpu": "INTEL(R) XEON(R) PLATINUM 8573C", + "logicalCpus": 4, + "memoryBytes": 17174360064 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "06-host-credential-settings-and-explicit-fallback", + "07-paired-remote-cannot-invoke-or-subscribe-to-plugins", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-bounded-list-rendering-and-frame-budget", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "da835efb4571b600465920b78ea946c980bc8f1a", + "run": "35386904258", + "platform": "win32" + }, + "installerSha256": "5ee93418900dd3b7c0333e27676913afafd786d19fc65d64d348afe741d1a288", + "windowsDriverMode": "Microsoft WebView2 attach (app-specific disposable runner policy)", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "credentials": { + "maskedInput": true, + "noSecretInInventoryOrSettings": true, + "mode": "native Windows credential store", + "removalAndFileRedaction": true + }, + "remoteBoundary": { + "transport": "paired loopback HTTP/WebSocket against stock desktop", + "approval": "explicit desktop command", + "coreRpc": true, + "addonRpcDenied": true, + "coreEvents": true, + "addonEventsDenied": true + }, + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 720.1849999999977 + }, + { + "command": "throw", + "uiObservationMs": 847.4954999999973 + }, + { + "command": "promises", + "uiObservationMs": 783.7483999999968 + }, + { + "command": "recurse", + "uiObservationMs": 838.1077000000005 + }, + { + "command": "allocate", + "uiObservationMs": 798.1844999999958 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 397, + "p95FrameGapMs": 15.700000000004366, + "maxFrameGapMs": 15.700000000004366, + "refreshMs": [ + 320.7900000000009, + 586.000199999995, + 325.5833999999959, + 328.9976999999999, + 606.938900000001 + ], + "p95FrameGapBudgetMs": 100 + }, + "corePaneRestoration": { + "preserved": [ + "Files", + "Changes", + "Review" + ], + "paused": true, + "removed": true, + "noEmptyAccessorySpace": true + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-windows-races-b5377a1d.json b/docs/addons/evidence/native-ui-windows-races-b5377a1d.json new file mode 100644 index 00000000..d8bbf754 --- /dev/null +++ b/docs/addons/evidence/native-ui-windows-races-b5377a1d.json @@ -0,0 +1,171 @@ +{ + "commit": "660e404f3dc0fd1fb01f4d658c2cc398c3e4d186", + "platform": "win32", + "hardware": { + "osRelease": "10.0.26100", + "cpu": "AMD EPYC 7763 64-Core Processor ", + "logicalCpus": 4, + "memoryBytes": 17178693632 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-credential-success-clears-error", + "06-host-credential-settings-and-explicit-fallback", + "07-paired-remote-cannot-invoke-or-subscribe-to-plugins", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-virtual-list-native-accessibility", + "08-bounded-list-rendering-and-frame-budget", + "09-native-update-review-and-matching-data-rollback", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "10-context-typing-preserved", + "10-context-project-switch-cancels", + "10-context-thread-close-cancels", + "10-context-composer-replacement-cancels", + "10-context-disable-cancels", + "10-delayed-public-sdk-context-races", + "10-remove-during-activation-leaves-no-host-or-draft", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native", + "CI-only delayed public-SDK package built with the packed author tools; normal native package import, SDK broker and plugin-rendered status panel" + ], + "installerBuild": { + "commit": "fe79468311d9482ecfd5d38b6573703af9a4abcb", + "run": "35398102950", + "platform": "win32" + }, + "installerSha256": "c2df0ba03411d0a9b112df7d5274d2aa36b06a2f50a9b461b6f36b73763e68b5", + "windowsDriverMode": "Microsoft WebView2 attach (app-specific disposable runner policy)", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "credentials": { + "maskedInput": true, + "noSecretInInventoryOrSettings": true, + "mode": "native Windows credential store", + "removalAndFileRedaction": true + }, + "remoteBoundary": { + "transport": "paired loopback HTTP/WebSocket against stock desktop", + "approval": "explicit desktop command", + "coreRpc": true, + "addonRpcDenied": true, + "coreEvents": true, + "addonEventsDenied": true + }, + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 888.1603000000032 + }, + { + "command": "throw", + "uiObservationMs": 743.0720000000001 + }, + { + "command": "promises", + "uiObservationMs": 820.3561999999947 + }, + { + "command": "recurse", + "uiObservationMs": 783.7592000000004 + }, + { + "command": "allocate", + "uiObservationMs": 812.5240999999951 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 368, + "p95FrameGapMs": 15.700000000004366, + "maxFrameGapMs": 15.799999999995634, + "refreshMs": [ + 322.3625000000029, + 321.92029999999795, + 326.9128999999957, + 326.0115000000005, + 333.7044999999998 + ], + "p95FrameGapBudgetMs": 100 + }, + "virtualListAccessibility": { + "label": "Add-on list", + "setSize": 500, + "keyboardEndPosition": 500, + "boundedRows": true + }, + "nativeUpdates": { + "sameAccess": true, + "activeHost": true, + "expandedAccessReview": true, + "cancellation": true, + "rollbackMatchingPrivateData": true, + "developerModeOff": true + }, + "corePaneRestoration": { + "preserved": [ + "Files", + "Changes", + "Review" + ], + "paused": true, + "removed": true, + "noEmptyAccessorySpace": true + }, + "contextRaces": { + "fixtureSha256": "09b14d21f9d611a759751202d38001f64d4a5ca04bd62800c535da39a7f2ce16", + "delayMs": 4000, + "completed": [ + "typing", + "workspace", + "thread", + "replacement", + "disable" + ], + "rejections": { + "workspace": "CONTEXT_STALE", + "thread": "CONTEXT_STALE", + "replacement": "CONTEXT_STALE" + } + }, + "removalDuringActivation": { + "fixtureSha256": "01d1b6b07fd4dc055ca01faefa774448905856d8d8922aa610076165184daaee", + "activationDelayMs": 700, + "removalCompletedMs": 726 + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-windows-render-da835efb.json b/docs/addons/evidence/native-ui-windows-render-da835efb.json new file mode 100644 index 00000000..9dbad5f0 --- /dev/null +++ b/docs/addons/evidence/native-ui-windows-render-da835efb.json @@ -0,0 +1,99 @@ +{ + "commit": "4419e446a62f6ca6f320226b2775afe9e0e2458e", + "platform": "win32", + "hardware": { + "osRelease": "10.0.26100", + "cpu": "INTEL(R) XEON(R) PLATINUM 8573C", + "logicalCpus": 4, + "memoryBytes": 17174360064 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-bounded-list-rendering-and-frame-budget", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "da835efb4571b600465920b78ea946c980bc8f1a", + "run": "35386904258", + "platform": "win32" + }, + "installerSha256": "5ee93418900dd3b7c0333e27676913afafd786d19fc65d64d348afe741d1a288", + "windowsDriverMode": "Microsoft WebView2 attach (app-specific disposable runner policy)", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 662.3261999999995 + }, + { + "command": "throw", + "uiObservationMs": 659.7552999999971 + }, + { + "command": "promises", + "uiObservationMs": 749.116399999999 + }, + { + "command": "recurse", + "uiObservationMs": 715.0553 + }, + { + "command": "allocate", + "uiObservationMs": 739.5305000000008 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 344, + "p95FrameGapMs": 15.700000000000728, + "maxFrameGapMs": 15.799999999999272, + "refreshMs": [ + 310.21259999999893, + 313.2037000000055, + 329.2927999999956, + 315.71719999999914, + 319.9893000000011 + ], + "p95FrameGapBudgetMs": 100 + }, + "status": "passed" +} diff --git a/docs/addons/evidence/native-ui-windows-updates-312321e3.json b/docs/addons/evidence/native-ui-windows-updates-312321e3.json new file mode 100644 index 00000000..a28ae52f --- /dev/null +++ b/docs/addons/evidence/native-ui-windows-updates-312321e3.json @@ -0,0 +1,134 @@ +{ + "commit": "1fb4ed3986596d4150ce7608573fa9df429faf58", + "platform": "win32", + "hardware": { + "osRelease": "10.0.26100", + "cpu": "AMD EPYC 7763 64-Core Processor ", + "logicalCpus": 4, + "memoryBytes": 17174360064 + }, + "checks": [ + "01-settings", + "01-no-plugin-hosts-at-clean-start", + "01-official-updater-without-plugins", + "01-keyboard-dialog-focus-and-core-themes", + "02-import-project-brief", + "02-import-issue-companion", + "02-import-fault-isolation", + "03-enabled-plugins-remain-lazy", + "03-disable-enable", + "03-issue-configuration", + "04-project-brief-native-git", + "05-project-brief-real-draft", + "06-issue-companion-native-https", + "06-host-credential-settings-and-explicit-fallback", + "07-paired-remote-cannot-invoke-or-subscribe-to-plugins", + "07-official-updater-while-plugins-paused", + "07-no-auto-submit", + "08-hostile-block-core-interactivity", + "08-hostile-throw-core-interactivity", + "08-hostile-promises-core-interactivity", + "08-hostile-recurse-core-interactivity", + "08-hostile-allocate-core-interactivity", + "08-bounded-list-rendering-and-frame-budget", + "09-native-update-review-and-matching-data-rollback", + "09-paused-restart-preserves-installations-and-settings", + "09-classic-interface-keeps-plugin-panels-and-core-terminal", + "10-remove-packages-keeps-core-usable", + "11-corrupt-plugin-registry-does-not-block-core-startup" + ], + "failedChecks": [], + "seams": [ + "Synthetic loopback account API; no real account or credentials", + "Native file chooser selection supplies package fixture path; all addon IPC remains native" + ], + "installerBuild": { + "commit": "312321e3e72985f637b02b0621526e7d6a24fd69", + "run": "35392514725", + "platform": "win32" + }, + "installerSha256": "e6342ccb5010295443ff4a2d262b202013000ffa2cb2f8423059d50d22cad3b9", + "windowsDriverMode": "Microsoft WebView2 attach (app-specific disposable runner policy)", + "officialUpdater": [ + { + "phase": "clean", + "available": false, + "version": null + }, + { + "phase": "paused", + "available": false, + "version": null + } + ], + "credentials": { + "maskedInput": true, + "noSecretInInventoryOrSettings": true, + "mode": "native Windows credential store", + "removalAndFileRedaction": true + }, + "remoteBoundary": { + "transport": "paired loopback HTTP/WebSocket against stock desktop", + "approval": "explicit desktop command", + "coreRpc": true, + "addonRpcDenied": true, + "coreEvents": true, + "addonEventsDenied": true + }, + "hostileWorkloads": [ + { + "command": "block", + "uiObservationMs": 703.7139999999999 + }, + { + "command": "throw", + "uiObservationMs": 688.3133999999991 + }, + { + "command": "promises", + "uiObservationMs": 769.3055000000022 + }, + { + "command": "recurse", + "uiObservationMs": 747.0486000000019 + }, + { + "command": "allocate", + "uiObservationMs": 778.359199999999 + } + ], + "uiRendering": { + "workload": "500 visible-model paths, five real Git refreshes while typing", + "renderedRows": 14, + "frameSamples": 353, + "p95FrameGapMs": 15.700000000004366, + "maxFrameGapMs": 15.700000000004366, + "refreshMs": [ + 307.21540000000095, + 332.2992000000013, + 326.76870000000054, + 321.8341999999975, + 327.01780000000144 + ], + "p95FrameGapBudgetMs": 100 + }, + "nativeUpdates": { + "sameAccess": true, + "activeHost": true, + "expandedAccessReview": true, + "cancellation": true, + "rollbackMatchingPrivateData": true, + "developerModeOff": true + }, + "corePaneRestoration": { + "preserved": [ + "Files", + "Changes", + "Review" + ], + "paused": true, + "removed": true, + "noEmptyAccessorySpace": true + }, + "status": "passed" +} diff --git a/docs/addons/evidence/packaged-linux-3045.json b/docs/addons/evidence/packaged-linux-3045.json new file mode 100644 index 00000000..23eea00b --- /dev/null +++ b/docs/addons/evidence/packaged-linux-3045.json @@ -0,0 +1,67 @@ +{ + "platform": "linux", + "os": "6.8.0-1064-azure", + "cpu": "AMD EPYC 7763 64-Core Processor", + "logicalCpus": 4, + "hostSha256": "dd6310532390ff0f3b4312db2615be3c4fa933e955bd7330f1db050241b20d3f", + "bundles": [ + { + "format": ".deb", + "packagedSha256": "dd6310532390ff0f3b4312db2615be3c4fa933e955bd7330f1db050241b20d3f", + "provenance": "exact-sha256", + "hostPath": "usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1007.0116740000001 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 4.0497770000001765 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 3.105837999999949 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1003.5204709999998 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 3.6716019999998935 + } + ] + }, + { + "format": ".AppImage", + "packagedSha256": "7e74ae728316e1403c33fd0690f20ba21483db2a6cdc290f506678d0adcf8c07", + "provenance": "elf-sections-and-dynamic-metadata", + "hostPath": "squashfs-root/usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1003.984684 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 4.069824000000153 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 3.232184000000416 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1003.9180489999999 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 3.816092000000026 + } + ] + } + ], + "workflowHead": "3045f47ffc3f20e34951922b72e06904ce231c78", + "workflowRun": "https://github.com/Zeus-Deus/codemux/actions/runs/35376157749" +} diff --git a/docs/addons/evidence/packaged-linux-81b4e666.json b/docs/addons/evidence/packaged-linux-81b4e666.json new file mode 100644 index 00000000..140de59e --- /dev/null +++ b/docs/addons/evidence/packaged-linux-81b4e666.json @@ -0,0 +1,93 @@ +{ + "platform": "linux", + "os": "6.8.0-1064-azure", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "logicalCpus": 4, + "hostSha256": "25f052bff5ffd35062fad787b3064a80e2e6885919232931ff98986bdf96a618", + "bundles": [ + { + "format": ".deb", + "packagedSha256": "25f052bff5ffd35062fad787b3064a80e2e6885919232931ff98986bdf96a618", + "provenance": "exact-sha256", + "hostPath": "usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1006.9543240000021 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 4.385537000001932 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 3.2871089999971446 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1003.3969630000029 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 3.9254470000014408 + } + ] + }, + { + "format": ".AppImage", + "packagedSha256": "9ea2489d02a9d9603afc2b41ef37d6adb49088ca5a2622e1d6c0fe54df16df2b", + "provenance": "elf-sections-and-dynamic-metadata", + "hostPath": "squashfs-root/usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1004.2280780000001 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 4.508302000002004 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 3.3925860000017565 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1003.7832590000035 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 4.046247999998741 + } + ] + }, + { + "format": ".rpm", + "packagedSha256": "25f052bff5ffd35062fad787b3064a80e2e6885919232931ff98986bdf96a618", + "provenance": "exact-sha256", + "hostPath": "usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1004.2970229999992 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 4.479731999999785 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 3.48878600000171 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1003.3217890000014 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 4.06712300000072 + } + ] + } + ] +} diff --git a/docs/addons/evidence/packaged-linux.json b/docs/addons/evidence/packaged-linux.json new file mode 100644 index 00000000..8d98b3eb --- /dev/null +++ b/docs/addons/evidence/packaged-linux.json @@ -0,0 +1,65 @@ +{ + "platform": "linux", + "os": "6.8.0-1064-azure", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "logicalCpus": 4, + "hostSha256": "596d0feca78ac8d29f757023b025cf6834393878c43837919cc9d5bb3a8cb24a", + "bundles": [ + { + "format": ".deb", + "packagedSha256": "596d0feca78ac8d29f757023b025cf6834393878c43837919cc9d5bb3a8cb24a", + "provenance": "exact-sha256", + "hostPath": "usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1005.3854839999999 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 3.4434689999998227 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 2.564151999999922 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1002.983788 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 3.0863899999999376 + } + ] + }, + { + "format": ".AppImage", + "packagedSha256": "99deefd50560c56c965e1c431ee3b10dc891d50a4dca1bc7632ccbc9d7221ac3", + "provenance": "elf-sections-and-dynamic-metadata", + "hostPath": "squashfs-root/usr/lib/codemux/binaries/codemux-addon-host-linux-x64", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1003.4534299999996 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 3.559374000000389 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 2.632434000000103 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1003.2999260000006 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 3.2605430000003253 + } + ] + } + ] +} diff --git a/docs/addons/evidence/packaged-windows-3045.json b/docs/addons/evidence/packaged-windows-3045.json new file mode 100644 index 00000000..a6c8e0df --- /dev/null +++ b/docs/addons/evidence/packaged-windows-3045.json @@ -0,0 +1,39 @@ +{ + "platform": "win32", + "os": "10.0.26100", + "cpu": "AMD EPYC 9V74 80-Core Processor ", + "logicalCpus": 4, + "hostSha256": "1e55035d1d55e06ac1ec06a8df1fd3b36e524de072401cd2391e08c983732c21", + "bundles": [ + { + "format": ".exe", + "packagedSha256": "1e55035d1d55e06ac1ec06a8df1fd3b36e524de072401cd2391e08c983732c21", + "provenance": "exact-sha256", + "hostPath": "binaries\\codemux-addon-host-windows-x64.exe", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1014.7129999999997 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 12.016399999998612 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 10.23279999999977 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1010.6166999999987 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 11.13169999999991 + } + ] + } + ], + "workflowHead": "3045f47ffc3f20e34951922b72e06904ce231c78", + "workflowRun": "https://github.com/Zeus-Deus/codemux/actions/runs/35376157749" +} diff --git a/docs/addons/evidence/packaged-windows-312321e3.json b/docs/addons/evidence/packaged-windows-312321e3.json new file mode 100644 index 00000000..5a4dd22a --- /dev/null +++ b/docs/addons/evidence/packaged-windows-312321e3.json @@ -0,0 +1,37 @@ +{ + "platform": "win32", + "os": "10.0.26100", + "cpu": "INTEL(R) XEON(R) PLATINUM 8573C", + "logicalCpus": 4, + "hostSha256": "ed059c176ef77d8b859de5d9c076bf2f766b336f60d12dda9fa57ecf5b7760d2", + "bundles": [ + { + "format": ".exe", + "packagedSha256": "ed059c176ef77d8b859de5d9c076bf2f766b336f60d12dda9fa57ecf5b7760d2", + "provenance": "exact-sha256", + "hostPath": "binaries\\codemux-addon-host-windows-x64.exe", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1017.4812000000002 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 13.421500000000378 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 11.864400000000387 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1012.6272000000008 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 14.814099999999598 + } + ] + } + ] +} diff --git a/docs/addons/evidence/packaged-windows-81b4e666.json b/docs/addons/evidence/packaged-windows-81b4e666.json new file mode 100644 index 00000000..887eb0f5 --- /dev/null +++ b/docs/addons/evidence/packaged-windows-81b4e666.json @@ -0,0 +1,37 @@ +{ + "platform": "win32", + "os": "10.0.26100", + "cpu": "AMD EPYC 7763 64-Core Processor ", + "logicalCpus": 4, + "hostSha256": "7a5cc2c020d80d2150a13f0bd0a4aeac94b1189337a276119363e099d0fc464e", + "bundles": [ + { + "format": ".exe", + "packagedSha256": "7a5cc2c020d80d2150a13f0bd0a4aeac94b1189337a276119363e099d0fc464e", + "provenance": "exact-sha256", + "hostPath": "binaries\\codemux-addon-host-windows-x64.exe", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1018.5375000000022 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 13.21219999999812 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 11.929599999999482 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1011.4567999999999 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 11.7345000000023 + } + ] + } + ] +} diff --git a/docs/addons/evidence/packaged-windows.json b/docs/addons/evidence/packaged-windows.json new file mode 100644 index 00000000..eef4dab6 --- /dev/null +++ b/docs/addons/evidence/packaged-windows.json @@ -0,0 +1,37 @@ +{ + "platform": "win32", + "os": "10.0.26100", + "cpu": "AMD EPYC 7763 64-Core Processor ", + "logicalCpus": 4, + "hostSha256": "3bb02ce44d035459a3c6f6b4cadad112007d9f403ae9018380712b500363dd9a", + "bundles": [ + { + "format": ".exe", + "packagedSha256": "3bb02ce44d035459a3c6f6b4cadad112007d9f403ae9018380712b500363dd9a", + "provenance": "exact-sha256", + "hostPath": "binaries\\codemux-addon-host-windows-x64.exe", + "faults": [ + { + "workload": "while(true){}", + "elapsedMs": 1016.4846999999991 + }, + { + "workload": "function f(){f()} f()", + "elapsedMs": 13.874699999998484 + }, + { + "workload": "new ArrayBuffer(128*1024*1024)", + "elapsedMs": 13.774599999998827 + }, + { + "workload": "Promise.resolve().then(function loop(){Promise.resolve().then(loop)})", + "elapsedMs": 1015.4512999999988 + }, + { + "workload": "throw Error('fixture')", + "elapsedMs": 13.554199999998673 + } + ] + } + ] +} diff --git a/docs/addons/evidence/settings-chat-gui-off.png b/docs/addons/evidence/settings-chat-gui-off.png new file mode 100644 index 00000000..74aafadc Binary files /dev/null and b/docs/addons/evidence/settings-chat-gui-off.png differ diff --git a/docs/addons/evidence/settings-small-dark.png b/docs/addons/evidence/settings-small-dark.png new file mode 100644 index 00000000..b3be9811 Binary files /dev/null and b/docs/addons/evidence/settings-small-dark.png differ diff --git a/docs/addons/evidence/settings-small-light.png b/docs/addons/evidence/settings-small-light.png new file mode 100644 index 00000000..93b7bb5b Binary files /dev/null and b/docs/addons/evidence/settings-small-light.png differ diff --git a/docs/addons/evidence/ui-validation-linux.json b/docs/addons/evidence/ui-validation-linux.json new file mode 100644 index 00000000..024b5b47 --- /dev/null +++ b/docs/addons/evidence/ui-validation-linux.json @@ -0,0 +1,61 @@ +{ + "scope": "native tree validation only; excludes desktop rendering", + "profile": "release", + "cpu": "AMD Ryzen 5 7600 6-Core Processor", + "os": "Linux-7.2.5-3-omarchy-x86_64-with-glibc2.44", + "beforeRevision": "abbef66f", + "before": { + "mutations": 1000, + "nodes": 1999, + "samplesMs": [ + 309.27556899999996, + 325.363121, + 317.45625, + 335.17134799999997, + 367.932893, + 305.527473, + 302.401511, + 318.32661099999996, + 324.188074, + 317.057896, + 327.036234, + 317.15141600000004, + 311.552704, + 310.49523, + 340.684726, + 322.706556, + 308.452561, + 317.536766, + 354.593086, + 498.97656 + ], + "treeBytes": 222790 + }, + "after": { + "mutations": 1000, + "nodes": 1999, + "samplesMs": [ + 3.5989720000000003, + 3.3492819999999996, + 2.662969, + 2.6882989999999998, + 2.96279, + 2.650559, + 3.374612, + 2.815029, + 2.8049199999999996, + 2.489119, + 2.5001189999999998, + 3.0858600000000003, + 2.9734000000000003, + 2.88762, + 2.519508, + 2.740759, + 2.604579, + 3.894684, + 3.135761, + 2.602768 + ], + "treeBytes": 222790 + } +} diff --git a/docs/addons/evidence/ui/composer-accessory.png b/docs/addons/evidence/ui/composer-accessory.png new file mode 100644 index 00000000..13cf21df Binary files /dev/null and b/docs/addons/evidence/ui/composer-accessory.png differ diff --git a/docs/addons/evidence/ui/composer-addons-menu.png b/docs/addons/evidence/ui/composer-addons-menu.png new file mode 100644 index 00000000..fef4d7b3 Binary files /dev/null and b/docs/addons/evidence/ui/composer-addons-menu.png differ diff --git a/docs/addons/evidence/ui/composer-reason-after.png b/docs/addons/evidence/ui/composer-reason-after.png new file mode 100644 index 00000000..89aac3d3 Binary files /dev/null and b/docs/addons/evidence/ui/composer-reason-after.png differ diff --git a/docs/addons/evidence/ui/composer-reason-before.png b/docs/addons/evidence/ui/composer-reason-before.png new file mode 100644 index 00000000..7276b7b4 Binary files /dev/null and b/docs/addons/evidence/ui/composer-reason-before.png differ diff --git a/docs/addons/evidence/ui/credentials-after.png b/docs/addons/evidence/ui/credentials-after.png new file mode 100644 index 00000000..0e33af5d Binary files /dev/null and b/docs/addons/evidence/ui/credentials-after.png differ diff --git a/docs/addons/evidence/ui/credentials-before.png b/docs/addons/evidence/ui/credentials-before.png new file mode 100644 index 00000000..7036f391 Binary files /dev/null and b/docs/addons/evidence/ui/credentials-before.png differ diff --git a/docs/addons/evidence/ui/install-review-after.png b/docs/addons/evidence/ui/install-review-after.png new file mode 100644 index 00000000..0c96df95 Binary files /dev/null and b/docs/addons/evidence/ui/install-review-after.png differ diff --git a/docs/addons/evidence/ui/install-review-before.png b/docs/addons/evidence/ui/install-review-before.png new file mode 100644 index 00000000..816cf3a0 Binary files /dev/null and b/docs/addons/evidence/ui/install-review-before.png differ diff --git a/docs/addons/evidence/ui/refused-event-after.png b/docs/addons/evidence/ui/refused-event-after.png new file mode 100644 index 00000000..9bcd0bbf Binary files /dev/null and b/docs/addons/evidence/ui/refused-event-after.png differ diff --git a/docs/addons/evidence/ui/refused-event-before.png b/docs/addons/evidence/ui/refused-event-before.png new file mode 100644 index 00000000..21d2d0b1 Binary files /dev/null and b/docs/addons/evidence/ui/refused-event-before.png differ diff --git a/docs/addons/evidence/ui/registry-error-after.png b/docs/addons/evidence/ui/registry-error-after.png new file mode 100644 index 00000000..bd01feb8 Binary files /dev/null and b/docs/addons/evidence/ui/registry-error-after.png differ diff --git a/docs/addons/evidence/ui/registry-error-before.png b/docs/addons/evidence/ui/registry-error-before.png new file mode 100644 index 00000000..29d08c7a Binary files /dev/null and b/docs/addons/evidence/ui/registry-error-before.png differ diff --git a/docs/addons/evidence/ui/update-review-after.png b/docs/addons/evidence/ui/update-review-after.png new file mode 100644 index 00000000..a1cd32f5 Binary files /dev/null and b/docs/addons/evidence/ui/update-review-after.png differ diff --git a/docs/addons/evidence/ui/update-review-before.png b/docs/addons/evidence/ui/update-review-before.png new file mode 100644 index 00000000..e390920e Binary files /dev/null and b/docs/addons/evidence/ui/update-review-before.png differ diff --git a/examples/addons/issue-companion/README.md b/examples/addons/issue-companion/README.md index 2d165259..0c7c05a9 100644 --- a/examples/addons/issue-companion/README.md +++ b/examples/addons/issue-companion/README.md @@ -6,4 +6,4 @@ Build independently with the public SDK: `npm install`, `npm run build`, `npm ru Before SDK publication, install the packed SDK and CLI with `npm install --no-save --package-lock=false /path/to/codemux-plugin-sdk-1.0.0.tgz /path/to/codemux-plugin-cli-1.0.0.tgz`. Then run the same commands. Neither package imports app internals. -Configure a GitHub repository owner/name in Settings. The optional bearer token stays in the host credential store. Issue loading sends only the configured repository path to api.github.com; it never uploads workspace data. Open in browser and Add to draft require separate explicit clicks. Public unauthenticated requests are supported, subject to GitHub rate limits. +Configure a GitHub repository owner/name in Settings. The optional bearer token stays in the host credential store. Issue loading sends only the configured repository path to api.github.com; it never uploads workspace data. Open in browser and Add to draft require separate explicit clicks. Public unauthenticated requests are supported, subject to GitHub rate limits. The panel lists the 20 most recent open issues. A rate-limited response (429, or 403 with an exhausted quota or `retry-after`) says when to try again; any other 403 means the token cannot read the repository. diff --git a/examples/addons/issue-companion/src/index.tsx b/examples/addons/issue-companion/src/index.tsx index b5249622..20b9b767 100644 --- a/examples/addons/issue-companion/src/index.tsx +++ b/examples/addons/issue-companion/src/index.tsx @@ -7,12 +7,26 @@ import { Select, useEffect, useState, + PluginError, type ViewProps, type ContextHandle, } from "@codemux/plugin-sdk"; type Issue = { number: number; title: string; html_url: string }; +const describe = (error: unknown) => + error instanceof Error ? error.message : "Issue Companion is unavailable"; +// GitHub reports when a limit resets in seconds, either relative or absolute. +function retryHint(headers: Record) { + const retry = Number(headers["retry-after"]) * 1000; + const reset = Number(headers["x-ratelimit-reset"]) * 1000 - Date.now(); + const wait = retry > 0 ? retry : reset > 0 ? reset : 0; + if (!wait) return "Wait before refreshing"; + const minutes = Math.ceil(wait / 60000); + return `Try again in about ${minutes} minute${minutes === 1 ? "" : "s"}`; +} export default definePlugin({ activate(ctx) { + // Notifications are limited to three per minute; a rejected one is dropped. + const notify = (text: string) => ctx.ui.notify(text).catch(() => {}); async function fetchIssues(context: ContextHandle): Promise { const settings = await ctx.settings.get(); const owner = settings.owner, @@ -26,23 +40,47 @@ export default definePlugin({ throw new Error( "Configure a repository owner and name in Add-ons settings.", ); - const response = await ctx.http.fetch(context, { - origin: "https://api.github.com", - path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=open&per_page=50`, - method: "GET", - headers: { Accept: "application/vnd.github+json" }, - }); + let response; + try { + // Twenty issues keep typical responses well below the 512 KiB limit. + response = await ctx.http.fetch(context, { + origin: "https://api.github.com", + path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=open&per_page=20`, + method: "GET", + headers: { Accept: "application/vnd.github+json" }, + }); + } catch (error) { + if ( + error instanceof PluginError && + error.code === "CREDENTIAL_REQUIRED" + ) + throw new Error( + "The saved GitHub token cannot be read. Unlock the system keyring or enter the token again in Add-ons settings.", + ); + if (error instanceof PluginError && error.code === "RESOURCE_LIMIT") + throw new Error( + `Issues could not be loaded within add-on limits (${error.message}). Try again shortly.`, + ); + throw error; + } if (response.status === 401) throw new Error( "GitHub did not accept the configured credential. Update it in Add-ons settings.", ); + // GitHub signals primary and secondary rate limits with 429, or with 403 + // plus an exhausted quota or a retry-after header. if ( response.status === 429 || (response.status === 403 && - response.headers["x-ratelimit-remaining"] === "0") + (response.headers["x-ratelimit-remaining"] === "0" || + response.headers["retry-after"] !== undefined)) ) throw new Error( - "GitHub rate limit reached. Wait before refreshing or configure a token.", + `GitHub rate limit reached. ${retryHint(response.headers)}, or configure a token for a higher limit.`, + ); + if (response.status === 403) + throw new Error( + "GitHub denied access to this repository. Check that the configured token may read it, or remove the token to use public access.", ); if (response.status === 404) throw new Error( @@ -67,7 +105,7 @@ export default definePlugin({ typeof item.html_url === "string" && item.html_url.startsWith("https://github.com/"), ) - .slice(0, 50); + .slice(0, 20); } function Issues({ context }: ViewProps) { const [issues, setIssues] = useState([]), @@ -84,7 +122,7 @@ export default definePlugin({ } }) .catch((e) => { - if (live) setError(e.message); + if (live) setError(describe(e)); }) .finally(() => { if (live) setLoading(false); @@ -172,13 +210,21 @@ export default definePlugin({ ); } - ctx.commands.register("open", (context) => - ctx.panels.open("issues", context), - ); + ctx.commands.register("open", async (context) => { + try { + await ctx.panels.open("issues", context); + } catch (error) { + await notify(describe(error)); + } + }); ctx.panels.register("issues", (props) => ); - ctx.composerActions.register("browse", (context) => - ctx.composerViews.open("issues", context), - ); + ctx.composerActions.register("browse", async (context) => { + try { + await ctx.composerViews.open("issues", context); + } catch (error) { + await notify(describe(error)); + } + }); ctx.composerViews.register("issues", (props) => ); }, }); diff --git a/examples/addons/project-brief/README.md b/examples/addons/project-brief/README.md index 8bb1038d..8f5c5caa 100644 --- a/examples/addons/project-brief/README.md +++ b/examples/addons/project-brief/README.md @@ -6,4 +6,6 @@ Build independently with the public SDK: `npm install`, `npm run build`, `npm ru Before SDK publication, install the packed SDK and CLI with `npm install --no-save --package-lock=false /path/to/codemux-plugin-sdk-1.0.0.tgz /path/to/codemux-plugin-cli-1.0.0.tgz`. Then run the same commands. Neither package imports app internals. -Open the panel from the right-panel + menu. Refresh reads local project metadata and a bounded Git summary. Add to draft preserves existing input; a panel needs one unambiguous mounted composer. The composer action targets its own draft. Include changed filenames is configured in Settings. Refresh stores a private last-used preference; uninstall removes it unless Keep data is selected. +Open the panel from the right-panel + menu. Refresh reads local project metadata and a bounded Git summary. Add to draft preserves existing input; a panel needs one unambiguous mounted composer. The composer action targets its own draft. + +The **Include changed filenames** setting (Settings → Add-ons, on by default) decides whether Add to draft and the composer action list up to 20 changed paths in the brief. The **Show changed paths** checkbox is a private preference in the plugin's own storage and only controls the panel's path list. Both survive restarts; uninstalling removes them unless Keep data is selected. diff --git a/examples/addons/project-brief/src/index.tsx b/examples/addons/project-brief/src/index.tsx index 41b6cc02..54cc3670 100644 --- a/examples/addons/project-brief/src/index.tsx +++ b/examples/addons/project-brief/src/index.tsx @@ -19,6 +19,8 @@ const message = (error: unknown) => error instanceof Error ? error.message : "Project information is unavailable"; export default definePlugin({ activate(ctx) { + // Notifications are limited to three per minute; a rejected one is dropped. + const notify = (text: string) => ctx.ui.notify(text).catch(() => {}); async function load(context: ContextHandle) { const workspace = await ctx.workspace.current(context); if (!workspace) @@ -108,11 +110,15 @@ export default definePlugin({ checked={expanded} onChange={async (event) => { setExpanded(event.value === true); - await ctx.storage.set( - { scope: "global" }, - "show-paths", - event.value === true, - ); + try { + await ctx.storage.set( + { scope: "global" }, + "show-paths", + event.value === true, + ); + } catch (e) { + setError(`Preference not saved: ${message(e)}`); + } }} /> {expanded && @@ -139,11 +145,6 @@ export default definePlugin({ setError(""); try { setData(await load(event.context)); - await ctx.storage.set( - { scope: "workspace", context: event.context }, - "last-refreshed", - Date.now(), - ); } catch (e) { setError(message(e)); } finally { @@ -171,14 +172,18 @@ export default definePlugin({ ); } ctx.panels.register("brief", (props) => ); - ctx.commands.register("open", (context) => - ctx.panels.open("brief", context), - ); + ctx.commands.register("open", async (context) => { + try { + await ctx.panels.open("brief", context); + } catch (error) { + await notify(message(error)); + } + }); ctx.composerActions.register("insert", async (context) => { try { await insert(context); } catch (error) { - await ctx.ui.notify(message(error)); + await notify(message(error)); } }); }, diff --git a/packages/plugin-cli/README.md b/packages/plugin-cli/README.md index cd9e9e88..495914ea 100644 --- a/packages/plugin-cli/README.md +++ b/packages/plugin-cli/README.md @@ -3,15 +3,31 @@ Run `codemux-plugin init my-plugin`, then in that directory run `npm install`, `npm run build`, `npm run check`, and `npm run pack`. Before public npm publication, install packed local SDK/CLI tarballs in place of the exact 1.0.0 dependencies. +Requires Node.js 20 or later. -- `build`: bundles TypeScript/Preact and the SDK adapter into `plugin.js`. -- `check`: validates declared metadata and required distribution files without - evaluating plugin code. The desktop repeats authoritative native validation. -- `pack`: writes a deterministic gzip/tar `.cmxaddon` with only permitted files - and prints its SHA-256. Install from Settings → Add-ons once available. -- `dev`: watches source and manifest changes, rebuilds and repacks. Desktop reload - requires explicit Developer mode and package selection. It does not grant new - permissions or silently enable a package. +- `init [--id publisher.name]`: writes a starter with pinned + dependencies, strict TypeScript, a `workspace.read` sample permission, a panel + with a command that opens it, the MIT license text and a `.gitignore`. Without + `--id` the ID is `example.`; replace the `example` publisher + before publishing, because the ID cannot change once users install it. +- `build [--sourcemap]`: bundles `src/index.tsx`, whose default export is the + `definePlugin` result, with the SDK adapter into one `plugin.js`. Dependencies + resolve through `module` or `main` when they have no exports map, and + `process.env.NODE_ENV` is `"production"`. `--sourcemap` also writes `source.map` + for developer diagnostics; a build without it removes a previous one. +- `check`: validates the manifest with the same rules as the desktop, and the + required distribution files, without evaluating plugin code. Versions are + SemVer without a `v`; `api` uses comma-separated comparators such as `^1.0.0` + or `>=1.0.0, <2.0.0`. The desktop repeats authoritative native validation. +- `pack [--out ]`: writes a deterministic gzip/tar `.cmxaddon` with only + permitted files, by default `-.cmxaddon`, and prints its SHA-256. + Install it from Settings → Add-ons. +- `dev [--sourcemap] [--out ]`: rebuilds and repacks on every change to + `src/`, `manifest.json`, `README.md`, `LICENSE` or `NOTICE`, and keeps watching + after a failed build. The package always goes to the same path, by default + `dist/.cmxaddon`, replaced atomically; select that file in the app's + Developer mode. Reloading requires explicit Developer mode and package + selection. It does not grant new permissions or silently enable a package. Only the author machine needs Node or a compiler. The official app never runs npm, dependency installation, package scripts, or a third-party native binary. diff --git a/packages/plugin-cli/package-lock.json b/packages/plugin-cli/package-lock.json index 0fd00cd5..4ac616fd 100644 --- a/packages/plugin-cli/package-lock.json +++ b/packages/plugin-cli/package-lock.json @@ -10,12 +10,13 @@ "license": "MIT", "dependencies": { "ajv": "8.17.1", - "ajv-formats": "3.0.1", - "esbuild": "0.28.2", - "semver": "7.7.2" + "esbuild": "0.28.2" }, "bin": { "codemux-plugin": "src/cli.mjs" + }, + "engines": { + "node": ">=20" } }, "node_modules/@esbuild/aix-ppc64": { @@ -450,23 +451,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -544,18 +528,6 @@ "engines": { "node": ">=0.10.0" } - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } } } } diff --git a/packages/plugin-cli/package.json b/packages/plugin-cli/package.json index 8200ee9f..4982b747 100644 --- a/packages/plugin-cli/package.json +++ b/packages/plugin-cli/package.json @@ -3,6 +3,19 @@ "version": "1.0.0", "type": "module", "license": "MIT", + "description": "Build, check, pack and watch CodeMux feature plugins.", + "repository": { + "type": "git", + "url": "git+https://github.com/Zeus-Deus/codemux.git", + "directory": "packages/plugin-cli" + }, + "homepage": "https://github.com/Zeus-Deus/codemux/tree/main/packages/plugin-cli#readme", + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=20" + }, "bin": { "codemux-plugin": "./src/cli.mjs" }, @@ -14,8 +27,6 @@ ], "dependencies": { "esbuild": "0.28.2", - "ajv": "8.17.1", - "ajv-formats": "3.0.1", - "semver": "7.7.2" + "ajv": "8.17.1" } } diff --git a/packages/plugin-cli/schema/manifest.json b/packages/plugin-cli/schema/manifest.json index 0fa9b66a..6511c713 100644 --- a/packages/plugin-cli/schema/manifest.json +++ b/packages/plugin-cli/schema/manifest.json @@ -35,60 +35,85 @@ "type": "array", "items": { "$ref": "#/definitions/Credential" - } + }, + "maxItems": 20 }, "description": { - "type": "string" + "type": "string", + "maxLength": 240, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "entry": { - "type": "string" + "type": "string", + "enum": [ + "plugin.js" + ] }, "format": { - "type": "string" + "type": "string", + "enum": [ + "codemux.feature-plugin" + ] }, "http": { "type": "array", "items": { "$ref": "#/definitions/HttpGrant" - } + }, + "maxItems": 20 }, "id": { - "type": "string" + "type": "string", + "pattern": "^(?!(?:con|prn|aux|nul|com[0-9]|lpt[0-9])\\.)[a-z][a-z0-9-]{1,39}\\.(?!(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$)[a-z][a-z0-9-]{1,39}$" }, "license": { - "type": "string" + "type": "string", + "maxLength": 100, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "manifestVersion": { "type": "integer", "format": "uint32", - "minimum": 0.0 + "maximum": 1.0, + "minimum": 1.0 }, "name": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "permissions": { "type": "array", "items": { "$ref": "#/definitions/Permission" - } + }, + "uniqueItems": true }, "platforms": { "type": "array", "items": { "$ref": "#/definitions/Platform" - } + }, + "minItems": 1, + "uniqueItems": true }, "repository": { - "type": "string" + "type": "string", + "pattern": "^https://" }, "settings": { "type": "array", "items": { "$ref": "#/definitions/Setting" - } + }, + "maxItems": 50 }, "version": { - "type": "string" + "type": "string", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\\+[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*)?$" } }, "additionalProperties": false, @@ -101,10 +126,14 @@ ], "properties": { "name": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "url": { - "type": "string" + "type": "string", + "pattern": "^https://" } }, "additionalProperties": false @@ -118,13 +147,17 @@ ], "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "requiresWorkspace": { "type": "boolean" }, "title": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" } }, "additionalProperties": false @@ -142,25 +175,29 @@ "type": "array", "items": { "$ref": "#/definitions/Command" - } + }, + "maxItems": 20 }, "composerActions": { "type": "array", "items": { "$ref": "#/definitions/View" - } + }, + "maxItems": 8 }, "composerViews": { "type": "array", "items": { "$ref": "#/definitions/View" - } + }, + "maxItems": 4 }, "panels": { "type": "array", "items": { "$ref": "#/definitions/View" - } + }, + "maxItems": 8 } }, "additionalProperties": false @@ -175,13 +212,18 @@ ], "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "origin": { - "type": "string" + "type": "string", + "pattern": "^https://(?!.*\\.(?:[0-9]+|0x[0-9a-f]*)$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" }, "type": { "$ref": "#/definitions/CredentialType" @@ -213,10 +255,13 @@ "type": "array", "items": { "$ref": "#/definitions/HttpMethod" - } + }, + "minItems": 1, + "uniqueItems": true }, "origin": { - "type": "string" + "type": "string", + "pattern": "^https://(?!.*\\.(?:[0-9]+|0x[0-9a-f]*)$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" } }, "additionalProperties": false @@ -262,10 +307,14 @@ "type": "boolean" }, "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "type": { "type": "string", @@ -286,13 +335,18 @@ "properties": { "default": { "default": "", - "type": "string" + "type": "string", + "maxLength": 4096 }, "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "type": { "type": "string", @@ -319,10 +373,14 @@ "format": "int64" }, "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "max": { "type": "integer", @@ -355,10 +413,14 @@ "type": "string" }, "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "type": { "type": "string", @@ -369,8 +431,14 @@ "values": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 4096, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" + }, + "maxItems": 50, + "minItems": 1, + "uniqueItems": true } }, "additionalProperties": false @@ -386,13 +454,35 @@ ], "properties": { "icon": { - "type": "string" + "type": "string", + "enum": [ + "file-text", + "git-branch", + "github", + "list", + "check", + "info", + "settings", + "book-open", + "link", + "refresh-cw", + "plus", + "circle-alert", + "folder", + "terminal", + "code", + "search" + ] }, "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "title": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" } }, "additionalProperties": false diff --git a/packages/plugin-cli/src/bundle.mjs b/packages/plugin-cli/src/bundle.mjs new file mode 100644 index 00000000..a2e0c1d6 --- /dev/null +++ b/packages/plugin-cli/src/bundle.mjs @@ -0,0 +1,8 @@ +// esbuild options shared by `build` and the repository's native SDK test, so +// both produce the same single QuickJS IIFE. The neutral platform avoids +// browser- or Node-only package variants; `module`/`main` still resolve packages +// without an exports map, and the constant NODE_ENV removes the need for the +// absent `process` global. +export function bundleOptions({entry='./src/index.tsx',runtime='@codemux/plugin-sdk/runtime',resolveDir}) { + return {stdin:{contents:`import plugin from ${JSON.stringify(entry)}; import {register} from ${JSON.stringify(runtime)}; register(plugin);`,resolveDir,sourcefile:'entry.ts'},bundle:true,format:'iife',platform:'neutral',mainFields:['module','main'],define:{'process.env.NODE_ENV':'"production"'},target:'es2020',jsx:'automatic',jsxImportSource:'preact',plugins:[{name:'no-runtime-imports',setup(b){b.onResolve({filter:/.*/},args=>{if(args.kind==='dynamic-import')throw Error('Dynamic imports are not supported')})}}]}; +} diff --git a/packages/plugin-cli/src/cli.mjs b/packages/plugin-cli/src/cli.mjs index 271a0939..548b7dd2 100755 --- a/packages/plugin-cli/src/cli.mjs +++ b/packages/plugin-cli/src/cli.mjs @@ -1,31 +1,144 @@ #!/usr/bin/env node -import {readFile,writeFile,mkdir,stat,copyFile} from 'node:fs/promises'; -import {resolve,join} from 'node:path'; -import {fileURLToPath} from 'node:url'; +import {readFile,writeFile,mkdir,stat,rename,rm} from 'node:fs/promises'; +import {resolve,join,basename,dirname} from 'node:path'; import {gzipSync} from 'node:zlib'; import {createHash} from 'node:crypto'; import {watch} from 'node:fs'; import {build as bundle} from 'esbuild'; -import {validate} from './validate.mjs'; -const [command='help',directory='.']=process.argv.slice(2); -const root=resolve(directory); -async function manifest(){const bytes=await readFile(join(root,'manifest.json'));if(bytes.length>65536)throw Error('Manifest too large');return validate(JSON.parse(bytes));} +import {parse,pluginId,validate} from './validate.mjs'; +import {bundleOptions} from './bundle.mjs'; +const usage='codemux-plugin init [--id publisher.name] | build [directory] [--sourcemap] | check [directory] | pack [directory] [--out ] | dev [directory] [--sourcemap] [--out ]'; +const options={},positional=[]; +async function manifest(){const bytes=await readFile(join(root,'manifest.json'));if(bytes.length>65536)throw Error('Manifest too large');return validate(parse(bytes));} +// Write through a temporary sibling so a watching desktop never reads a torn package. +async function writeAtomic(path,bytes){await mkdir(dirname(path),{recursive:true});const temporary=join(dirname(path),'.'+basename(path)+'.'+process.pid+'.tmp');await writeFile(temporary,bytes);for(let attempt=0;;attempt++){try{return await rename(temporary,path)}catch(e){if(attempt>=5||!['EPERM','EBUSY','EACCES'].includes(e.code)){await rm(temporary,{force:true});throw e}await new Promise(r=>setTimeout(r,50))}}} +// The entry point is always src/index.tsx; `--sourcemap` also writes source.map. async function build(){ await manifest(); - await bundle({stdin:{contents:"import plugin from './src/index.tsx'; import {register} from '@codemux/plugin-sdk/runtime'; register(plugin);",resolveDir:root,sourcefile:'entry.ts'},bundle:true,format:'iife',platform:'neutral',target:'es2020',jsx:'automatic',jsxImportSource:'preact',outfile:join(root,'plugin.js'),metafile:true,plugins:[{name:'no-runtime-imports',setup(b){b.onResolve({filter:/.*/},args=>{if(args.kind==='dynamic-import')throw Error('Dynamic imports are not supported')})}}]}); + const result=await bundle({...bundleOptions({resolveDir:root}),outfile:join(root,'plugin.js'),sourcemap:options.sourcemap?'external':false,write:false}); + for(const file of result.outputFiles)await writeAtomic(join(root,file.path.endsWith('.map')?'source.map':'plugin.js'),file.contents); + if(!options.sourcemap)await rm(join(root,'source.map'),{force:true}); await check(); } -async function check(){const m=await manifest();for(const name of ['plugin.js','README.md','LICENSE']){const info=await stat(join(root,name));if(!info.isFile()||info.size>(name==='plugin.js'?5*1024*1024:1024*1024))throw Error('Invalid '+name);}return m;} +async function check(){const m=await manifest();for(const name of ['plugin.js','README.md','LICENSE']){const info=await stat(join(root,name)).catch(e=>{throw e.code==='ENOENT'?Error(`Missing ${name}${name==='plugin.js'?'; run codemux-plugin build first':''}`):e});if(!info.isFile()||info.size>(name==='plugin.js'?5*1024*1024:1024*1024))throw Error('Invalid '+name);}return m;} function tarFile(name,bytes){ const header=Buffer.alloc(512);header.write(name,0,100,'utf8');header.write('0000600\0',100);header.write('0000000\0',108);header.write('0000000\0',116);header.write(bytes.length.toString(8).padStart(11,'0')+'\0',124);header.write('00000000000\0',136);header.fill(32,148,156);header[156]=48;header.write('ustar\0',257);header.write('00',263);header.write([...header].reduce((a,b)=>a+b,0).toString(8).padStart(6,'0')+'\0 ',148); return Buffer.concat([header,bytes,Buffer.alloc((512-bytes.length%512)%512)]); } -async function pack(){const m=await check();const files=[];for(const name of ['manifest.json','plugin.js','README.md','LICENSE','NOTICE','source.map']){try{files.push(tarFile(name,await readFile(join(root,name))));}catch(e){if(!['NOTICE','source.map'].includes(name)||e.code!=='ENOENT')throw e;}} - const tar=Buffer.concat([...files,Buffer.alloc(1024)]);if(tar.length>30*1024*1024)throw Error('Expanded package too large');const bytes=gzipSync(tar,{level:9});if(bytes.length>10*1024*1024)throw Error('Compressed package too large');const out=join(root,m.id+'-'+m.version+'.cmxaddon');await writeFile(out,bytes);console.log(out+'\nsha256 '+createHash('sha256').update(bytes).digest('hex'));return out; +async function pack(out){const m=await check();const files=[];for(const name of ['manifest.json','plugin.js','README.md','LICENSE','NOTICE','source.map']){try{files.push(tarFile(name,await readFile(join(root,name))));}catch(e){if(!['NOTICE','source.map'].includes(name)||e.code!=='ENOENT')throw e;}} + const tar=Buffer.concat([...files,Buffer.alloc(1024)]);if(tar.length>30*1024*1024)throw Error('Expanded package too large');const bytes=gzipSync(tar,{level:9});if(bytes.length>10*1024*1024)throw Error('Compressed package too large');const target=typeof out==='function'?out(m):out??join(root,m.id+'-'+m.version+'.cmxaddon');await writeAtomic(target,bytes);console.log(target+'\nsha256 '+createHash('sha256').update(bytes).digest('hex'));return target; } -async function init(){await mkdir(root,{recursive:true});await mkdir(join(root,'src'),{recursive:true}); - const m={format:'codemux.feature-plugin',manifestVersion:1,id:'example.hello',name:'Hello',description:'An independent CodeMux plugin.',version:'1.0.0',api:'^1.0.0',entry:'plugin.js',platforms:['linux-x64','windows-x64'],author:{name:'Example',url:'https://example.com'},repository:'https://github.com/example/hello',license:'MIT',permissions:[],http:[],credentials:[],contributes:{commands:[{id:'hello',title:'Say hello',requiresWorkspace:false}],panels:[],composerActions:[],composerViews:[]},settings:[]}; - const files={'manifest.json':JSON.stringify(m,null,2),'package.json':JSON.stringify({name:m.id,version:'1.0.0',private:true,type:'module',scripts:{build:'codemux-plugin build',check:'tsc --noEmit && codemux-plugin check',pack:'codemux-plugin pack',dev:'codemux-plugin dev'},dependencies:{'@codemux/plugin-sdk':'1.0.0','preact':'10.29.8'},devDependencies:{'@codemux/plugin-cli':'1.0.0','typescript':'5.6.2'}},null,2),'src/index.tsx':"import {definePlugin} from '@codemux/plugin-sdk';\nexport default definePlugin({activate(ctx) { ctx.commands.register('hello', async () => { await ctx.ui.notify('Hello from your plugin'); }); }});\n",'tsconfig.json':JSON.stringify({compilerOptions:{target:'ES2020',module:'ESNext',moduleResolution:'Bundler',strict:true,noEmit:true,jsx:'react-jsx',jsxImportSource:'preact',skipLibCheck:true},include:['src']}),'README.md':'# Hello\n\nRun npm install, npm run build, and npm run pack. Import the .cmxaddon in Settings → Add-ons.\n','LICENSE':'MIT\nCopyright (c) '+new Date().getFullYear()+' Example\n'}; +const slug=name=>name.toLowerCase().replace(/[^a-z0-9-]+/g,'-').replace(/^[^a-z]+/,'').replace(/-+/g,'-').slice(0,40).replace(/-$/,''); +const title=id=>id.split('.')[1].split('-').filter(Boolean).map(w=>w[0].toUpperCase()+w.slice(1)).join(' '); +const mit=holder=>`MIT License + +Copyright (c) ${new Date().getFullYear()} ${holder} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +`; +const starter=name=>`import { + definePlugin, + Stack, + Heading, + Text, + Button, + PluginError, + useEffect, + useState, + type ViewProps, + type Workspace, +} from "@codemux/plugin-sdk"; +// Host calls reject with a PluginError whose code says why they could not run. +const describe = (error: unknown) => + error instanceof PluginError ? \`\${error.code}: \${error.message}\` : "Something went wrong"; +export default definePlugin({ + activate(ctx) { + function Main({ context }: ViewProps) { + const [workspace, setWorkspace] = useState(null); + const [error, setError] = useState(""); + async function load(handle = context) { + try { + // Declared in manifest.json as the workspace.read permission. + setWorkspace(await ctx.workspace.current(handle)); + setError(""); + } catch (e) { + setError(describe(e)); + } + } + useEffect(() => { + void load(); + }, [context]); + return ( + + ${name} + {workspace ? \`Project: \${workspace.name}\` : "No local project is open."} + {error && {error}} + + + ); + } + ctx.panels.register("main", (props) =>
); + ctx.commands.register("open", async (context) => { + try { + await ctx.panels.open("main", context); + } catch (e) { + console.warn(describe(e)); + } + }); + ctx.commands.register("hello", async () => { + // Notifications are limited to three per minute. + try { + await ctx.ui.notify("Hello from your plugin"); + } catch (e) { + console.warn(describe(e)); + } + }); + }, +}); +`; +async function init(){ + // Without --id, derive a placeholder from the directory name. + const derived='example.'+slug(basename(root)); + const id=options.id??(pluginId(derived)?derived:'example.hello'); + if(!pluginId(id))throw Error(`Invalid plugin ID "${id}": pass --id publisher.name, each part 2-40 lowercase letters, digits or hyphens starting with a letter`); + const name=title(id); + await mkdir(join(root,'src'),{recursive:true}); + const m=validate({format:'codemux.feature-plugin',manifestVersion:1,id,name,description:'An independent CodeMux plugin.',version:'1.0.0',api:'^1.0.0',entry:'plugin.js',platforms:['linux-x64','windows-x64'],author:{name:'Example',url:'https://example.com'},repository:'https://github.com/example/'+id.split('.')[1],license:'MIT',permissions:['workspace.read'],http:[],credentials:[],contributes:{commands:[{id:'open',title:'Open '+name,requiresWorkspace:true},{id:'hello',title:'Say hello',requiresWorkspace:false}],panels:[{id:'main',title:name,icon:'file-text'}],composerActions:[],composerViews:[]},settings:[]}); + const files={'manifest.json':JSON.stringify(m,null,2)+'\n','package.json':JSON.stringify({name:m.id,version:'1.0.0',private:true,type:'module',scripts:{build:'codemux-plugin build',check:'tsc --noEmit && codemux-plugin check',pack:'codemux-plugin pack',dev:'codemux-plugin dev'},dependencies:{'@codemux/plugin-sdk':'1.0.0','preact':'10.29.8'},devDependencies:{'@codemux/plugin-cli':'1.0.0','typescript':'5.6.2'}},null,2)+'\n','src/index.tsx':starter(name),'tsconfig.json':JSON.stringify({compilerOptions:{target:'ES2020',module:'ESNext',moduleResolution:'Bundler',strict:true,noEmit:true,jsx:'react-jsx',jsxImportSource:'preact',skipLibCheck:true},include:['src']})+'\n','.gitignore':'node_modules/\ndist/\nplugin.js\nsource.map\n*.cmxaddon\n','LICENSE':mit('Example'), + 'README.md':`# ${name}\n\nA CodeMux feature plugin. Before publishing, replace the \`example\` publisher in the \`${id}\` ID, the author, repository and LICENSE holder with your own; the ID cannot change after users install it.\n\nRun \`npm install\`, \`npm run build\`, \`npm run check\` and \`npm run pack\`, then import the \`.cmxaddon\` in Settings → Add-ons. \`npm run dev\` rebuilds on every change and writes \`dist/${id}.cmxaddon\`; select that file in Developer mode.\n\nThe code lives in \`src/index.tsx\`. The panel reads the open project through the \`workspace.read\` permission declared in \`manifest.json\`; declare every permission a host call needs. Host calls can fail with a \`PluginError\` code, so handle them where they are made. See the \`@codemux/plugin-sdk\` README for the API, limits and components.\n`}; for(const [path,contents] of Object.entries(files))await writeFile(join(root,path),contents,{flag:'wx'}); + console.log(`Created ${id} in ${root}`); } -try{switch(command){case 'init':await init();break;case 'build':await build();break;case 'check':await check();console.log('Package valid');break;case 'pack':await pack();break;case 'dev':await build();await pack();{let running=false,again=false;const rebuild=async()=>{if(running){again=true;return}running=true;do{again=false;try{await build();await pack()}catch(e){console.error(e.message)}}while(again);running=false};watch(join(root,'src'),{recursive:true},rebuild);watch(join(root,'manifest.json'),rebuild);}break;default:console.log('codemux-plugin init | build | check | pack | dev');}}catch(e){console.error(e.message);process.exitCode=1} +async function dev(){ + const out=m=>options.out?resolve(options.out):join(root,'dist',m.id+'.cmxaddon'); + let running=false,again=false; + // A failed build is reported and the watch continues, including the first one. + const rebuild=async()=>{if(running){again=true;return}running=true;do{again=false;try{await build();await pack(out)}catch(e){console.error(e.message)}}while(again);running=false}; + watch(join(root,'src'),{recursive:true},rebuild); + watch(root,(event,name)=>{if(['manifest.json','README.md','LICENSE','NOTICE'].includes(String(name)))rebuild()}); + await rebuild(); + console.log('Watching src/, manifest.json, README.md, LICENSE and NOTICE. Select the package above in Developer mode.'); +} +let root; +try{ + const args=process.argv.slice(2); + for(let i=0;i=args.length)throw Error(`${arg} needs a value`);options[arg.slice(2)]=args[++i];}else if(arg.startsWith('--'))throw Error(`Unknown option ${arg}\n${usage}`);else positional.push(arg);} + const [command='help',directory='.']=positional;root=resolve(directory); + switch(command){case 'init':await init();break;case 'build':await build();break;case 'check':await check();console.log('Package valid');break;case 'pack':await pack(options.out&&resolve(options.out));break;case 'dev':await dev();break;default:console.log(usage);} +}catch(e){console.error(e.message);process.exitCode=1} diff --git a/packages/plugin-cli/src/validate.mjs b/packages/plugin-cli/src/validate.mjs index bfb5adb9..00e11b74 100644 --- a/packages/plugin-cli/src/validate.mjs +++ b/packages/plugin-cli/src/validate.mjs @@ -1,36 +1,143 @@ import {readFile} from 'node:fs/promises'; +import {isUtf8} from 'node:buffer'; import Ajv from 'ajv'; -import semver from 'semver'; +// Mirrors the desktop's authoritative validator (addon-protocol manifest.rs), +// so `check` and `pack` accept exactly the manifests the app imports. const schema=JSON.parse(await readFile(new URL('../schema/manifest.json',import.meta.url),'utf8')); +// A JS number cannot hold every int64, so validate() checks integer ranges. const checkSchema=new Ajv({strict:false,allErrors:true,formats:{uint32:true,int64:true}}).compile(schema); +const i64=[-(2n**63n),2n**63n-1n]; +// The exact source integers of objects from parse(); a JS number rounds past 2^53. +const sourceIntegers=new WeakMap(); +const integer=(object,key)=>sourceIntegers.get(object)?.get(key)??(Number.isInteger(object[key])?BigInt(object[key]):NaN); +// The desktop reads manifest bytes with serde_json, which rejects what JSON.parse +// accepts: invalid UTF-8, duplicate keys and lone surrogates. Every manifest number +// is an integer field, and serde_json reads a fraction, an exponent, -0 or a value +// outside 64 bits as a float, which no such field accepts. +export function parse(bytes) { + if(!isUtf8(bytes))throw Error('manifest.json is not valid UTF-8'); + // Buffer decoding needs no ICU and keeps a byte order mark, which JSON.parse rejects. + const source=Buffer.from(bytes).toString('utf8'); + JSON.parse(source); + // The grammar is valid, so each token is a string, a literal, a number or a bracket. + const tokens=source.match(/"(?:[^"\\]|\\.)*"|[{}[\]]|[^\s"{}[\],:]+/g); + let at=0; + const string=token=>{const value=JSON.parse(token);if(!value.isWellFormed())throw Error(`Invalid string ${token} in manifest.json: lone surrogate escapes are not valid Unicode`);return value}; + const read=()=>{ + const token=tokens[at++]; + if(token==='['){const list=[];while(tokens[at]!==']')list.push(read());at++;return list} + if(token==='{'){ + const object={},integers=new Map(); + while(tokens[at]!=='}'){ + const key=string(tokens[at++]); + if(Object.hasOwn(object,key))throw Error(`Duplicate key "${key}" in manifest.json`); + const value=read(); + if(typeof value==='number')integers.set(key,BigInt(tokens[at-1])); + Object.defineProperty(object,key,{value,writable:true,enumerable:true,configurable:true}); + } + at++;sourceIntegers.set(object,integers);return object; + } + if(token[0]==='"')return string(token); + if(['true','false','null'].includes(token))return JSON.parse(token); + if(!/^-?(0|[1-9]\d*)$/.test(token)||token==='-0'||BigInt(token)i64[1])throw Error(`Invalid number ${token} in manifest.json: use a whole number within the signed 64-bit range, without a fraction or exponent`); + return Number(token); + }; + return read(); +} const id=/^[a-z][a-z0-9-]{0,39}$/; +// Windows reserves device names even when an extension follows. +const reserved=/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/; +export const pluginId=value=>typeof value==='string'&&/^[a-z][a-z0-9-]{1,39}\.[a-z][a-z0-9-]{1,39}$/.test(value)&&value.split('.').every(part=>!reserved.test(part)); const unique=values=>new Set(values).size===values.length; -const text=(s,n)=>typeof s==='string'&&s.length>0&&[...s].length<=n&&!/[\x00-\x1f\x7f]/.test(s); -const icons=['file-text','git-branch','github','list','check','info','settings','book-open','link','refresh-cw','plus','circle-alert','folder','terminal','code','search']; -const https=value=>{try {const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password;}catch{return false}}; -const origin=value=>{try {const u=new URL(value);return https(value)&&u.origin===value&&!u.port&&!/^\[|^[\d.]+$/.test(u.hostname)&&u.pathname==='/'&&!u.search&&!u.hash;}catch{return false}}; +// Rust rejects C0 and C1 controls, and JSON with lone surrogates never parses there. +const text=(s,n)=>typeof s==='string'&&s.length>0&&s.isWellFormed()&&[...s].length<=n&&!/[\u0000-\u001f\u007f-\u009f]/.test(s); +export const icons=['file-text','git-branch','github','list','check','info','settings','book-open','link','refresh-cw','plus','circle-alert','folder','terminal','code','search']; +const https=value=>{try {const u=new URL(value);return value.isWellFormed()&&u.protocol==='https:'&&!u.username&&!u.password;}catch{return false}}; +const origin=value=>{try {const u=new URL(value);return https(value)&&u.origin===value&&!u.port&&!/^\[|^[\d.]+$/.test(u.hostname)&&!u.hostname.includes('*')&&!u.hostname.endsWith('.')&&u.pathname==='/'&&!u.search&&!u.hash;}catch{return false}}; +// Rust semver 1.0 grammar: strict SemVer 2.0 versions with u64 numbers, and +// comma-separated requirements where a bare version means a caret requirement. +const number='(0|[1-9]\\d*)', identifier='(?:\\d*[A-Za-z-][0-9A-Za-z-]*|0|[1-9]\\d*)', pre=`${identifier}(?:\\.${identifier})*`, build='[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*'; +const u64=digits=>BigInt(digits)<=18446744073709551615n; +export function version(value) { + const m=typeof value==='string'&&new RegExp(`^${number}\\.${number}\\.${number}(?:-${pre})?(?:\\+${build})?$`).exec(value); + return Boolean(m)&&m.slice(1,4).every(u64); +} +const part=`(?:\\.(?:([*xX])|${number}))?`; +const comparator=new RegExp(`^(=|>=|>|<=|<|~|\\^)? *${number}${part}${part}(?:-(${pre}))?(?:\\+(${build}))? *`); +export function requirement(value) { + if(typeof value!=='string')return null; + let rest=value.replace(/^ +/,''); + if(/^[*xX]/.test(rest))return /^[*xX] *$/.test(rest) ? [] : null; + const comparators=[]; + for(;;) { + const m=comparator.exec(rest); + if(!m||comparators.length===32)return null; + const [all,op,major,minorWild,minor,patchWild,patch,prerelease,metadata]=m; + if((minorWild&&patch!==undefined)||((prerelease!==undefined||metadata!==undefined)&&patch===undefined)||![major,minor,patch].every(n=>n===undefined||u64(n)))return null; + const wild=Boolean(minorWild||patchWild); + comparators.push({op:op??(wild?'*':'^'),major:Number(major),minor:minor===undefined?undefined:Number(minor),patch:patch===undefined?undefined:Number(patch),pre:prerelease??''}); + rest=rest.slice(all.length); + if(rest==='')return comparators; + if(rest[0]!==',')return null; + rest=rest.slice(1).replace(/^ +/,''); + } +} +// semver's matches() for a release version, where an empty pre-release sorts last. +export function matches(comparators,[major,minor,patch]) { + const exact=c=>major===c.major&&(c.minor===undefined||minor===c.minor)&&(c.patch===undefined||patch===c.patch)&&c.pre===''; + const greater=c=>major!==c.major ? major>c.major : c.minor===undefined ? false : minor!==c.minor ? minor>c.minor : c.patch===undefined ? false : patch!==c.patch ? patch>c.patch : c.pre!==''; + const less=c=>major!==c.major ? majormajor===c.major&&(c.minor===undefined||minor===c.minor)&&(c.patch===undefined||patch>=c.patch); + const caret=c=>{ + if(major!==c.major)return false; + if(c.minor===undefined)return true; + if(c.patch===undefined)return c.major>0 ? minor>=c.minor : minor===c.minor; + if(c.major>0)return minor!==c.minor ? minor>c.minor : patch>=c.patch; + if(c.minor>0)return minor===c.minor&&patch>=c.patch; + return minor===c.minor&&patch===c.patch; + }; + const test={'=':exact,'*':exact,'>':greater,'>=':c=>exact(c)||greater(c),'<':less,'<=':c=>exact(c)||less(c),'~':tilde,'^':caret}; + return comparators.every(c=>test[c.op](c)); +} export function validate(manifest) { - if (!checkSchema(manifest)) throw Error('Manifest schema: '+checkSchema.errors.map(e=>e.instancePath+' '+e.message).join('; ')); + const schemaErrors=checkSchema(manifest) ? [] : checkSchema.errors; + const failSchema=errors=>{throw Error('Manifest schema: '+errors.map(e=>e.instancePath+' '+e.message).join('; '))}; + // The rules below assume the schema's shape; value constraints the schema also + // encodes (enum, pattern, lengths, bounds) get their more specific message there. + const structural=schemaErrors.filter(e=>!['enum','const','pattern','format','minLength','maxLength','minimum','maximum','minItems','maxItems','uniqueItems'].includes(e.keyword)); + if (structural.length) failSchema(structural); const m=manifest; const require=(ok,message)=>{if(!ok)throw Error(message)}; - require(m.format==='codemux.feature-plugin'&&m.manifestVersion===1&&m.entry==='plugin.js','Unsupported package format'); - require(/^[a-z][a-z0-9-]{1,39}\.[a-z][a-z0-9-]{1,39}$/.test(m.id),'Invalid publisher.name identity'); - require(text(m.name,80)&&text(m.description,240)&&text(m.author.name,80)&&https(m.author.url)&&https(m.repository)&&text(m.license,100),'Invalid metadata'); - require(semver.valid(m.version)&&semver.validRange(m.api)&&semver.satisfies('1.0.0',m.api),'Unsupported version or API'); + require(m.format==='codemux.feature-plugin'&&m.manifestVersion===1&&m.entry==='plugin.js','Unsupported package format: use format "codemux.feature-plugin", manifestVersion 1 and entry "plugin.js"'); + require(pluginId(m.id),'Invalid identity: use publisher.name, each part 2-40 lowercase letters, digits or hyphens starting with a letter, and not a Windows device name'); + require(text(m.name,80)&&text(m.description,240)&&text(m.author.name,80)&&text(m.license,100),'Invalid metadata: name, author and license need 1-80, 1-80 and 1-100 characters, description 1-240, without control characters'); + require(https(m.author.url)&&https(m.repository),'Invalid metadata: author.url and repository must be HTTPS URLs without credentials'); + require(version(m.version),`Invalid package version "${m.version}": use SemVer such as 1.0.0, without a v prefix or spaces`); + const range=requirement(m.api); + require(range,`Invalid API range "${m.api}": use comma-separated comparators such as "^1.0.0" or ">=1.0.0, <2.0.0"`); + require(matches(range,[1,0,0]),`API range "${m.api}" does not include the supported plugin API 1.0.0`); require(m.platforms.length>0&&unique(m.platforms)&&unique(m.permissions),'Duplicate platforms or permissions'); for(const [kind,max] of Object.entries({commands:20,panels:8,composerActions:8,composerViews:4})) { - const list=m.contributes[kind];require(list.length<=max&&unique(list.map(c=>c.id)),'Duplicate or excessive contributions'); - for(const c of list) require(id.test(c.id)&&text(c.title,80)&&(kind==='commands'||icons.includes(c.icon)),'Invalid contribution'); + const list=m.contributes[kind];require(list.length<=max&&unique(list.map(c=>c.id)),`Duplicate or more than ${max} ${kind}`); + for(const c of list) { + require(id.test(c.id)&&text(c.title,80),`Invalid ${kind} entry "${c.id}": IDs need 1-40 lowercase letters, digits or hyphens starting with a letter, titles 1-80 characters`); + require(kind==='commands'||icons.includes(c.icon),`Unknown icon "${c.icon}" for ${kind}/${c.id}; use one of: ${icons.join(', ')}`); + } } - require(m.settings.length<=50&&unique(m.settings.map(s=>s.id)),'Duplicate or excessive settings'); + require(m.settings.length<=50&&unique(m.settings.map(s=>s.id)),'Duplicate or more than 50 settings'); for(const s of m.settings) { - require(id.test(s.id)&&text(s.label,80),'Invalid setting'); - if(s.type==='string')require(Buffer.byteLength(s.default??'')<=4096,'String default too large'); - if(s.type==='integer')require(s.min<=s.default&&s.default<=s.max,'Invalid integer bounds'); - if(s.type==='enum')require(s.values.length>0&&s.values.length<=50&&unique(s.values)&&s.values.includes(s.default)&&s.values.every(v=>text(v,4096)),'Invalid enum'); + require(id.test(s.id)&&text(s.label,80),`Invalid setting "${s.id}"`); + if(s.type==='string')require((s.default??'').isWellFormed()&&Buffer.byteLength(s.default??'')<=4096,`String default of "${s.id}" exceeds 4 KiB or is not valid Unicode`); + if(s.type==='integer'){ + const [value,min,max]=['default','min','max'].map(key=>integer(s,key)); + require(i64[0]<=min&&max<=i64[1],`Integer bounds of "${s.id}" are outside the signed 64-bit range`); + require(min<=value&&value<=max,`Integer default of "${s.id}" is outside min/max`); + } + if(s.type==='enum')require(s.values.length>0&&s.values.length<=50&&unique(s.values)&&s.values.includes(s.default)&&s.values.every(v=>text(v,4096)),`Invalid enum setting "${s.id}"`); } require(m.http.length<=20&&m.credentials.length<=20&&unique(m.http.map(h=>h.origin))&&unique(m.credentials.map(c=>c.id))&&unique(m.credentials.map(c=>c.origin)),'Duplicate or excessive HTTP grants'); - for(const h of m.http)require(origin(h.origin)&&h.methods.length>0&&unique(h.methods)&&(h.credential===null||m.credentials.some(c=>c.id===h.credential&&c.origin===h.origin)),'Invalid HTTP declaration'); - for(const c of m.credentials)require(id.test(c.id)&&text(c.label,80)&&origin(c.origin)&&m.http.some(h=>h.origin===c.origin&&h.credential===c.id),'Invalid credential'); + for(const h of m.http)require(origin(h.origin)&&h.methods.length>0&&unique(h.methods)&&(h.credential===null||m.credentials.some(c=>c.id===h.credential&&c.origin===h.origin)),`Invalid HTTP declaration "${h.origin}": use an exact lowercase HTTPS hostname origin on port 443, no wildcard, IP address, trailing dot or path`); + for(const c of m.credentials)require(id.test(c.id)&&text(c.label,80)&&origin(c.origin)&&m.http.some(h=>h.origin===c.origin&&h.credential===c.id),`Invalid credential "${c.id}"`); + if (schemaErrors.length) failSchema(schemaErrors); return m; } diff --git a/packages/plugin-cli/tests/author.test.mjs b/packages/plugin-cli/tests/author.test.mjs new file mode 100644 index 00000000..2df797c8 --- /dev/null +++ b/packages/plugin-cli/tests/author.test.mjs @@ -0,0 +1,102 @@ +// The starter and every CLI command, run like a third-party author: packed +// SDK and CLI tarballs installed in a directory outside the app checkout. +// Set CODEMUX_AUTHOR_TARBALLS to the directory holding the packed SDK and CLI +// (scripts/addons/build-examples.sh does). +import assert from 'node:assert/strict'; +import {spawn, spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import {gunzipSync} from 'node:zlib'; +const packs = process.env.CODEMUX_AUTHOR_TARBALLS; +const tarballs = packs && ['sdk', 'cli'].map(name => join(packs, `codemux-plugin-${name}-1.0.0.tgz`)); +const source = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); +function run(command, args, cwd, expected = 0) { + const result = spawnSync(command, args, {cwd, encoding: 'utf8', shell: process.platform === 'win32' && command === 'npm'}); + assert.equal(result.status, expected, `${command} ${args.join(' ')}:\n${result.stdout}\n${result.stderr}`); + return result; +} +function entries(archive) { + const tar = gunzipSync(archive), names = []; + for (let offset = 0; tar[offset] !== 0; ) { + names.push(tar.toString('utf8', offset, offset + 100).replace(/\0.*$/s, '')); + const size = parseInt(tar.toString('ascii', offset + 124, offset + 135), 8); + offset += 512 + Math.ceil(size / 512) * 512; + } + return names; +} +const digest = async path => {try {return createHash('sha256').update(await readFile(path)).digest('hex');} catch {return null;}}; +async function until(predicate, what, ms = 30000) { + const end = Date.now() + ms; + while (Date.now() < end) { + const value = await predicate(); + if (value) return value; + await new Promise(r => setTimeout(r, 100)); + } + throw Error('Timed out waiting for ' + what); +} +test('starter builds, checks, packs and watches from packed tarballs', {skip: !tarballs && 'CODEMUX_AUTHOR_TARBALLS is not set', timeout: 300000}, async t => { + const workspace = await mkdtemp(join(tmpdir(), 'codemux-author-')); + t.after(() => rm(workspace, {recursive: true, force: true})); + const project = join(workspace, 'My_Plugin'); + run(process.execPath, [source, 'init', project]); + const manifest = JSON.parse(await readFile(join(project, 'manifest.json'), 'utf8')); + assert.equal(manifest.id, 'example.my-plugin', 'the ID is derived from the directory'); + assert.deepEqual(manifest.permissions, ['workspace.read']); + assert.deepEqual(manifest.contributes.panels.map(p => p.icon), ['file-text']); + assert.match(await readFile(join(project, 'LICENSE'), 'utf8'), /Permission is hereby granted, free of charge/); + assert.match(await readFile(join(project, '.gitignore'), 'utf8'), /node_modules\/\n.*\*\.cmxaddon/s); + run(process.execPath, [source, 'init', join(workspace, 'explicit'), '--id', 'acme.notes']); + assert.equal(JSON.parse(await readFile(join(workspace, 'explicit/manifest.json'), 'utf8')).id, 'acme.notes'); + run(process.execPath, [source, 'init', join(workspace, 'reserved'), '--id', 'con.notes'], undefined, 1); + run('npm', ['install', '--no-save', '--package-lock=false', '--ignore-scripts', '--no-audit', '--no-fund', ...tarballs], project); + // npm run check includes strict TypeScript over the starter source. + for (const script of ['build', 'check', 'pack']) run('npm', ['run', script], project); + const packed = join(project, 'example.my-plugin-1.0.0.cmxaddon'); + assert.deepEqual(entries(await readFile(packed)), ['manifest.json', 'plugin.js', 'README.md', 'LICENSE']); + const cli = join(project, 'node_modules/@codemux/plugin-cli/src/cli.mjs'); + // Optional developer source map, removed again by a plain build. + run(process.execPath, [cli, 'build', '--sourcemap'], project); + run(process.execPath, [cli, 'pack', '--out', join(project, 'dist/mapped.cmxaddon')], project); + assert.ok(entries(await readFile(join(project, 'dist/mapped.cmxaddon'))).includes('source.map')); + run(process.execPath, [cli, 'build'], project); + assert.equal(await digest(join(project, 'source.map')), null); + // Invalid manifests fail `check` with the rejected value named. + const valid = await readFile(join(project, 'manifest.json'), 'utf8'); + for (const [change, message] of [ + [m => {m.contributes.panels[0].icon = 'star';}, /Unknown icon "star"/], + [m => {m.version = 'v1.0.0';}, /Invalid package version "v1\.0\.0"/], + [m => {m.api = '>=1.0.0 <2.0.0';}, /Invalid API range/], + [m => {m.contributes.commands[1].id = 'open';}, /Duplicate or more than 20 commands/], + ]) { + const m = JSON.parse(valid); + change(m); + await writeFile(join(project, 'manifest.json'), JSON.stringify(m)); + assert.match(run(process.execPath, [cli, 'check'], project, 1).stderr, message); + } + await writeFile(join(project, 'manifest.json'), valid); + // `dev` keeps watching after a failed first build and repacks one stable path. + const entry = join(project, 'src/index.tsx'); + const original = await readFile(entry, 'utf8'); + await writeFile(entry, 'export default {'); + const output = join(project, 'dist/example.my-plugin.cmxaddon'); + const dev = spawn(process.execPath, [cli, 'dev'], {cwd: project, stdio: ['ignore', 'pipe', 'pipe']}); + let errors = ''; + dev.stderr.on('data', data => (errors += data)); + const exited = new Promise(r => dev.once('exit', r)); + try { + await until(() => errors.length > 0, 'the first build error'); + assert.equal(dev.exitCode, null, 'dev must keep watching after a failed build'); + assert.equal(await digest(output), null); + await writeFile(entry, original); + const first = await until(() => digest(output), 'the first development package'); + await writeFile(entry, original.replace('Hello from your plugin', 'Hello again')); + await until(async () => {const next = await digest(output); return next && next !== first;}, 'a rebuilt development package'); + } finally { + dev.kill(); + await exited; + } +}); diff --git a/packages/plugin-cli/tests/manifest-cases.json b/packages/plugin-cli/tests/manifest-cases.json new file mode 100644 index 00000000..932946be --- /dev/null +++ b/packages/plugin-cli/tests/manifest-cases.json @@ -0,0 +1,133 @@ +{ + "comment": "Accept/reject cases shared with the desktop validator (addon-protocol manifest.rs). Each case replaces JSON pointers in base.", + "base": { + "format": "codemux.feature-plugin", + "manifestVersion": 1, + "id": "example.hello", + "name": "Hello", + "description": "An independent CodeMux plugin.", + "version": "1.0.0", + "api": "^1.0.0", + "entry": "plugin.js", + "platforms": ["linux-x64", "windows-x64"], + "author": { "name": "Example", "url": "https://example.com" }, + "repository": "https://github.com/example/hello", + "license": "MIT", + "permissions": [], + "http": [], + "credentials": [], + "contributes": { + "commands": [{ "id": "hello", "title": "Say hello", "requiresWorkspace": false }], + "panels": [{ "id": "main", "title": "Main", "icon": "file-text" }], + "composerActions": [], + "composerViews": [] + }, + "settings": [] + }, + "cases": [ + { "name": "base", "set": {}, "expect": "valid" }, + { "name": "prerelease and build version", "set": { "/version": "1.0.0-alpha.1+build.05" }, "expect": "valid" }, + { "name": "alphanumeric prerelease with leading digit", "set": { "/version": "1.0.0-0a.1" }, "expect": "valid" }, + { "name": "zero version", "set": { "/version": "0.0.0" }, "expect": "valid" }, + { "name": "v prefix", "set": { "/version": "v1.0.0" }, "expect": "invalid" }, + { "name": "leading space", "set": { "/version": " 1.0.0" }, "expect": "invalid" }, + { "name": "trailing space", "set": { "/version": "1.0.0 " }, "expect": "invalid" }, + { "name": "missing patch", "set": { "/version": "1.0" }, "expect": "invalid" }, + { "name": "leading zero", "set": { "/version": "01.0.0" }, "expect": "invalid" }, + { "name": "numeric prerelease leading zero", "set": { "/version": "1.0.0-01" }, "expect": "invalid" }, + { "name": "empty prerelease", "set": { "/version": "1.0.0-" }, "expect": "invalid" }, + { "name": "empty prerelease segment", "set": { "/version": "1.0.0-alpha..1" }, "expect": "invalid" }, + { "name": "empty build", "set": { "/version": "1.0.0+" }, "expect": "invalid" }, + { "name": "u64 overflow", "set": { "/version": "18446744073709551616.0.0" }, "expect": "invalid" }, + { "name": "u64 maximum", "set": { "/version": "18446744073709551615.0.0" }, "expect": "valid" }, + { "name": "comma range", "set": { "/api": ">=1.0.0, <2.0.0" }, "expect": "valid" }, + { "name": "comma range without space", "set": { "/api": ">=1.0.0,<2.0.0" }, "expect": "valid" }, + { "name": "space before comma", "set": { "/api": ">=1.0.0 , <2.0.0" }, "expect": "valid" }, + { "name": "bare version is caret", "set": { "/api": "1.0.0" }, "expect": "valid" }, + { "name": "bare major", "set": { "/api": "1" }, "expect": "valid" }, + { "name": "minor wildcard", "set": { "/api": "1.x" }, "expect": "valid" }, + { "name": "star wildcards", "set": { "/api": "1.*.*" }, "expect": "valid" }, + { "name": "star", "set": { "/api": "*" }, "expect": "valid" }, + { "name": "letter wildcard", "set": { "/api": "x" }, "expect": "valid" }, + { "name": "tilde", "set": { "/api": "~1.0" }, "expect": "valid" }, + { "name": "exact", "set": { "/api": "=1.0.0" }, "expect": "valid" }, + { "name": "less or equal", "set": { "/api": "<=1.0.0" }, "expect": "valid" }, + { "name": "surrounding spaces", "set": { "/api": " ^1.0.0 " }, "expect": "valid" }, + { "name": "space after operator", "set": { "/api": ">= 1.0.0" }, "expect": "valid" }, + { "name": "caret prerelease", "set": { "/api": "^1.0.0-rc.1" }, "expect": "valid" }, + { "name": "greater than prerelease", "set": { "/api": ">1.0.0-rc.1" }, "expect": "valid" }, + { "name": "caret zero minor", "set": { "/api": "^0.9" }, "expect": "incompatible" }, + { "name": "next major", "set": { "/api": "^2.0.0" }, "expect": "incompatible" }, + { "name": "below one", "set": { "/api": "<1.0.0" }, "expect": "incompatible" }, + { "name": "above one", "set": { "/api": ">1.0.0" }, "expect": "incompatible" }, + { "name": "bare patch release", "set": { "/api": "1.0.1" }, "expect": "incompatible" }, + { "name": "exact prerelease", "set": { "/api": "=1.0.0-rc.1" }, "expect": "incompatible" }, + { "name": "below prerelease", "set": { "/api": "<1.0.0-rc.1" }, "expect": "incompatible" }, + { "name": "tilde older minor", "set": { "/api": "~0.9" }, "expect": "incompatible" }, + { "name": "npm or", "set": { "/api": "^1.0.0 || ^2.0.0" }, "expect": "invalid" }, + { "name": "npm space separated", "set": { "/api": ">=1.0.0 <2.0.0" }, "expect": "invalid" }, + { "name": "npm hyphen range", "set": { "/api": "1.0.0 - 2.0.0" }, "expect": "invalid" }, + { "name": "trailing comma", "set": { "/api": "^1.0.0," }, "expect": "invalid" }, + { "name": "empty range", "set": { "/api": "" }, "expect": "invalid" }, + { "name": "star with comparator", "set": { "/api": "*, ^1.0.0" }, "expect": "invalid" }, + { "name": "patch after wildcard", "set": { "/api": "1.x.0" }, "expect": "invalid" }, + { "name": "prerelease without patch", "set": { "/api": "1.2-alpha" }, "expect": "invalid" }, + { "name": "v prefix range", "set": { "/api": "v1" }, "expect": "invalid" }, + { "name": "double equals", "set": { "/api": "==1.0.0" }, "expect": "invalid" }, + { "name": "two character segments", "set": { "/id": "ab.cd" }, "expect": "valid" }, + { "name": "one character segment", "set": { "/id": "a.hello" }, "expect": "invalid" }, + { "name": "uppercase id", "set": { "/id": "Example.hello" }, "expect": "invalid" }, + { "name": "three segments", "set": { "/id": "example.hello.world" }, "expect": "invalid" }, + { "name": "device-like but not reserved", "set": { "/id": "example.console" }, "expect": "valid" }, + { "name": "reserved publisher", "set": { "/id": "con.hello" }, "expect": "invalid" }, + { "name": "reserved name", "set": { "/id": "example.nul" }, "expect": "invalid" }, + { "name": "reserved serial port", "set": { "/id": "example.com1" }, "expect": "invalid" }, + { "name": "reserved printer port", "set": { "/id": "lpt9.hello" }, "expect": "invalid" }, + { "name": "C1 control in name", "set": { "/name": "Hello\u0085" }, "expect": "invalid" }, + { "name": "C0 control in description", "set": { "/description": "Line\nbreak" }, "expect": "invalid" }, + { "name": "80 character name", "set": { "/name": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn" }, "expect": "valid" }, + { "name": "81 character name", "set": { "/name": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn" }, "expect": "invalid" }, + { "name": "non-BMP characters count once", "set": { "/name": "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀" }, "expect": "valid" }, + { "name": "http metadata URL", "set": { "/repository": "http://github.com/example/hello" }, "expect": "invalid" }, + { "name": "unknown icon", "set": { "/contributes/panels/0/icon": "star" }, "expect": "invalid" }, + { "name": "every documented icon", "set": { "/contributes/panels": [ + { "id": "a", "title": "A", "icon": "circle-alert" }, + { "id": "b", "title": "B", "icon": "folder" }, + { "id": "c", "title": "C", "icon": "terminal" }, + { "id": "d", "title": "D", "icon": "code" }, + { "id": "e", "title": "E", "icon": "search" }, + { "id": "f", "title": "F", "icon": "refresh-cw" }, + { "id": "g", "title": "G", "icon": "book-open" }, + { "id": "h", "title": "H", "icon": "git-branch" } + ] }, "expect": "valid" }, + { "name": "duplicate command", "set": { "/contributes/commands": [ + { "id": "hello", "title": "One", "requiresWorkspace": false }, + { "id": "hello", "title": "Two", "requiresWorkspace": false } + ] }, "expect": "invalid" }, + { "name": "unknown field", "set": { "/contributes/commands/0/shortcut": "Ctrl+H" }, "expect": "invalid" }, + { "name": "enum default outside values", "set": { "/settings": [ + { "id": "mode", "type": "enum", "label": "Mode", "default": "c", "values": ["a", "b"] } + ] }, "expect": "invalid" }, + { "name": "integer default outside bounds", "set": { "/settings": [ + { "id": "count", "type": "integer", "label": "Count", "default": 11, "min": 0, "max": 10 } + ] }, "expect": "invalid" }, + { "name": "string setting without default", "set": { "/settings": [ + { "id": "owner", "type": "string", "label": "Owner" } + ] }, "expect": "valid" }, + { "name": "credential origin", "set": { + "/http": [{ "origin": "https://api.example.com", "methods": ["GET"], "credential": "token" }], + "/credentials": [{ "id": "token", "label": "API token", "origin": "https://api.example.com", "type": "bearer" }] + }, "expect": "valid" }, + { "name": "http origin", "set": { "/http": [{ "origin": "http://api.example.com", "methods": ["GET"], "credential": null }] }, "expect": "invalid" }, + { "name": "origin with path", "set": { "/http": [{ "origin": "https://api.example.com/v1", "methods": ["GET"], "credential": null }] }, "expect": "invalid" }, + { "name": "origin with explicit port", "set": { "/http": [{ "origin": "https://api.example.com:443", "methods": ["GET"], "credential": null }] }, "expect": "invalid" }, + { "name": "origin with other port", "set": { "/http": [{ "origin": "https://api.example.com:8443", "methods": ["GET"], "credential": null }] }, "expect": "invalid" }, + { "name": "uppercase origin", "set": { "/http": [{ "origin": "https://API.example.com", "methods": ["GET"], "credential": null }] }, "expect": "invalid" }, + { "name": "origin with credentials", "set": { "/http": [{ "origin": "https://user@api.example.com", "methods": ["GET"], "credential": null }] }, "expect": "invalid" }, + { "name": "IPv4 origin", "set": { "/http": [{ "origin": "https://127.0.0.1", "methods": ["GET"], "credential": null }] }, "expect": "invalid" }, + { "name": "IPv6 origin", "set": { "/http": [{ "origin": "https://[::1]", "methods": ["GET"], "credential": null }] }, "expect": "invalid" }, + { "name": "wildcard origin", "set": { "/http": [{ "origin": "https://*.example.com", "methods": ["GET"], "credential": null }] }, "expect": "invalid" }, + { "name": "trailing dot origin", "set": { "/http": [{ "origin": "https://api.example.com.", "methods": ["GET"], "credential": null }] }, "expect": "invalid" }, + { "name": "duplicate method", "set": { "/http": [{ "origin": "https://api.example.com", "methods": ["GET", "GET"], "credential": null }] }, "expect": "invalid" } + ] +} diff --git a/packages/plugin-cli/tests/validate.test.mjs b/packages/plugin-cli/tests/validate.test.mjs new file mode 100644 index 00000000..15392b01 --- /dev/null +++ b/packages/plugin-cli/tests/validate.test.mjs @@ -0,0 +1,99 @@ +// Author-side validation must agree with the desktop's manifest.rs. +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {test} from 'node:test'; +import {icons, parse, validate} from '../src/validate.mjs'; +const {base, cases} = JSON.parse(await readFile(new URL('./manifest-cases.json', import.meta.url), 'utf8')); +function apply(manifest, pointer, value) { + const parts = pointer.split('/').slice(1); + let node = manifest; + for (const part of parts.slice(0, -1)) node = node[part]; + node[parts.at(-1)] = value; +} +function outcome(manifest) { + try {validate(manifest); return 'valid';} + catch (error) {return /does not include the supported plugin API/.test(error.message) ? 'incompatible' : 'invalid';} +} +for (const {name, set, expect} of cases) + test(`manifest case: ${name}`, () => { + const manifest = structuredClone(base); + for (const [pointer, value] of Object.entries(set)) apply(manifest, pointer, value); + assert.equal(outcome(manifest), expect); + }); +test('icon allowlists match the desktop validator and the SDK', async () => { + const list = source => JSON.parse('[' + source.replaceAll("'", '"').replace(/,\s*$/, '') + ']'); + const rust = await readFile(new URL('../../../src-tauri/addon-protocol/src/manifest.rs', import.meta.url), 'utf8'); + const sdk = await readFile(new URL('../../plugin-sdk/src/ui.ts', import.meta.url), 'utf8'); + assert.deepEqual(icons, list(/pub const ICONS: &\[&str\] = &\[([^\]]*)\]/.exec(rust)[1])); + assert.deepEqual(icons, list(/export const ICONS=\[([^\]]*)\]/.exec(sdk)[1])); +}); +test('errors name the rejected value', () => { + const manifest = structuredClone(base); + manifest.contributes.panels[0].icon = 'star'; + assert.throws(() => validate(manifest), /Unknown icon "star" for panels\/main; use one of: file-text/); + manifest.contributes.panels[0].icon = 'file-text'; + manifest.version = 'v1.0.0'; + assert.throws(() => validate(manifest), /Invalid package version "v1\.0\.0"/); +}); +// `check` and `pack` read manifest.json bytes, which the desktop parses with +// serde_json. The cases above are parsed values and cannot express these. +test('manifest bytes are read as strictly as the desktop reads them', () => { + const text = JSON.stringify(base); + const settings = value => text.replace('"settings":[]', `"settings":[${value}]`); + const integer = fields => settings(`{"id":"count","type":"integer","label":"Count",${fields}}`); + const note = value => settings(`{"id":"note","type":"string","label":"Note","default":${value}}`); + const invalidUtf8 = Buffer.from(text.replace('Hello', 'Hel#lo')); + invalidUtf8[invalidUtf8.indexOf('#')] = 0xff; + const cases = [ + ['base', text, 'valid'], + ['fractional manifestVersion', text.replace('"manifestVersion":1', '"manifestVersion":1.0'), 'invalid'], + ['exponent manifestVersion', text.replace('"manifestVersion":1', '"manifestVersion":1e0'), 'invalid'], + ['integer setting', integer('"default":-5,"min":-10,"max":10'), 'valid'], + ['exponent bound', integer('"default":5,"min":0,"max":1e6'), 'invalid'], + ['fractional default', integer('"default":5.0,"min":0,"max":10'), 'invalid'], + ['negative zero', integer('"default":5,"min":-0,"max":10'), 'invalid'], + ['i64 extremes', integer('"default":9007199254740993,"min":-9223372036854775808,"max":9223372036854775807'), 'valid'], + ['above i64', integer('"default":5,"min":0,"max":9223372036854775808'), 'invalid'], + ['below i64', integer('"default":5,"min":-9223372036854775809,"max":10'), 'invalid'], + ['default above max past 2^53', integer('"default":9007199254740993,"min":0,"max":9007199254740992'), 'invalid'], + ['surrogate pair default', note('"\\ud83d\\ude00"'), 'valid'], + ['lone surrogate default', note('"\\ud800"'), 'invalid'], + ['lone surrogate repository', text.replace('"https://github.com/example/hello"', '"https://github.com/example/\\udc00"'), 'invalid'], + ['duplicate key', text.replace('"name":"Hello"', '"name":"Hello","name":"Hello"'), 'invalid'], + ['duplicate setting tag', settings('{"id":"on","type":"boolean","type":"boolean","label":"On","default":true}'), 'invalid'], + ['invalid UTF-8', invalidUtf8, 'invalid'], + ['byte order mark', Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(text)]), 'invalid'], + ]; + for (const [name, bytes, expect] of cases) { + let result = 'valid'; + try {validate(parse(Buffer.from(bytes)));} catch {result = 'invalid';} + assert.equal(result, expect, name); + } + // Parsed values carry no syntax, but must still fit the desktop's types. + const manifest = structuredClone(base); + manifest.settings = [{id: 'count', type: 'integer', label: 'Count', default: 0, min: 0, max: 2 ** 63}]; + assert.throws(() => validate(manifest), /outside the signed 64-bit range/); + manifest.settings = [{id: 'note', type: 'string', label: 'Note', default: '\ud800'}]; + assert.throws(() => validate(manifest), /not valid Unicode/); + manifest.settings = []; + // URL parsing percent-encodes a lone surrogate, so https() checks it first. + for (const field of ['repository', 'author.url']) { + const copy = structuredClone(manifest); + apply(copy, '/' + field.replace('.', '/'), 'https://github.com/example/\udc00'); + assert.throws(() => validate(copy), /author\.url and repository must be HTTPS URLs/, field); + } +}); +// Node built without ICU rejects TextDecoder's `fatal` option. +test('manifest bytes are read without ICU', () => { + const {TextDecoder} = globalThis; + globalThis.TextDecoder = class extends TextDecoder { + constructor(label, options) { + if (options?.fatal) throw Object.assign(new TypeError('"fatal" option is not supported on Node.js compiled without ICU'), {code: 'ERR_NO_ICU'}); + super(label, options); + } + }; + try { + assert.equal(validate(parse(Buffer.from(JSON.stringify(base)))).id, base.id); + assert.throws(() => parse(Buffer.from([0x7b, 0xff, 0x7d])), /not valid UTF-8/); + } finally {globalThis.TextDecoder = TextDecoder;} +}); diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 252e22af..c41849aa 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -7,32 +7,193 @@ package is an implementation preview, not a declaration that release gates passe Build this package with `npm ci --ignore-scripts && npm run build`, then `npm pack --ignore-scripts`. Install the resulting tarball into your author project alongside a packed `@codemux/plugin-cli`. No app checkout is required there. +`codemux-plugin init` creates a starter project; this README is the API reference. ```tsx -import {definePlugin, Stack, Text, Button} from '@codemux/plugin-sdk'; +import {definePlugin, Stack, Text, Button, PluginError} from '@codemux/plugin-sdk'; export default definePlugin({activate(ctx) { ctx.commands.register('open', context => ctx.panels.open('brief', context)); - ctx.panels.register('brief', ({context}) => + ctx.panels.register('brief', () => A separately distributed plugin - + ); }}); ``` -Declare each ID in `manifest.json` before registration, and declare -`composer.append` for this example. Each registration returns a disposer. The app -owns final cleanup regardless of author cleanup failures. Settings and storage -are private to an installation; credentials never enter JS. UI events supply -opaque context handles, including expiring single-use user interactions. - -All host methods return promises and reject with `PluginError.code`. Handle -`NO_COMPOSER`, `CONTEXT_STALE`, `INTERACTION_REQUIRED`, missing credentials and -network failures in the plugin UI. Never save a context handle in storage. Use the -new handle supplied by workspace changes, commands, or UI events. - -The public operation and permission contracts are in the engineering specification -and `dist/types.d.ts`; `schema/manifest.json` is generated from the Rust contract. -The desktop performs semantic validation in addition to schema validation. -Components accept token-based properties, never CSS or arbitrary HTML. Hooks are -Preact hooks. Browser libraries requiring a real browser DOM are unsupported. +The project needs `manifest.json`, `README.md`, `LICENSE` and the entry file +`src/index.tsx`, whose default export is the `definePlugin` result. Declare each +ID in `manifest.json` before registering it, and declare `composer.append` for +this example. The bundle contains all dependencies; there are no runtime imports, +`process`, `require`, `fetch` or browser DOM. Browser libraries that need a real +DOM are unsupported. Hooks (`useState`, `useEffect`, `useMemo`, `useCallback`, +`useRef`, `useReducer`) are Preact hooks. `schema/manifest.json` is generated from +the desktop's Rust contract; the desktop also validates the rules below. + +## Manifest + +| Field | Rules | +| --- | --- | +| `format`, `manifestVersion`, `entry` | `"codemux.feature-plugin"`, `1`, `"plugin.js"` | +| `id` | `publisher.name`; each part 2–40 of `a-z 0-9 -`, starting with a letter; not a Windows device name (`con`, `prn`, `aux`, `nul`, `com1`–`com9`, `lpt1`–`lpt9`). Fixed once users install it | +| `name`, `description`, `author.name`, `license` | 1–80, 1–240, 1–80 and 1–100 characters, no control characters | +| `author.url`, `repository` | HTTPS URLs without credentials | +| `version` | SemVer such as `1.0.0` or `1.1.0-beta.1`; no `v` prefix or spaces | +| `api` | Comma-separated comparators that include `1.0.0`, such as `^1.0.0` or `>=1.0.0, <2.0.0`. A bare version means `^`. `\|\|`, hyphen ranges and space-separated comparators are rejected | +| `platforms` | One or both of `linux-x64`, `windows-x64` | +| `permissions` | `workspace.read`, `git.read`, `composer.append`, `external.open` | +| `http` | Up to 20 `{origin, methods, credential}`. `origin` is an exact lowercase `https://host` on port 443: no path, port, wildcard, IP address or trailing dot. `methods` from `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. `credential` is `null` or a credential ID for the same origin | +| `credentials` | Up to 20 `{id, label, origin, type: "bearer"}`, one per origin, each referenced by its origin's `http` entry. The user enters the token in Settings; plugins never read it | +| `contributes` | `commands` (≤20, `{id, title, requiresWorkspace}`), `panels` (≤8), `composerActions` (≤8) and `composerViews` (≤4), each `{id, title, icon}`. IDs are 1–40 of `a-z 0-9 -` starting with a letter; titles 1–80 characters | +| `settings` | Up to 50 `{id, type, label, default}` with `type` `boolean`, `string` (≤4 KiB, default `""`), `integer` (also `min` and `max`) or `enum` (also 1–50 `values` containing the default) | + +The whole manifest is at most 64 KiB. Icons are `file-text`, `git-branch`, +`github`, `list`, `check`, `info`, `settings`, `book-open`, `link`, `refresh-cw`, +`plus`, `circle-alert`, `folder`, `terminal`, `code` and `search`, also exported as +`ICONS` and the `IconName` type. A package holds `manifest.json`, `plugin.js` +(≤5 MiB), `README.md`, `LICENSE` and optional `NOTICE` and `source.map`. + +## Contributions and context + +`activate(ctx)` registers a callback for every declared contribution before it +returns (or before its promise resolves). An undeclared or duplicate ID throws, +and a declared ID left unregistered fails activation. + +| Registration | Callback | +| --- | --- | +| `ctx.commands.register(id, handler)` | `(context) => void \| Promise`; shown in the command palette | +| `ctx.composerActions.register(id, handler)` | Same shape; shown in the composer's add-on menu | +| `ctx.panels.register(id, render)` | `({viewId, context}) => children`; a right-panel view | +| `ctx.composerViews.register(id, render)` | Same shape; an accessory above the composer | + +Each registration returns a disposer. Contributions are fixed for a running +plugin generation, so disposing after activation only stops the callback: the +entry stays listed, invoking a disposed command does nothing, and a disposed view +renders empty. Registering the same ID again restores it. Workspace and settings +subscriptions also return disposers. The app owns final cleanup regardless of +author cleanup failures. + +A `ContextHandle` is an opaque string checked by the desktop. Views receive one +bound to their workspace and composer. Commands run from the palette, and UI +events such as `onPress`, carry a fresh handle that also holds a single-use user +interaction, valid for 10 seconds and not extended by awaiting other work. Pass +the handle from the event that caused an action; never store handles. When the +project changes, mounted views are unmounted and `workspace.subscribe` callbacks +receive the new handle (or `null`). An idle plugin stops after 60 seconds without +a mounted view, so keep state that must survive in storage. + +## Host API + +Every method returns a promise. Failures reject with `PluginError`, whose `code` +is one of `PERMISSION_DENIED`, `CONTEXT_STALE`, `NO_WORKSPACE`, +`REMOTE_UNSUPPORTED`, `NO_COMPOSER`, `INTERACTION_REQUIRED`, `NOT_A_GIT_REPO`, +`INCOMPATIBLE_API`, `RESOURCE_LIMIT`, `TIMEOUT`, `PLUGIN_STOPPED`, +`INVALID_MESSAGE`, `CREDENTIAL_REQUIRED`, `NETWORK_DENIED` or +`STORAGE_UNAVAILABLE`, with a readable `message`. + +| Method | Result | Requires | Typical errors | +| --- | --- | --- | --- | +| `workspace.current(context)` | `{id, name, rootName, location: 'local'}` or `null`; never a path | `workspace.read` | `CONTEXT_STALE`, `REMOTE_UNSUPPORTED` | +| `workspace.subscribe(callback)` | Disposer; `callback(context \| null)` on project change | `workspace.read` | — | +| `git.summary(context)` | `{branch \| null, ahead, behind, staged, unstaged, untracked, conflicts, paths, truncated}`; up to 500 relative paths | `git.read` | `NOT_A_GIT_REPO`, `NO_WORKSPACE`, `TIMEOUT` | +| `panels.open(id, context)` | Opens one of this plugin's panels | Live interaction | `INTERACTION_REQUIRED`, `CONTEXT_STALE`, `NO_WORKSPACE` | +| `composerViews.open(id, context)` | Opens this plugin's accessory on the bound composer | Live interaction | `NO_COMPOSER`, `INTERACTION_REQUIRED` | +| `composer.appendText(context, text)` | Appends up to 32 KiB to the bound draft, keeping its content; resolves to the draft revision | `composer.append`, live interaction | `NO_COMPOSER`, `INTERACTION_REQUIRED`, `CONTEXT_STALE` | +| `settings.get()` | Current values of the declared settings, defaults included | — | — | +| `settings.subscribe(callback)` | Disposer; `callback(settings)` after the user changes them | — | — | +| `storage.get(scope, key)` / `set(scope, key, value)` / `delete(scope, key)` | JSON or `null`; `set` and `delete` resolve after the write commits | — | `STORAGE_UNAVAILABLE`, `RESOURCE_LIMIT`, `NO_WORKSPACE` | +| `http.fetch(context, {origin, path, method, headers?, body?})` | `{status, headers, body}` | A matching `http` entry | `NETWORK_DENIED`, `CREDENTIAL_REQUIRED`, `RESOURCE_LIMIT`, `TIMEOUT` | +| `links.open(url, context)` | Opens an HTTPS URL in the browser | `external.open`, live interaction | `INTERACTION_REQUIRED`, `NETWORK_DENIED` | +| `ui.notify(message)` | Shows a short attributed notification (≤500 characters) | — | `RESOURCE_LIMIT` after three per minute | + +Storage `scope` is `{scope: 'global'}` or `{scope: 'workspace', context}`. Keys +are at most 128 ASCII characters, values at most 64 KiB of JSON, and each plugin +at most 5 MiB. `http.fetch` takes an absolute path starting with one `/` (query +included), a request body up to 256 KiB, and safe request headers; the desktop +attaches the declared credential when the user configured one. It never follows +redirects, returns only `content-type`, `etag`, `retry-after` and `x-ratelimit-*` +response headers, and limits bodies to 512 KiB, four concurrent requests and +10 MiB per minute. During `activate` a plugin may register, read settings and use +storage, but network, notifications, panels and composer calls are refused. + +## Faults and limits + +A stable host rejection is an ordinary outcome. When a command handler, composer +action, UI callback or subscription callback returns a promise that rejects with +a `PluginError`, the SDK records a short diagnostic and the plugin keeps running. +Still, catch errors where users need to see them, as the example above does. A +click can also cross an update: an event for a callback that a newer render +removed, or for a view that has closed, is ignored with a short diagnostic. + +Everything else is a runtime fault. A synchronous throw from any callback or +render, a promise that rejects with anything other than a `PluginError`, an +unhandled rejection elsewhere (for example an uncaught promise in `useEffect` or +a timer), invalid UI and exceeded resources stop the plugin. It is disabled until +the user chooses Retry in Settings → Add-ons; it is never restarted +automatically. + +| Resource | Limit | +| --- | --- | +| Host requests | 16 outstanding; 15 s timeout (30 s for HTTP); 20 per second with a burst of 20, and 100 per minute | +| UI updates | 30 batches per second, 1,000 mutations per batch, four mounted views | +| Views | 2,000 nodes, depth 32, 256 KiB of serialized state each | +| Console | Five entries per second, up to 1,024 characters each; the app records only their size | +| Execution | 250 ms per callback (1 s during activation), 1 s of JS per rolling 5 s, 64 MiB heap | +| Timers | 128 live timers; intervals of at least 100 ms | + +The SDK paces its own traffic below these quotas. Requests beyond the +per-second rate wait briefly in order; once the per-minute budget is spent they +reject locally with `RESOURCE_LIMIT` instead of reaching the host. UI updates +are coalesced into at most about 16 batches per second, keeping only the latest +value of each property, and console entries above the rate are dropped. Rapid +input therefore never stops a plugin, but avoid a host request per keystroke; +save on blur, on a button, or after a pause. + +## Components + +Components accept token properties, never CSS or HTML. Each has its own props +type (`StackProps`, `TextFieldProps`, ...); values outside these ranges fail type +checking and are rejected by the desktop, which stops the plugin. + +| Component | Props | +| --- | --- | +| `Stack` | `direction` (`vertical` \| `horizontal`), `spacing` (`none`, `xs`, `sm`, `md`, `lg`), `align` (`start`, `center`, `end`, `stretch`) | +| `Grid` | `columns` (1–12), `spacing`, `align` | +| `Card` | `title`, `spacing`, `align` | +| `Text`, `Badge` | Text children | +| `Heading` | `level` (1–6), text children | +| `Markdown` | Markdown text children; HTML and images are dropped, and HTTPS links open through the host (`external.open`) | +| `Button` | `label` or children, `disabled`, `onPress` | +| `TextField`, `TextArea` | `label`, `value`, `placeholder`, `disabled`, `onChange` (`event.value` is a string) | +| `Select` | `label`, `options` (`{label, value}[]`), `value`, `disabled`, `onChange` | +| `Checkbox`, `Switch` | `label`, `checked`, `disabled`, `onChange` (`event.value` is a boolean) | +| `Tabs` | `label`, `options`, `value`, `disabled`, `onChange`; children are the selected panel | +| `List` | `items` (up to 500 strings), `label` (the accessible name) | +| `Table` | `headers`, `rows` (up to 500 rows of up to 20 strings), `label` | +| `Progress` | `value`, `max`, `label` | +| `Icon` | `name` (an `IconName`), `label`, `color` | +| `Divider` | — | +| `EmptyState` | `title`, children | + +All components except `Icon` and `Divider` also take `size` (`xs`–`lg`), `color` +(`default`, `muted`, `success`, `warning`, `danger`, `accent`), `width` and +`height` (`auto`, `full`). Strings are limited to 4 KiB (32 KiB for text and +field values). Every callback receives `{context, value}`. + +`TextField` and `TextArea` keep the text locally while the user types and report +every change through `onChange`. A `value` you render replaces that text only +when it differs from the last value the field reported, so echoing the value +back never moves the caret or drops keystrokes, and a field without `value` is +uncontrolled. `Select`, `Checkbox`, `Switch` and `Tabs` show the `value` or +`checked` you render, so update your state in `onChange`. + +A batch that breaks a rendering limit is rejected whole and stops the plugin; +intermediate states must fit as well. Native validation has a 50 ms budget per +batch. Prefer small updates to existing content over replacing large subtrees. +These ceilings are bounds, not a guaranteed update rate on every machine. diff --git a/packages/plugin-sdk/package-lock.json b/packages/plugin-sdk/package-lock.json index 3a851b31..032067d5 100644 --- a/packages/plugin-sdk/package-lock.json +++ b/packages/plugin-sdk/package-lock.json @@ -9,8 +9,13 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@preact/signals": "1.3.4", + "@preact/signals-core": "1.14.4", "@remote-dom/core": "1.11.1", + "@remote-dom/polyfill": "1.5.1", "@remote-dom/preact": "1.3.0", + "@remote-dom/signals": "2.1.1", + "htm": "3.1.1", "preact": "10.29.8" }, "devDependencies": { @@ -22,7 +27,6 @@ "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.3.4.tgz", "integrity": "sha512-TPMkStdT0QpSc8FpB63aOwXoSiZyIrPsP9Uj347KopdS6olZdAYeeird/5FZv/M1Yc1ge5qstub2o8VDbvkT4g==", "license": "MIT", - "peer": true, "dependencies": { "@preact/signals-core": "^1.7.0" }, @@ -39,7 +43,6 @@ "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index fecd5a41..f16fd11e 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,8 +1,12 @@ { "name": "@codemux/plugin-sdk", "version": "1.0.0", "type": "module", "license": "MIT", + "description": "SDK for CodeMux feature plugins: Preact components and host APIs for the isolated plugin runtime.", + "repository": {"type":"git", "url":"git+https://github.com/Zeus-Deus/codemux.git", "directory":"packages/plugin-sdk"}, + "homepage": "https://github.com/Zeus-Deus/codemux/tree/main/packages/plugin-sdk#readme", + "publishConfig": {"access":"public"}, "exports": {".": {"types":"./dist/index.d.ts", "default":"./dist/index.js"}, "./runtime":"./dist/runtime.js", "./schema":"./schema/manifest.json"}, "files": ["dist", "schema", "README.md", "LICENSE"], "scripts": {"build":"tsc -p tsconfig.json", "check":"tsc -p tsconfig.json --noEmit"}, - "dependencies": {"@remote-dom/core":"1.11.1", "@remote-dom/preact":"1.3.0", "preact":"10.29.8"}, + "dependencies": {"@preact/signals":"1.3.4", "@preact/signals-core":"1.14.4", "@remote-dom/core":"1.11.1", "@remote-dom/polyfill":"1.5.1", "@remote-dom/preact":"1.3.0", "@remote-dom/signals":"2.1.1", "htm":"3.1.1", "preact":"10.29.8"}, "devDependencies": {"typescript":"5.6.2"} } diff --git a/packages/plugin-sdk/schema/manifest.json b/packages/plugin-sdk/schema/manifest.json index 0fa9b66a..6511c713 100644 --- a/packages/plugin-sdk/schema/manifest.json +++ b/packages/plugin-sdk/schema/manifest.json @@ -35,60 +35,85 @@ "type": "array", "items": { "$ref": "#/definitions/Credential" - } + }, + "maxItems": 20 }, "description": { - "type": "string" + "type": "string", + "maxLength": 240, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "entry": { - "type": "string" + "type": "string", + "enum": [ + "plugin.js" + ] }, "format": { - "type": "string" + "type": "string", + "enum": [ + "codemux.feature-plugin" + ] }, "http": { "type": "array", "items": { "$ref": "#/definitions/HttpGrant" - } + }, + "maxItems": 20 }, "id": { - "type": "string" + "type": "string", + "pattern": "^(?!(?:con|prn|aux|nul|com[0-9]|lpt[0-9])\\.)[a-z][a-z0-9-]{1,39}\\.(?!(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$)[a-z][a-z0-9-]{1,39}$" }, "license": { - "type": "string" + "type": "string", + "maxLength": 100, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "manifestVersion": { "type": "integer", "format": "uint32", - "minimum": 0.0 + "maximum": 1.0, + "minimum": 1.0 }, "name": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "permissions": { "type": "array", "items": { "$ref": "#/definitions/Permission" - } + }, + "uniqueItems": true }, "platforms": { "type": "array", "items": { "$ref": "#/definitions/Platform" - } + }, + "minItems": 1, + "uniqueItems": true }, "repository": { - "type": "string" + "type": "string", + "pattern": "^https://" }, "settings": { "type": "array", "items": { "$ref": "#/definitions/Setting" - } + }, + "maxItems": 50 }, "version": { - "type": "string" + "type": "string", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\\+[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*)?$" } }, "additionalProperties": false, @@ -101,10 +126,14 @@ ], "properties": { "name": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "url": { - "type": "string" + "type": "string", + "pattern": "^https://" } }, "additionalProperties": false @@ -118,13 +147,17 @@ ], "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "requiresWorkspace": { "type": "boolean" }, "title": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" } }, "additionalProperties": false @@ -142,25 +175,29 @@ "type": "array", "items": { "$ref": "#/definitions/Command" - } + }, + "maxItems": 20 }, "composerActions": { "type": "array", "items": { "$ref": "#/definitions/View" - } + }, + "maxItems": 8 }, "composerViews": { "type": "array", "items": { "$ref": "#/definitions/View" - } + }, + "maxItems": 4 }, "panels": { "type": "array", "items": { "$ref": "#/definitions/View" - } + }, + "maxItems": 8 } }, "additionalProperties": false @@ -175,13 +212,18 @@ ], "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "origin": { - "type": "string" + "type": "string", + "pattern": "^https://(?!.*\\.(?:[0-9]+|0x[0-9a-f]*)$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" }, "type": { "$ref": "#/definitions/CredentialType" @@ -213,10 +255,13 @@ "type": "array", "items": { "$ref": "#/definitions/HttpMethod" - } + }, + "minItems": 1, + "uniqueItems": true }, "origin": { - "type": "string" + "type": "string", + "pattern": "^https://(?!.*\\.(?:[0-9]+|0x[0-9a-f]*)$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" } }, "additionalProperties": false @@ -262,10 +307,14 @@ "type": "boolean" }, "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "type": { "type": "string", @@ -286,13 +335,18 @@ "properties": { "default": { "default": "", - "type": "string" + "type": "string", + "maxLength": 4096 }, "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "type": { "type": "string", @@ -319,10 +373,14 @@ "format": "int64" }, "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "max": { "type": "integer", @@ -355,10 +413,14 @@ "type": "string" }, "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "label": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" }, "type": { "type": "string", @@ -369,8 +431,14 @@ "values": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 4096, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" + }, + "maxItems": 50, + "minItems": 1, + "uniqueItems": true } }, "additionalProperties": false @@ -386,13 +454,35 @@ ], "properties": { "icon": { - "type": "string" + "type": "string", + "enum": [ + "file-text", + "git-branch", + "github", + "list", + "check", + "info", + "settings", + "book-open", + "link", + "refresh-cw", + "plus", + "circle-alert", + "folder", + "terminal", + "code", + "search" + ] }, "id": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,39}$" }, "title": { - "type": "string" + "type": "string", + "maxLength": 80, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]*$" } }, "additionalProperties": false diff --git a/packages/plugin-sdk/src/runtime.ts b/packages/plugin-sdk/src/runtime.ts index 8f1f4b78..78282a51 100644 --- a/packages/plugin-sdk/src/runtime.ts +++ b/packages/plugin-sdk/src/runtime.ts @@ -4,6 +4,7 @@ import { RemoteReceiver } from "@remote-dom/core/receivers"; import { h, render, options } from "preact"; import { PluginError, + type ErrorCode, type Plugin, type PluginContext, type ContextHandle, @@ -14,6 +15,49 @@ import { options.debounceRendering = (fn) => Promise.resolve().then(fn); customElements.define("cmx-root", RemoteRootElement); type Kind = "commands" | "panels" | "composerActions" | "composerViews"; +// Stable host errors are ordinary outcomes of a handler or UI callback. +const handled: Record = { + PERMISSION_DENIED: true, + CONTEXT_STALE: true, + NO_WORKSPACE: true, + REMOTE_UNSUPPORTED: true, + NO_COMPOSER: true, + INTERACTION_REQUIRED: true, + NOT_A_GIT_REPO: true, + INCOMPATIBLE_API: true, + RESOURCE_LIMIT: true, + TIMEOUT: true, + PLUGIN_STOPPED: true, + INVALID_MESSAGE: true, + CREDENTIAL_REQUIRED: true, + NETWORK_DENIED: true, + STORAGE_UNAVAILABLE: true, +}; +// Sliding windows kept stricter than the native quotas. The parent counts on +// arrival, so longer windows and lower counts absorb pipe jitter, millisecond +// rounding and the 100 ms timer floor: a paced plugin never reaches a quota. +function limiter(windows: [number, number][]) { + const span = Math.max(...windows.map(([ms]) => ms)); + let times: number[] = []; + return { + // Earliest time one more event fits behind `queued` earlier FIFO events. + next(t: number, queued = 0) { + const list = times.filter((x) => x > t - span); + let at = t; + for (let n = 0; n <= queued; n++) { + for (const [ms, count] of windows) + if (list.length >= count) + at = Math.max(at, list[list.length - count] + ms); + list.push(at); + } + return at; + }, + take(t: number) { + times = times.filter((x) => x > t - span); + times.push(t); + }, + }; +} interface Manifest { id: string; contributes: Record; @@ -44,8 +88,10 @@ export function register(plugin: Plugin) { function adapter({ manifest, send, now }: Transport) { let seq = 0, callbackSeq = 0, - stopped = false; - const handlers = new Map(); + stopped = false, + activated = false; + // A null entry is a registration disposed after activation. + const handlers = new Map(); const workspaceListeners = new Set<(c: ContextHandle | null) => void>(); const settingsListeners = new Set<(s: Record) => void>(); const pending = new Map< @@ -63,8 +109,78 @@ function adapter({ manifest, send, now }: Transport) { receiver: RemoteReceiver; callbacks: Map; ids: Map; + acknowledge(revision: number): void; + flush(): void; } >(); + // Host quotas: 20 requests/s and 100/min, 30 UI batches/s, 5 logs/s. + const requestQuota = limiter([ + [1200, 18], + [62000, 95], + ]); + const uiQuota = limiter([ + [250, 4], + [1200, 20], + ]); + const logQuota = limiter([ + [1200, 4], + [61000, 240], + ]); + const waiting: (() => void)[] = []; + let requestTimer: ReturnType | undefined; + let uiTimer: ReturnType | undefined; + const pace = (quota: ReturnType) => { + const t = now(); + if (quota.next(t) > t) return false; + quota.take(t); + return true; + }; + // Excess diagnostics are dropped locally rather than sent over quota. + const diagnose = (message: string) => { + if (pace(logQuota)) send("log", { message: message.slice(0, 1024) }); + }; + try { + const native = globalThis.console as unknown as Record< + string, + (...args: unknown[]) => void + >; + globalThis.console = Object.freeze( + Object.fromEntries( + ["log", "info", "warn", "error", "debug"].map((level) => [ + level, + (...args: unknown[]) => { + if (pace(logQuota)) native[level](...args); + }, + ]), + ), + ) as unknown as Console; + } catch { + // A host that freezes console keeps its own bounded logging. + } + // Report a stable host rejection from an author callback and keep running. + // Synchronous throws and any other rejection remain runtime faults. + const settle = (result: unknown, source: string) => { + if (!result || typeof (result as PromiseLike).then !== "function") + return; + Promise.resolve(result).then(undefined, (error: unknown) => { + if (!(error instanceof PluginError) || handled[error.code] !== true) + throw error; + diagnose(`${source} rejected with ${error.code}: ${error.message}`); + }); + }; + const pump = () => { + requestTimer = undefined; + while (waiting.length && !stopped) { + const t = now(), + at = requestQuota.next(t); + if (at > t) { + requestTimer = setTimeout(pump, at - t); + return; + } + requestQuota.take(t); + waiting.shift()!(); + } + }; function request(operation: string, params: unknown): Promise { if (stopped) return Promise.reject( @@ -74,6 +190,17 @@ function adapter({ manifest, send, now }: Transport) { return Promise.reject( new PluginError("RESOURCE_LIMIT", "Too many outstanding requests"), ); + // Short bursts wait for the per-second window. A request that could only + // go once the per-minute window drains is rejected before it reaches the + // host. + const t = now(); + if (requestQuota.next(t, waiting.length) - t > 1500) + return Promise.reject( + new PluginError( + "RESOURCE_LIMIT", + "Too many host requests in the last minute; try again later", + ), + ); const id = ++seq; return new Promise((resolve, reject) => { const timer = setTimeout( @@ -84,7 +211,10 @@ function adapter({ manifest, send, now }: Transport) { operation === "http.fetch" ? 30000 : 15000, ); pending.set(id, { resolve, reject, timer }); - send("host.request", { operation, params }, id); + waiting.push(() => { + if (pending.has(id)) send("host.request", { operation, params }, id); + }); + if (!requestTimer) pump(); }); } function registration(kind: Kind) { @@ -92,7 +222,7 @@ function adapter({ manifest, send, now }: Transport) { const key = kind + "/" + id; if ( !manifest.contributes[kind].some((d) => d.id === id) || - handlers.has(key) + handlers.get(key) ) throw new PluginError( "INVALID_MESSAGE", @@ -100,7 +230,11 @@ function adapter({ manifest, send, now }: Transport) { ); handlers.set(key, handler); return () => { - handlers.delete(key); + if (handlers.get(key) !== handler) return; + // Contributions are fixed once the host has the registration list; + // disposing afterwards only stops callbacks for this generation. + if (activated) handlers.set(key, null); + else handlers.delete(key); }; }; } @@ -154,10 +288,10 @@ function adapter({ manifest, send, now }: Transport) { const unmount = (viewId: string) => { const view = views.get(viewId); if (!view) return; + views.delete(viewId); render(null, view.root); view.callbacks.clear(); view.ids.clear(); - views.delete(viewId); }; const fail = () => { throw new PluginError("INVALID_MESSAGE", "Invalid runtime operation"); @@ -182,6 +316,7 @@ function adapter({ manifest, send, now }: Transport) { for (const kind of Object.keys(manifest.contributes) as Kind[]) for (const { id } of manifest.contributes[kind]) if (!handlers.has(kind + "/" + id)) fail(); + activated = true; send("ready", { phase: "activated", registrations: [...handlers.keys()], @@ -192,24 +327,41 @@ function adapter({ manifest, send, now }: Transport) { case "command.execute": { const kind = p.kind === "composerActions" ? "composerActions" : "commands"; - const handler = handlers.get(kind + "/" + p.id) as Handler | undefined; - if (!handler) fail(); - Promise.resolve(handler!(p.context)).catch(fail); + const key = kind + "/" + p.id; + if (!handlers.has(key)) fail(); + const handler = handlers.get(key) as Handler | null; + if (!handler) diagnose(`${key} was disposed; the call was ignored`); + else settle(handler(p.context), key); break; } case "view.mount": { - if (views.size >= 4 || views.has(p.viewId)) - throw new PluginError("RESOURCE_LIMIT", "View limit"); + // The desktop enforces the view limit and issues each view ID once. It + // can send a mount before the unmount that freed its slot, so views + // beyond the limit here are not a fault. + if (views.has(p.viewId)) fail(); const kind = p.kind === "composerViews" ? "composerViews" : "panels"; - const renderer = handlers.get(kind + "/" + p.id) as - | ViewRenderer - | undefined; - if (!renderer) fail(); + const key = kind + "/" + p.id; + if (!handlers.has(key)) fail(); + const renderer = handlers.get(key) as ViewRenderer | null; const root = document.createElement("cmx-root") as RemoteRootElement; const receiver = new RemoteReceiver(); const callbacks = new Map(); const ids = new Map(); - views.set(p.viewId, { root, receiver, callbacks, ids }); + let sentRevision = 0; + let acknowledgedRevision = 0; + views.set(p.viewId, { + root, + receiver, + callbacks, + ids, + acknowledge(revision) { + if (!Number.isSafeInteger(revision) || revision > sentRevision) + fail(); + acknowledgedRevision = Math.max(acknowledgedRevision, revision); + flush(); + }, + flush: () => flush(), + }); // Preact can emit many individual mutations during one commit. Batch one // microtask, with a bound before enqueueing, so native validation sees the // commit atomically rather than consuming the two-batch queue per node. @@ -217,9 +369,28 @@ function adapter({ manifest, send, now }: Transport) { let scheduled = false; const flush = () => { scheduled = false; + if (!views.has(p.viewId)) { + queued = []; + return; + } + // One batch in flight; keep ordered, bounded mutations until the + // trusted renderer acknowledges it. Slow rendering is not a fault. + if (acknowledgedRevision < sentRevision || queued.length === 0) + return; + // All views share the plugin's batch rate. While paced, records keep + // coalescing and one shared timer retries every view. + const t = now(), + at = uiQuota.next(t); + if (at > t) { + uiTimer ??= setTimeout(() => { + uiTimer = undefined; + for (const view of views.values()) view.flush(); + }, at - t); + return; + } + uiQuota.take(t); const records = queued; queued = []; - if (records.length === 0) return; receiver.connection.mutate(records); const live = new Set(); const visit = (v: any): void => { @@ -296,17 +467,31 @@ function adapter({ manifest, send, now }: Transport) { return { callbackId: id }; }), ); - if ( - [...views.values()].reduce((n, v) => n + v.callbacks.size, 0) > 4096 - ) - throw new PluginError("RESOURCE_LIMIT", "Callback limit"); + // The desktop bounds live callbacks over the views it holds. A view + // it has dropped can still be here, so no local total is enforced. + sentRevision++; send("ui.patch", { viewId: p.viewId, records: serialized }); }; root.connect({ mutate(records) { - if (queued.length + records.length > 1000) + if (!views.has(p.viewId)) return; + for (const record of records) { + // Only the latest text or property value of a node matters while + // a batch waits, so held input cannot exhaust the mutation bound. + if (record[0] === 2 || record[0] === 3) { + const index = queued.findIndex( + (r) => + r[0] === record[0] && + r[1] === record[1] && + (r[0] === 2 || + (r[2] === record[2] && (r[4] ?? 1) === (record[4] ?? 1))), + ); + if (index >= 0) queued.splice(index, 1); + } + queued.push(record); + } + if (queued.length > 1000) throw new PluginError("RESOURCE_LIMIT", "Mutation limit"); - queued.push(...records); if (!scheduled) { scheduled = true; Promise.resolve().then(flush); @@ -319,24 +504,50 @@ function adapter({ manifest, send, now }: Transport) { ); }, }); - render(h(renderer!, { viewId: p.viewId, context: p.context }), root); + if (renderer) + render(h(renderer, { viewId: p.viewId, context: p.context }), root); + else diagnose(`${key} was disposed; the view stays empty`); break; } case "view.unmount": unmount(p.viewId); break; + case "ui.ack": + views.get(p.viewId)?.acknowledge(p.revision); + break; case "ui.event": { - const fn = views.get(p.viewId)?.callbacks.get(p.callbackId); - if (!fn) fail(); - fn!({ context: p.context, value: p.value }); + if ( + typeof p?.viewId !== "string" || + typeof p.callbackId !== "string" || + typeof p.context !== "string" || + (p.value != null && + typeof p.value !== "string" && + typeof p.value !== "boolean") + ) + fail(); + // The desktop checks each event against the tree it has applied, so an + // event can still cross the patch that released its callback, or an + // unmount. That is a stale click, not a fault: ignore it. + const view = views.get(p.viewId); + const fn = view?.callbacks.get(p.callbackId); + if (!fn) { + diagnose( + `A UI event for a ${view ? "released callback" : "closed view"} was ignored`, + ); + break; + } + // Remote DOM returns the author's promise through the event response. + settle(fn({ context: p.context, value: p.value }), "UI callback"); break; } case "workspace.changed": for (const id of views.keys()) unmount(id); - for (const callback of workspaceListeners) callback(p.context); + for (const callback of workspaceListeners) + settle(callback(p.context), "workspace.subscribe"); break; case "settings.changed": - for (const callback of settingsListeners) callback(p.settings); + for (const callback of settingsListeners) + settle(callback(p.settings), "settings.subscribe"); break; case "deactivate": { stopped = true; @@ -344,6 +555,9 @@ function adapter({ manifest, send, now }: Transport) { handlers.clear(); workspaceListeners.clear(); settingsListeners.clear(); + waiting.length = 0; + clearTimeout(requestTimer); + clearTimeout(uiTimer); for (const call of pending.values()) { clearTimeout(call.timer); call.reject(new PluginError("PLUGIN_STOPPED", "Plugin stopped")); diff --git a/packages/plugin-sdk/src/ui.ts b/packages/plugin-sdk/src/ui.ts index 6972e79f..779d451f 100644 --- a/packages/plugin-sdk/src/ui.ts +++ b/packages/plugin-sdk/src/ui.ts @@ -3,22 +3,55 @@ import {RemoteElement} from '@remote-dom/core/elements'; import {createRemoteComponent} from '@remote-dom/preact'; import {h, type ComponentChildren, type FunctionComponent} from 'preact'; import type {UiEvent} from './types.js'; +/** Icon names accepted by the desktop; any other name stops the plugin generation. */ +export const ICONS=['file-text','git-branch','github','list','check','info','settings','book-open','link','refresh-cw','plus','circle-alert','folder','terminal','code','search'] as const; +export type IconName=typeof ICONS[number]; +export type Spacing='none'|'xs'|'sm'|'md'|'lg'; +export type Columns=1|2|3|4|5|6|7|8|9|10|11|12; +export type HeadingLevel=1|2|3|4|5|6; +export type Size='xs'|'sm'|'md'|'lg'; +export type Color='default'|'muted'|'success'|'warning'|'danger'|'accent'; +export interface SelectOption {label:string; value:string} +export type UiCallback=(event:E)=>void|Promise; +/** Every property the native validator knows; each component accepts the subset it renders. */ export interface UiProps { children?:ComponentChildren; - spacing?:'none'|'xs'|'sm'|'md'|'lg'; direction?:'horizontal'|'vertical'; - columns?:number; align?:'start'|'center'|'end'|'stretch'; width?:'auto'|'full'; height?:'auto'|'full'; - size?:'xs'|'sm'|'md'|'lg'; color?:'default'|'muted'|'success'|'warning'|'danger'|'accent'; + spacing?:Spacing; direction?:'horizontal'|'vertical'; + columns?:Columns; align?:'start'|'center'|'end'|'stretch'; width?:'auto'|'full'; height?:'auto'|'full'; + size?:Size; color?:Color; label?:string; value?:string|number|boolean; placeholder?:string; disabled?:boolean; checked?:boolean; - title?:string; name?:string; level?:number; max?:number; - options?:{label:string; value:string}[]; rows?:string[][]; headers?:string[]; items?:string[]; - onPress?:(event:UiEvent)=>void|Promise; onChange?:(event:UiEvent)=>void|Promise; + title?:string; name?:IconName; level?:HeadingLevel; max?:number; + options?:SelectOption[]; rows?:string[][]; headers?:string[]; items?:string[]; + onPress?:UiCallback; onChange?:UiCallback; } +interface Styled {children?:ComponentChildren; size?:Size; color?:Color; width?:'auto'|'full'; height?:'auto'|'full'} +interface Layout extends Styled {spacing?:Spacing; align?:'start'|'center'|'end'|'stretch'} +export interface StackProps extends Layout {direction?:'horizontal'|'vertical'} +export interface GridProps extends Layout {columns?:Columns} +export interface CardProps extends Layout {title?:string} +export interface TextProps extends Styled {} +export interface HeadingProps extends Styled {level?:HeadingLevel} +export interface MarkdownProps extends Styled {} +export interface ButtonProps extends Styled {label?:string; disabled?:boolean; onPress?:UiCallback} +export interface TextFieldProps extends Styled {label?:string; value?:string; placeholder?:string; disabled?:boolean; onChange?:UiCallback} +export interface SelectProps extends Styled {label?:string; value?:string; options:SelectOption[]; disabled?:boolean; onChange?:UiCallback} +export interface CheckboxProps extends Styled {label?:string; checked?:boolean; disabled?:boolean; onChange?:UiCallback} +export interface TabsProps extends Styled {label?:string; value?:string; options:SelectOption[]; disabled?:boolean; onChange?:UiCallback} +export interface ListProps extends Styled {label?:string; items:string[]} +export interface TableProps extends Styled {label?:string; headers?:string[]; rows:string[][]} +export interface ProgressProps extends Styled {label?:string; value?:number; max?:number} +export interface IconProps {name:IconName; label?:string; color?:Color} +export interface EmptyStateProps extends Styled {title?:string} const propertyNames=['spacing','direction','columns','align','width','height','size','color','label','value','placeholder','disabled','checked','title','name','level','max','options','rows','headers','items']; -function component(name:string):FunctionComponent { +type Dispatched=CustomEvent&{respondWith?(response:unknown):void}; +function component

(name:string):FunctionComponent

{ const tag='cmx-'+name.replace(/[A-Z]/g,(c,i)=>(i?'-':'')+c.toLowerCase()); class Element extends RemoteElement, {}, {}, {press(detail:UiEvent):void; change(detail:UiEvent):void}> {static remoteProperties=propertyNames; static remoteEvents=['press','change'] as const;} customElements.define(tag,Element); const Remote=createRemoteComponent(tag as keyof HTMLElementTagNameMap,Element,{eventProps:{onPress:{event:'press'},onChange:{event:'change'}}}); - return props=>h(Remote,{...props,onPress:props.onPress ? (event:CustomEvent)=>props.onPress!(event.detail) : undefined,onChange:props.onChange ? (event:CustomEvent)=>props.onChange!(event.detail) : undefined} as never); + // Hand the callback's promise back to the SDK runtime, which reports stable + // host rejections instead of treating them as unhandled. + const forward=(callback:UiCallback|undefined)=>callback ? (event:Dispatched)=>event.respondWith?.(callback(event.detail as never)) : undefined; + return props=>{const {onPress,onChange}=props as {onPress?:UiCallback; onChange?:UiCallback}; return h(Remote,{...props,onPress:forward(onPress),onChange:forward(onChange)} as never);}; } -export const Stack=component('Stack'), Grid=component('Grid'), Card=component('Card'), Text=component('Text'), Heading=component('Heading'), Markdown=component('Markdown'), Button=component('Button'), TextField=component('TextField'), TextArea=component('TextArea'), Select=component('Select'), Checkbox=component('Checkbox'), Switch=component('Switch'), Tabs=component('Tabs'), List=component('List'), Table=component('Table'), Badge=component('Badge'), Progress=component('Progress'), Icon=component('Icon'), Divider=component('Divider'), EmptyState=component('EmptyState'); +export const Stack=component('Stack'), Grid=component('Grid'), Card=component('Card'), Text=component('Text'), Heading=component('Heading'), Markdown=component('Markdown'), Button=component('Button'), TextField=component('TextField'), TextArea=component('TextArea'), Select=component('Select'), Checkbox=component('Checkbox'), Switch=component('Switch'), Tabs=component('Tabs'), List=component('List'), Table=component('Table'), Badge=component('Badge'), Progress=component('Progress'), Icon=component('Icon'), Divider=component<{}>('Divider'), EmptyState=component('EmptyState'); diff --git a/packages/plugin-sdk/tests/dependency.ts b/packages/plugin-sdk/tests/dependency.ts new file mode 100644 index 00000000..e2979b31 --- /dev/null +++ b/packages/plugin-sdk/tests/dependency.ts @@ -0,0 +1,10 @@ +import { definePlugin } from "../src/index.js"; +// A CommonJS package with only a `main` field that reads process.env.NODE_ENV; +// the native test provides it in a temporary node_modules directory. +// @ts-expect-error: not a dependency of the SDK package +import dependency from "main-only"; +export default definePlugin({ + activate(ctx) { + ctx.commands.register("hello", () => ctx.ui.notify(dependency.mode)); + }, +}); diff --git a/packages/plugin-sdk/tests/policy.tsx b/packages/plugin-sdk/tests/policy.tsx new file mode 100644 index 00000000..b31ede0e --- /dev/null +++ b/packages/plugin-sdk/tests/policy.tsx @@ -0,0 +1,91 @@ +import { + definePlugin, + Stack, + Button, + TextField, + PluginError, + useState, +} from "../src/index.js"; +// Well-behaved and faulty callbacks for scripts/addons/sdk-native.mjs. +export default definePlugin({ + activate(ctx) { + ctx.commands.register("handled", async () => { + await ctx.ui.notify("handled"); + }); + ctx.commands.register("reject", async () => { + await Promise.resolve(); + throw new Error("Synthetic plain rejection"); + }); + ctx.commands.register("throw", () => { + throw new PluginError("NO_COMPOSER", "Synchronous throws stay faults"); + }); + const later = ctx.commands.register("later", async () => { + await ctx.ui.notify("later"); + }); + ctx.commands.register("dispose", () => later()); + ctx.commands.register("burst", async () => { + for (let n = 0; n < 40; n++) + await ctx.storage.set({ scope: "global" }, "burst", n); + }); + ctx.commands.register("exhaust", async () => { + for (let n = 0; n < 70; n++) + try { + await ctx.storage.set({ scope: "global" }, "exhaust", n); + } catch (error) { + if (error instanceof PluginError) + console.log("rejected " + error.code); + } + }); + ctx.commands.register("flood", () => { + for (let n = 0; n < 50; n++) console.log("line " + n); + }); + ctx.workspace.subscribe((context) => ctx.ui.notify("workspace " + context)); + const disposed = ctx.settings.subscribe(() => + ctx.ui.notify("disposed listener"), + ); + disposed(); + ctx.settings.subscribe((settings) => + ctx.ui.notify("settings " + JSON.stringify(settings)), + ); + ctx.panels.register("form", () => { + const [text, setText] = useState(""); + return ( + + setText(event.value)} + /> + + + ); + }); + // Each press replaces the button, releasing the previous callback. + ctx.panels.register("swap", () => { + const [round, setRound] = useState(0); + return ( + + - {plugin.tier === "official" && ( - - )} - -

- {plugin.description} -

-
- - -
- - ))} + + + ); + })} )}

Listings are reviewed contributions, not a guarantee against defects. - Offline devices learn new revocations when they reconnect. + While add-ons are installed, CodeMux rechecks the catalog about once a + day for revocations and updates. Offline devices learn new revocations + when they reconnect.

); diff --git a/src/components/addons/addon-renderer.test.tsx b/src/components/addons/addon-renderer.test.tsx index 0e00e601..4a24f611 100644 --- a/src/components/addons/addon-renderer.test.tsx +++ b/src/components/addons/addon-renderer.test.tsx @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { AddonNode } from "@/lib/addons/types"; -import { AddonRenderer } from "./addon-renderer"; +import { ADDON_ICON_NAMES, AddonRenderer } from "./addon-renderer"; afterEach(cleanup); const node = ( id: string, @@ -116,6 +119,31 @@ describe("trusted add-on rendering", () => { fireEvent.keyDown(second, { key: "Home" }); expect(document.activeElement).toBe(first); }); + it("does not submit a containing form when a plugin Markdown link is activated", () => { + const submit = vi.fn(); + const link = vi.fn(); + render( +
{ + event.preventDefault(); + submit(); + }} + > + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + expect(link).toHaveBeenCalledOnce(); + expect(submit).not.toHaveBeenCalled(); + }); it("bounds mounted list rows even for the maximum 500-row input", () => { render( { />, ); expect(screen.getAllByRole("listitem").length).toBeLessThanOrEqual(14); + expect(screen.getAllByRole("listitem")[0]).toHaveAttribute( + "aria-setsize", + "500", + ); + expect(screen.getAllByRole("listitem")[0]).toHaveAttribute( + "aria-posinset", + "1", + ); + fireEvent.scroll(screen.getByRole("list", { name: "Add-on list" }), { + target: { scrollTop: 3600 }, + }); + expect(screen.getAllByRole("listitem")[0]).toHaveAttribute( + "aria-posinset", + "99", + ); + expect(screen.getAllByRole("listitem")[0]).toHaveTextContent("98"); + }); + it("exposes complete table dimensions and row positions across a virtual scroll", () => { + render( + [String(i)]), + }), + ]} + event={vi.fn()} + link={vi.fn()} + />, + ); + const table = screen.getByRole("table", { name: "Add-on table" }); + expect(table).toHaveAttribute("aria-rowcount", "501"); + expect(screen.getAllByRole("row")[0]).toHaveAttribute("aria-rowindex", "1"); + expect(screen.getAllByRole("row")[1]).toHaveAttribute("aria-rowindex", "2"); + fireEvent.scroll(table, { target: { scrollTop: 3600 } }); + expect(screen.getAllByRole("row")[1]).toHaveAttribute( + "aria-rowindex", + "100", + ); + expect(screen.getAllByRole("row")[1]).toHaveTextContent("98"); + }); + it("gives a labelled list or table its own accessible name", () => { + render( + , + ); + expect(screen.getByRole("list", { name: "Changed paths" })).toBeTruthy(); + expect(screen.getByRole("table", { name: "Open issues" })).toBeTruthy(); + }); +}); + +describe("trusted adapters use CodeMux controls", () => { + it("renders Switch as the app's switch, labelled and keyboard operable", async () => { + const event = vi.fn(); + render( + , + ); + const toggle = screen.getByRole("switch", { name: "Include paths" }); + expect(toggle).toHaveAttribute("data-slot", "switch"); + expect(toggle).toHaveAttribute("aria-checked", "false"); + // A checkbox stays a checkbox; only Switch changed. + expect(screen.getByRole("checkbox", { name: "Remember" })).toBeChecked(); + toggle.focus(); + await userEvent.keyboard(" "); + expect(event).toHaveBeenCalledWith( + expect.objectContaining({ id: "switch" }), + "change", + true, + ); + }); + it("maps Button, Badge and Divider to the shared components", () => { + const { container } = render( + , + ); + const refresh = screen.getByRole("button", { name: "Refresh" }); + expect(refresh).toHaveAttribute("data-slot", "button"); + expect(refresh).toHaveAttribute("data-variant", "outline"); + expect(screen.getByRole("button", { name: "Delete" })).toHaveAttribute( + "data-variant", + "destructive", + ); + expect(screen.getByText("3 open")).toHaveAttribute("data-slot", "badge"); + expect(container.querySelector('[data-slot="separator"]')).not.toBeNull(); + }); + it("renders every allowlisted icon name with its own glyph", () => { + // The generated manifest schema carries the validators' icon list + // (manifest::ICONS); the renderer must know every name on it. + const schema = JSON.parse( + readFileSync( + resolve(process.cwd(), "packages/plugin-sdk/schema/manifest.json"), + "utf8", + ), + ) as { + definitions: { View: { properties: { icon: { enum: string[] } } } }; + }; + const allowed = schema.definitions.View.properties.icon.enum; + expect([...ADDON_ICON_NAMES].sort()).toEqual([...allowed].sort()); + const { container } = render( + node(name, "cmx-icon", { name }))} + event={vi.fn()} + link={vi.fn()} + />, + ); + const glyphs = [...container.querySelectorAll("svg")].map( + (svg) => svg.getAttribute("class") ?? "", + ); + expect(glyphs).toHaveLength(allowed.length); + allowed.forEach((name, index) => + expect(glyphs[index]).toContain(`lucide-${name}`), + ); + }); + it("shows Markdown links as text when the add-on may not open links", () => { + render( + , + ); + expect(screen.queryByRole("button", { name: "Docs" })).toBeNull(); + expect(screen.getByText("Docs")).toBeTruthy(); + }); + it("opens a Markdown link whatever the case of its https scheme", () => { + const link = vi.fn(); + render( + , + ); + expect(screen.getByRole("button", { name: "Plain" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Docs" })); + expect(link).toHaveBeenCalledWith( + expect.objectContaining({ id: "markdown" }), + "HTTPS://example.com/docs", + ); + }); +}); + +describe("text fields keep what the user types", () => { + const field = (properties: Record) => + node("field", "cmx-text-field", { label: "Query", ...properties }); + function setup(properties: Record, element = "cmx-text-field") { + const event = vi.fn(); + const view = render( + , + ); + const echo = (value: string) => + view.rerender( + , + ); + return { + event, + echo, + input: screen.getByRole("textbox", { name: "Query" }) as + | HTMLInputElement + | HTMLTextAreaElement, + }; + } + const sent = (event: ReturnType) => + event.mock.calls.map(([, , value]) => value); + it("holds typed text before the add-on echoes it", async () => { + const { input, event } = setup({ value: "" }); + await userEvent.type(input, "abc"); + expect(input.value).toBe("abc"); + expect(sent(event)).toEqual(["a", "ab", "abc"]); + }); + it("ignores late echoes of earlier keystrokes during a fast burst", async () => { + const { input, event, echo } = setup({ value: "" }); + await userEvent.type(input, "abcd"); + // The add-on's round trip lags behind: its echoes arrive one by one. + echo("a"); + expect(input.value).toBe("abcd"); + echo("abc"); + expect(input.value).toBe("abcd"); + await userEvent.type(input, "e"); + echo("abcd"); + expect(input.value).toBe("abcde"); + expect(sent(event)).toEqual(["a", "ab", "abc", "abcd", "abcde"]); + }); + it("keeps the caret for a mid-string edit and its echo", async () => { + const { input, echo } = setup({ value: "helo world" }); + input.focus(); + input.setSelectionRange(3, 3); + await userEvent.keyboard("l"); + expect(input.value).toBe("hello world"); + expect(input.selectionStart).toBe(4); + echo("hello world"); + expect(input.value).toBe("hello world"); + expect(input.selectionStart).toBe(4); + }); + it("takes a value the add-on sets itself and keeps the caret there", async () => { + const { input, echo } = setup({ value: "hello world" }); + input.focus(); + input.setSelectionRange(5, 5); + echo("HELLO world"); + expect(input.value).toBe("HELLO world"); + expect(input.selectionStart).toBe(5); + // Clearing after a submit is a value the field never reported. + await userEvent.type(input, "!"); + echo(""); + expect(input.value).toBe(""); + }); + it("works uncontrolled when the add-on omits value", async () => { + const { input, event } = setup({}, "cmx-text-area"); + expect(input.tagName).toBe("TEXTAREA"); + await userEvent.type(input, "notes"); + expect(input.value).toBe("notes"); + expect(event).toHaveBeenLastCalledWith( + expect.objectContaining({ id: "field" }), + "change", + "notes", + ); + }); + it("does not send text over the broker's 32 KiB event limit", () => { + const { input, event } = setup({ value: "" }); + fireEvent.change(input, { target: { value: "é".repeat(16385) } }); + expect(event).not.toHaveBeenCalled(); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect( + screen.getByText(/too long to send to the add-on/), + ).toBeTruthy(); + // The message describes the field; it is not part of its name. + expect(input).toHaveAccessibleName("Query"); + expect(input).toHaveAccessibleDescription( + "This text is too long to send to the add-on (32 KiB at most).", + ); + fireEvent.change(input, { target: { value: "short" } }); + expect(sent(event)).toEqual(["short"]); + expect(input).not.toHaveAttribute("aria-invalid"); + expect(input).not.toHaveAttribute("aria-describedby"); + }); + it("never rolls back to a late echo after a long burst", () => { + // An add-on busy with other work answers only after dozens of edits. + const { input, event, echo } = setup({ value: "" }); + const typed = "the quick brown fox jumps over the lazy dog, twice over"; + for (let i = 1; i <= typed.length; i++) + fireEvent.change(input, { target: { value: typed.slice(0, i) } }); + expect(sent(event)).toHaveLength(typed.length); + echo("t"); + echo("the quick"); + expect(input.value).toBe(typed); + echo(typed); + expect(input.value).toBe(typed); + // Afterwards a value the add-on sets itself still wins. + echo(""); + expect(input.value).toBe(""); + }); +}); +describe("add-on buttons", () => { + it("honors a full-height button and keeps a long label on one line", () => { + const label = "Refresh the project brief from the working tree"; + render( + , + ); + const button = screen.getByRole("button", { name: label }); + expect(button).toHaveClass("h-full", "max-w-full"); + expect(button).toHaveAttribute("title", label); + expect(button.querySelector(".truncate")).toHaveTextContent(label); }); }); diff --git a/src/components/addons/addon-renderer.tsx b/src/components/addons/addon-renderer.tsx index 5160a6c0..20b7f342 100644 --- a/src/components/addons/addon-renderer.tsx +++ b/src/components/addons/addon-renderer.tsx @@ -1,9 +1,20 @@ -import { Fragment, useState, type ReactNode } from "react"; +import { + Fragment, + useCallback, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; import Markdown from "react-markdown"; import { BookOpen, Check, + CircleAlert, + Code, FileText, + Folder, GitBranch, Github, Info, @@ -11,11 +22,20 @@ import { List, Plus, RefreshCw, + Search, Settings, + Terminal, type LucideIcon, } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; import type { AddonNode } from "@/lib/addons/types"; import { cn } from "@/lib/utils"; +/** One glyph for every icon name the manifest and UI validators accept. */ const icons: Record = { "file-text": FileText, "git-branch": GitBranch, @@ -28,7 +48,13 @@ const icons: Record = { link: Link, "refresh-cw": RefreshCw, plus: Plus, + "circle-alert": CircleAlert, + folder: Folder, + terminal: Terminal, + code: Code, + search: Search, }; +export const ADDON_ICON_NAMES: readonly string[] = Object.keys(icons); export const addonIcon = (name: string) => icons[name] ?? Info; const spacing: Record = { none: "gap-0", @@ -40,8 +66,8 @@ const spacing: Record = { const colors: Record = { default: "text-foreground", muted: "text-muted-foreground", - success: "text-emerald-600 dark:text-emerald-400", - warning: "text-amber-600 dark:text-amber-400", + success: "text-success", + warning: "text-warning", danger: "text-destructive", accent: "text-primary", }; @@ -80,13 +106,152 @@ interface Props { value: string | boolean | null, ) => void; link: (node: AddonNode, url: string) => void; + /** False when the add-on may not open links; Markdown links then render + * as plain text instead of controls that can only be refused. */ + linksAllowed?: boolean; +} +/** The broker's limit for one UI event value (UTF-8 bytes). */ +const MAX_VALUE_BYTES = 32768; +/** Echoes this field still expects from the add-on. Only an add-on that has + * stopped answering falls this far behind; its oldest echoes are forgotten. */ +const MAX_PENDING_ECHOES = 1024; +const utf8 = new TextEncoder(); +/** A small stand-in for a reported value (its length and 32-bit FNV-1a + * hash), so a long burst in a large text area keeps little memory. */ +function fingerprint(value: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i++) + hash = Math.imul(hash ^ value.charCodeAt(i), 0x01000193); + return `${value.length}:${hash >>> 0}`; +} +/** + * TextField / TextArea adapter. The field owns what the user is typing: each + * edit updates it at once and is reported to the add-on, and the add-on's + * `value` echoes of those edits are ignored, so a slow round trip cannot + * drop keystrokes or move the caret. A `value` the add-on sets on its own + * (one this field never reported) replaces the text and keeps the caret. + * Without a `value` the field is uncontrolled. + */ +function AddonTextInput({ + multiline, + label, + placeholder, + disabled, + value, + className, + onValue, +}: { + multiline: boolean; + label: string; + placeholder: string; + disabled: boolean; + value: string | undefined; + className: string; + onValue: (value: string) => void; +}) { + const [draft, setDraft] = useState(value ?? ""); + const [tooLong, setTooLong] = useState(false); + const messageId = useId(); + const field = useRef(null); + const current = useRef(draft); + // Fingerprints of reported edits the add-on has not echoed yet, oldest first. + const pending = useRef([]); + const composing = useRef(false); + const deferred = useRef(undefined); + const caret = useRef<[number, number] | null>(null); + const accept = (remote: string | undefined) => { + if (remote === undefined) return; + const echo = pending.current.indexOf(fingerprint(remote)); + if (echo !== -1) { + // One of our own edits coming back, possibly late: drop it and every + // older one, but never let it overwrite what was typed since. + pending.current.splice(0, echo + 1); + return; + } + if (remote === current.current) return; + if (composing.current) { + deferred.current = remote; + return; + } + const el = field.current; + caret.current = + el && el.ownerDocument.activeElement === el + ? [el.selectionStart ?? remote.length, el.selectionEnd ?? remote.length] + : null; + pending.current = []; + current.current = remote; + setTooLong(false); + setDraft(remote); + }; + useLayoutEffect(() => accept(value), [value]); + useLayoutEffect(() => { + const el = field.current; + if (!caret.current || !el) return; + const [start, end] = caret.current; + caret.current = null; + el.setSelectionRange( + Math.min(start, draft.length), + Math.min(end, draft.length), + ); + }, [draft]); + const attach = useCallback( + (el: HTMLInputElement | HTMLTextAreaElement | null) => { + field.current = el; + }, + [], + ); + const props = { + ref: attach, + value: draft, + placeholder, + disabled, + "aria-invalid": tooLong || undefined, + "aria-describedby": tooLong ? messageId : undefined, + onChange: ( + e: React.ChangeEvent, + ) => { + const next = e.currentTarget.value; + current.current = next; + setDraft(next); + const over = utf8.encode(next).byteLength > MAX_VALUE_BYTES; + setTooLong(over); + if (over) return; + pending.current.push(fingerprint(next)); + if (pending.current.length > MAX_PENDING_ECHOES) pending.current.shift(); + onValue(next); + }, + onCompositionStart: () => { + composing.current = true; + }, + onCompositionEnd: () => { + composing.current = false; + const later = deferred.current; + deferred.current = undefined; + accept(later); + }, + }; + return ( +
+