From 8fee7fbdca800c7f4ea446aebc2f605a8a1d4d61 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Tue, 22 Sep 2026 14:12:06 +1000 Subject: [PATCH 01/11] Migrate BuilderLab CLI from Berd --- .github/ISSUE_TEMPLATE/bug-report.md | 44 +- .github/ISSUE_TEMPLATE/config.yml | 14 +- .github/ISSUE_TEMPLATE/feature-request.md | 23 + .gitignore | 5 + AGENTS.md | 101 + CLAUDE.md | 1 + CODEOWNERS | 17 +- CONTRIBUTING.md | 108 + Cargo.lock | 2512 +++++++++ Cargo.toml | 44 + Justfile | 128 + README.md | 157 +- SECURITY.md | 8 + bin/.just-1.46.0.pkg | 1 + bin/.lefthook-2.1.4.pkg | 1 + bin/.rustup-1.29.0.pkg | 1 + bin/.schema-registry-0.127.1.pkg | 1 + bin/README.hermit.md | 7 + bin/activate-hermit | 21 + bin/activate-hermit.fish | 24 + bin/cargo | 1 + bin/cargo-clippy | 1 + bin/cargo-fmt | 1 + bin/cargo-miri | 1 + bin/clippy-driver | 1 + bin/hermit | 43 + bin/hermit.hcl | 4 + bin/just | 1 + bin/lefthook | 1 + bin/rls | 1 + bin/rust-analyzer | 1 + bin/rust-gdb | 1 + bin/rust-gdbgui | 1 + bin/rust-lldb | 1 + bin/rustc | 1 + bin/rustdoc | 1 + bin/rustfmt | 1 + bin/rustup | 1 + bin/schema-registry | 1 + bl-local-dev-config.yaml | 11 + build.rs | 69 + crates/builderlab-auth/Cargo.toml | 24 + crates/builderlab-auth/src/auth.rs | 3 + crates/builderlab-auth/src/auth_login.rs | 541 ++ crates/builderlab-auth/src/auth_storage.rs | 578 ++ crates/builderlab-auth/src/config.rs | 208 + crates/builderlab-auth/src/keychain.rs | 152 + crates/builderlab-auth/src/lib.rs | 15 + crates/builderlab-auth/src/org_routing.rs | 205 + crates/builderlab-auth/src/preferences.rs | 83 + crates/builderlab-auth/src/workspace.rs | 140 + docker/acceptance/Dockerfile | 15 + docker/acceptance/Dockerfile.dockerignore | 22 + docker/acceptance/README.md | 30 + docker/acceptance/mock-marketplace.py | 121 + docker/acceptance/run-acceptance.sh | 155 + docs/RELEASING-bl.md | 62 + docs/RELEASING-sq.md | 118 + docs/bl-auth-flow.md | 39 + docs/bl-auth-local-testing.md | 103 + docs/sq-integration.md | 215 + docs/sq-overview.md | 74 + extensions.yaml | 135 + kochiku.yml | 5 + lefthook.yml | 16 + protos/google/api/annotations.proto | 31 + protos/google/api/http.proto | 370 ++ .../kgoose/api/v3/activity_messages.proto | 79 + .../kgoose/api/v3/agent_config_messages.proto | 200 + .../cash/kgoose/api/v3/chat_messages.proto | 598 +++ .../cash/kgoose/api/v3/common_messages.proto | 157 + .../kgoose/api/v3/extension_messages.proto | 63 + .../api/v3/extension_selection_config.proto | 55 + .../cash/kgoose/api/v3/profile_messages.proto | 152 + .../api/v3/tool_endpoint_messages.proto | 121 + .../kgoose/api/v3/tool_endpoint_service.proto | 29 + .../api/v1beta1/memory.proto | 261 + .../common/governance/v0/common.proto | 11 + .../v0/consumer_personal_data.proto | 137 + .../v0/employee_personal_data.proto | 137 + .../common/governance/v0/merchant_data.proto | 165 + .../governance/v0/payment_card_data.proto | 31 + .../common/governance/v0/semantic_types.proto | 25 + protos/squareup/common/pii.proto | 75 + rust-toolchain.toml | 3 + script/ci | 24 + script/update-extensions-catalog | 40 + script/update_extensions_catalog.py | 169 + src/appkit.rs | 243 + src/bin/bl.rs | 3 + src/bl/agents.rs | 603 +++ src/bl/agents_install.rs | 994 ++++ src/bl/agents_models.rs | 213 + src/bl/apps.rs | 4675 +++++++++++++++++ src/bl/auth.rs | 1 + src/bl/auth_callback.html | 188 + src/bl/auth_login.rs | 531 ++ src/bl/auth_storage.rs | 21 + src/bl/description.rs | 21 + src/bl/display.rs | 172 + src/bl/mod.rs | 21 + src/bl/org_routing.rs | 1 + src/bl/runner.rs | 52 + src/bl/skills.rs | 2126 ++++++++ src/bl/skills_api.rs | 1068 ++++ src/bl/skills_archive.rs | 209 + src/bl/skills_config.rs | 340 ++ src/bl/skills_doctor.rs | 324 ++ src/bl/skills_install.rs | 1176 +++++ src/bl/skills_models.rs | 286 + src/bl/skills_slug.rs | 112 + src/bl/skills_targets.rs | 677 +++ src/bl/workspace.rs | 347 ++ src/catalog.rs | 179 + src/cli.rs | 957 ++++ src/kgoose.rs | 363 ++ src/lib.rs | 1394 +++++ src/main.rs | 3 + src/proto.rs | 180 + src/runtime.rs | 758 +++ tests/bl_e2e.rs | 4373 +++++++++++++++ tests/cli_e2e.rs | 830 +++ tests/common/mod.rs | 467 ++ tests/docker_acceptance_contract.rs | 278 + 124 files changed, 33475 insertions(+), 65 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/feature-request.md create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Justfile create mode 100644 SECURITY.md create mode 120000 bin/.just-1.46.0.pkg create mode 120000 bin/.lefthook-2.1.4.pkg create mode 120000 bin/.rustup-1.29.0.pkg create mode 120000 bin/.schema-registry-0.127.1.pkg create mode 100644 bin/README.hermit.md create mode 100755 bin/activate-hermit create mode 100755 bin/activate-hermit.fish create mode 120000 bin/cargo create mode 120000 bin/cargo-clippy create mode 120000 bin/cargo-fmt create mode 120000 bin/cargo-miri create mode 120000 bin/clippy-driver create mode 100755 bin/hermit create mode 100644 bin/hermit.hcl create mode 120000 bin/just create mode 120000 bin/lefthook create mode 120000 bin/rls create mode 120000 bin/rust-analyzer create mode 120000 bin/rust-gdb create mode 120000 bin/rust-gdbgui create mode 120000 bin/rust-lldb create mode 120000 bin/rustc create mode 120000 bin/rustdoc create mode 120000 bin/rustfmt create mode 120000 bin/rustup create mode 120000 bin/schema-registry create mode 100644 bl-local-dev-config.yaml create mode 100644 build.rs create mode 100644 crates/builderlab-auth/Cargo.toml create mode 100644 crates/builderlab-auth/src/auth.rs create mode 100644 crates/builderlab-auth/src/auth_login.rs create mode 100644 crates/builderlab-auth/src/auth_storage.rs create mode 100644 crates/builderlab-auth/src/config.rs create mode 100644 crates/builderlab-auth/src/keychain.rs create mode 100644 crates/builderlab-auth/src/lib.rs create mode 100644 crates/builderlab-auth/src/org_routing.rs create mode 100644 crates/builderlab-auth/src/preferences.rs create mode 100644 crates/builderlab-auth/src/workspace.rs create mode 100644 docker/acceptance/Dockerfile create mode 100644 docker/acceptance/Dockerfile.dockerignore create mode 100644 docker/acceptance/README.md create mode 100644 docker/acceptance/mock-marketplace.py create mode 100755 docker/acceptance/run-acceptance.sh create mode 100644 docs/RELEASING-bl.md create mode 100644 docs/RELEASING-sq.md create mode 100644 docs/bl-auth-flow.md create mode 100644 docs/bl-auth-local-testing.md create mode 100644 docs/sq-integration.md create mode 100644 docs/sq-overview.md create mode 100644 extensions.yaml create mode 100644 kochiku.yml create mode 100644 lefthook.yml create mode 100644 protos/google/api/annotations.proto create mode 100644 protos/google/api/http.proto create mode 100644 protos/squareup/cash/kgoose/api/v3/activity_messages.proto create mode 100644 protos/squareup/cash/kgoose/api/v3/agent_config_messages.proto create mode 100644 protos/squareup/cash/kgoose/api/v3/chat_messages.proto create mode 100644 protos/squareup/cash/kgoose/api/v3/common_messages.proto create mode 100644 protos/squareup/cash/kgoose/api/v3/extension_messages.proto create mode 100644 protos/squareup/cash/kgoose/api/v3/extension_selection_config.proto create mode 100644 protos/squareup/cash/kgoose/api/v3/profile_messages.proto create mode 100644 protos/squareup/cash/kgoose/api/v3/tool_endpoint_messages.proto create mode 100644 protos/squareup/cash/kgoose/api/v3/tool_endpoint_service.proto create mode 100644 protos/squareup/cash/kgoosememorystore/api/v1beta1/memory.proto create mode 100644 protos/squareup/common/governance/v0/common.proto create mode 100644 protos/squareup/common/governance/v0/consumer_personal_data.proto create mode 100644 protos/squareup/common/governance/v0/employee_personal_data.proto create mode 100644 protos/squareup/common/governance/v0/merchant_data.proto create mode 100644 protos/squareup/common/governance/v0/payment_card_data.proto create mode 100644 protos/squareup/common/governance/v0/semantic_types.proto create mode 100644 protos/squareup/common/pii.proto create mode 100644 rust-toolchain.toml create mode 100755 script/ci create mode 100644 script/update-extensions-catalog create mode 100644 script/update_extensions_catalog.py create mode 100644 src/appkit.rs create mode 100644 src/bin/bl.rs create mode 100644 src/bl/agents.rs create mode 100644 src/bl/agents_install.rs create mode 100644 src/bl/agents_models.rs create mode 100644 src/bl/apps.rs create mode 100644 src/bl/auth.rs create mode 100644 src/bl/auth_callback.html create mode 100644 src/bl/auth_login.rs create mode 100644 src/bl/auth_storage.rs create mode 100644 src/bl/description.rs create mode 100644 src/bl/display.rs create mode 100644 src/bl/mod.rs create mode 100644 src/bl/org_routing.rs create mode 100644 src/bl/runner.rs create mode 100644 src/bl/skills.rs create mode 100644 src/bl/skills_api.rs create mode 100644 src/bl/skills_archive.rs create mode 100644 src/bl/skills_config.rs create mode 100644 src/bl/skills_doctor.rs create mode 100644 src/bl/skills_install.rs create mode 100644 src/bl/skills_models.rs create mode 100644 src/bl/skills_slug.rs create mode 100644 src/bl/skills_targets.rs create mode 100644 src/bl/workspace.rs create mode 100644 src/catalog.rs create mode 100644 src/cli.rs create mode 100644 src/kgoose.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/proto.rs create mode 100644 src/runtime.rs create mode 100644 tests/bl_e2e.rs create mode 100644 tests/cli_e2e.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/docker_acceptance_contract.rs diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 25f0bc5..1284b79 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,31 +1,31 @@ --- -name: 🐛 Bug Report -about: Thank you for taking the time, please report a reproducible bug -title: "[Bug] " +name: Bug report +about: Report a reproducible problem with BuilderLab CLI +title: "[Bug]: " labels: bug -assignees: add codeowner's @name here - +assignees: [] --- -**Describe the bug** -*A clear and concise description of what the bug is.* +## Description + + + +## Reproduction + +```text +# Command(s) to reproduce the problem +``` + +## Expected behavior -**To Reproduce:** -*Steps to reproduce the behavior:* -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error + -**Expected behavior:** -*A clear and concise description of what you expected to happen.* +## Actual behavior -**Supporting Material** -*If applicable, add screenshots, output log and/or other documentation to help explain your problem.* + -**Environment (please complete the following information):** - - OS: [ex: iOS] - - Version +## Environment -**Additional context** -Add any other context that you feel is relevant about the problem here. +- OS: +- CLI and version (`bl --version` or `sq agent-tools --version`): +- Installation or build method: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0ba9db2..49a169c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,12 @@ +# The normal GitHub UI does not offer a blank issue: a well-formed issue is the +# supported way to participate in BuilderLab CLI, so the templates ask for what +# we need to act on it. +blank_issues_enabled: false + contact_links: - - name: ❓ Questions and Help 🤔 - url: https://discord.gg/block-opensource (/add your discord channel if applicable) - about: This issue tracker is not for support questions. Please refer to the community for more help. + - name: Questions about using BuilderLab CLI + url: https://github.com/block/builderlab-cli/blob/main/README.md + about: The README covers setup and usage. Use an issue for a reproducible bug or actionable feature request. + - name: Security vulnerabilities + url: https://github.com/block/builderlab-cli/blob/main/SECURITY.md + about: Please report vulnerabilities privately, never as a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000..a5922d4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an improvement to BuilderLab CLI +title: "[Feature]: " +labels: enhancement +assignees: [] +--- + +## Problem + + + +## Current workaround + + + +## Proposed improvement + + + +## Alternatives and non-goals + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdca7dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +sqbin/ +dist/ +.DS_Store +.hermit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..98373f4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# AGENTS.md / CLAUDE.md + +Instructions for agents working in this standalone CLI repository. + +## Workflow + +- Never use `--no-verify` unless the user explicitly asks for it. +- Activate the Hermit environment before running repo commands that depend on managed tooling: + +```bash +source ./bin/activate-hermit +``` + +- Install git hooks when setting up the repo locally: + +```bash +lefthook install +``` + +## Common Commands + +- Build the Rust binary: + +```bash +just build +``` + +- Run tests: + +```bash +just test +``` + +- Run linting (rustfmt + clippy): + +```bash +just lint +``` + +- Run the main verification suite: + +```bash +just check +``` + +- Build the `sq` package artifact: + +```bash +just build-sq +``` + +- Inspect the CLI surface directly: + +```bash +./target/debug/agent-tools --help +./target/debug/agent-tools utils calculate --numbers 2 3 --operation add +``` + +- Smoke-test the packaged `sq` module and confirm `sq` is using the local `sqbin/` output: + +```bash +just package-smoke +sq which agent-tools +sq agent-tools linear --help +./sqbin/agent-tools.exoskeleton linear --help +``` + +`sq which agent-tools` should point at the package-local `sqbin/agent-tools.exoskeleton`. If `sq agent-tools ...` prints synthesized submenu help instead of the exoskeleton output, compare against `./sqbin/agent-tools.exoskeleton ...` to distinguish outer `sq` wrapper behavior from `bl-cli` behavior. + +## Project Layout + +- `src/kgoose.rs` defines the kgoose ToolEndpoint client and re-exports the generated proto request/response types used by the CLI. +- `src/cli.rs` bootstraps global flags and builds the dynamic clap command tree. Parsing is two-phase: a hand-rolled bootstrap parser strips infrastructure flags and extracts `command_tokens` first, then only the named extension is loaded from the API before building the clap tree. This avoids loading all extensions upfront. +- `src/runtime.rs` loads extension/tool metadata from the live kgoose API and derives CLI parameters from tool schemas. +- `src/catalog.rs` manages the static extensions catalog (`extensions.yaml`), embedded at compile time. +- `src/main.rs` wires help/version output and live ToolEndpoint execution. +- `src/proto.rs` re-exports the generated prost modules and includes the `pbjson-build` serde impls used for JSON-over-HTTP decoding of proto-backed request/metadata types. +- `sqbin/agent-tools.exoskeleton` is the packaged executable built by `just build-sq`. +- `docs/sq-overview.md` covers the repo and packaged CLI at a high level. +- `docs/sq-integration.md` covers how `sq` discovers and integrates the packaged module. +- `docs/RELEASING-sq.md` covers the Homebrew-backed `sq` command-pack release path. +- `docs/RELEASING-bl.md` covers building the `bl` CLI binary consumed by Berd.app. + +External docs: https://clig.dev/llms.txt -> guide you can consult to write better command-line programs, taking traditional UNIX principles and updating them for the modern day. + + +## Integration Notes + +- This repo is packaged as a single `sq` module named `agent-tools.exoskeleton`. +- Homebrew packaging should install the entire `sqbin/` directory into `prefix/"etc"` so `sq` can discover the pack. +- `sq` picks up the repo-local package when `sq which agent-tools` resolves to `sqbin/agent-tools.exoskeleton`. +- `sq` synthesizes extension submenu help from `--describe-commands` metadata. In practice, extension-level flags such as `sq agent-tools --help` and `sq agent-tools --describe` can be intercepted by the outer `sq` wrapper instead of reaching the exoskeleton. +- To verify exoskeleton-specific extension behavior, prefer `./sqbin/agent-tools.exoskeleton --help` and `./sqbin/agent-tools.exoskeleton --describe`, then compare with `sq agent-tools ...` to identify wrapper behavior. +- The current CLI talks directly to the kgoose ToolEndpoint JSON-over-HTTP routes and expects `KGOOSE_BASE_URL`, `KGOOSE_PLAYPEN`, or explicit flags when needed. `GOOSEMCP_PLAYPEN` is an independent opt-in that adds an `envoy-route--goosemcp=playpen-` entry to the outbound `Baggage` header for routing the downstream goosemcp Envoy; only set it when a matching playpen pod is running, otherwise extension calls fail with an opaque 5xx. +- Prefer generated proto types over handwritten mirrors. `tonic_prost_build` generates the prost messages, `extern_path` maps the `google.protobuf` JSON value types to `pbjson_types`, and `pbjson-build` adds serde support for the JSON-over-HTTP endpoints so the Rust types stay aligned with the service protos. +- `CallToolResponse` is now proto-backed too. `src/main.rs` pretty-prints the typed response envelope directly. +- The CLI is dynamic at three levels: ListExtensions determines which top-level extensions exist, ListTools determines which tool subcommands exist under each extension, and each tool's schema determines its flags/options. +- We take a hybrid static/dynamic approach: + - **Top-level extensions are static.** `extensions.yaml` is generated from two sources (kGoose ListExtensionsGrpcAction + G2 web app OAuth config for late-init extensions like notion, asana), then manually curated. Run `just update-extensions-catalog` to regenerate. This lets all known extensions appear in `--help` even if the user hasn't connected them yet. + - **Extension subcommands are fully dynamic.** ListTools and CallTool hit the live kgoose API. If the user hasn't connected an extension, `--help` for that extension will fail with a "not connected" error pointing to G2 Connections. The static catalog is used only to produce helpful error messages (distinguishing "unknown extension" from "known but not connected"). + - **`--describe-commands` leaf node is the extension subcommand.** We don't return nested tool commands under an extension, so `sq` forwards args to the extension subcommand directly. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS index 694c954..7e2f4e0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,24 +1,9 @@ # This CODEOWNERS file denotes the project leads # and encodes their responsibilities for code review. -# Instructions: At a minimum, replace the '@GITHUB_USER_NAME_GOES_HERE' -# here with at least one project lead. - # Lines starting with '#' are comments. # Each line is a file pattern followed by one or more owners. # The format is described: https://github.blog/2017-07-06-introducing-code-owners/ # These owners will be the default owners for everything in the repo. -* @jsibbison-square - - -# ----------------------------------------------- -# BELOW THIS LINE ARE TEMPLATES, UNUSED -# ----------------------------------------------- -# Order is important. The last matching pattern has the most precedence. -# So if a pull request only touches javascript files, only these owners -# will be requested to review. -# *.js @octocat @github/js - -# You can also use email addresses if you prefer. -# docs/* docs@example.com \ No newline at end of file +* @block/berd-oss-team diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..adb263c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,108 @@ +# Contributing to BuilderLab CLI + +BuilderLab CLI is developed by Block in the open. You can inspect the source, +build it locally, and help us improve it by filing a clear issue or proposing a +focused change. + +## Filing an issue + +Open an issue through the [issue chooser](https://github.com/block/builderlab-cli/issues/new/choose). +Use the bug report template for behavior that does not match the documented +CLI, and the feature request template for a capability or improvement. + +Blank issues are disabled so that reports contain enough information to act on. +Issues created through the API or other tooling should meet the same standard. + +### Using an agent + +An agent can help gather version information, reduce logs, and turn notes into +clear reproduction steps. You are still responsible for the issue: read the +result before posting it, and never let an agent invent versions, output, or +other details it did not observe. Write `unknown` when you do not know +something. + +### Before filing + +Please: + +1. Search open and closed issues and link the closest match, or say that you + found none. +2. Reproduce the problem with the current release or current `main` build. +3. Keep one problem per issue so it can be triaged and closed cleanly. + +### Bug reports + +A useful bug report lets a maintainer reproduce the problem without a long +back-and-forth. Include: + +- the exact command and relevant inputs; +- expected and actual behavior; +- whether it happens every time or intermittently; +- the CLI name and exact version; +- operating system and installation or build method; and +- the relevant output or log excerpt. + +Put output in a fenced code block, include only the relevant lines, and remove +credentials, tokens, prompts, file paths, and other sensitive information. + +### Feature requests + +Describe the problem you are trying to solve before proposing a solution. +Include: + +- what you do today and any workaround; +- why the capability belongs in BuilderLab CLI; +- what is explicitly out of scope; and +- alternatives you considered and why they were insufficient. + +## What happens next + +Issues are triaged on a best-effort basis. A report may be labelled and queued, +returned for more information, closed as a duplicate, or closed as out of +scope with an explanation. A closed issue is not a judgment on the person who +filed it; it records a product decision. + +## Security issues + +Do not open a public issue for a security vulnerability. Follow the private +reporting instructions in [SECURITY.md](SECURITY.md). + +## Building locally + +BuilderLab CLI uses the Hermit-managed toolchain. From the repository root: + +```bash +source ./bin/activate-hermit +just build +just test +just lint +``` + +Run the complete local verification suite before submitting a change: + +```bash +just ci +``` + +If the repository tools have not been installed yet: + +```bash +./bin/hermit install rustup just lefthook +source ./bin/activate-hermit +just setup +``` + +See the [README](README.md) for package and CLI usage, and [AGENTS.md](AGENTS.md) +for repository layout and implementation notes. + +## Pull requests + +Keep changes focused and explain the user-visible behavior they change. Add or +update tests and documentation when appropriate. Before requesting review: + +- run `just ci`; +- confirm that generated or packaged artifacts are intentional; and +- remove local paths, credentials, and unrelated changes from the diff. + +The [Block Open Source governance guide](GOVERNANCE.md) also applies to +participation in this repository. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..abb67f0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2512 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "builderlab-auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "core-foundation", + "reqwest", + "security-framework-sys", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "url", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pbjson" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8edd1efdd8ab23ba9cb9ace3d9987a72663d5d7c9f74fa00b51d6213645cf6c" +dependencies = [ + "base64", + "serde", +] + +[[package]] +name = "pbjson-build" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ed4d5c6ae95e08ac768883c8401cf0e8deb4e6e1d6a4e1fd3d2ec4f0ec63200" +dependencies = [ + "heck", + "itertools", + "prost", + "prost-types", +] + +[[package]] +name = "pbjson-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14e2757d877c0f607a82ce1b8560e224370f159d66c5d52eb55ea187ef0350e" +dependencies = [ + "bytes", + "chrono", + "pbjson", + "pbjson-build", + "prost", + "prost-build", + "serde", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sq-kgoose" +version = "0.7.12" +dependencies = [ + "anyhow", + "builderlab-auth", + "clap", + "clap_complete", + "pbjson", + "pbjson-build", + "pbjson-types", + "prost", + "prost-types", + "protoc-bin-vendored", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tempfile", + "tiny_http", + "tonic", + "tonic-prost", + "tonic-prost-build", + "url", + "webbrowser", + "zip", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dbf1b03 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "sq-kgoose" +version = "0.7.12" +edition = "2021" +rust-version = "1.88.0" +license = "Apache-2.0" +publish = false + +[[bin]] +name = "agent-tools" +path = "src/main.rs" + +[[bin]] +name = "bl" +path = "src/bin/bl.rs" + +[dependencies] +anyhow = "1" +builderlab-auth = { path = "crates/builderlab-auth", features = ["blocking-client"] } +clap = { version = "4.5", features = ["env"] } +clap_complete = "4.5" +pbjson = "0.9" +pbjson-types = "0.9" +prost = "0.14" +prost-types = "0.14" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +tiny_http = "0.12" +tonic = { version = "0.14", features = ["transport"] } +tonic-prost = "0.14" +url = "2.5" +webbrowser = "1.0" +zip = { version = "4", default-features = false, features = ["deflate"] } + +[build-dependencies] +pbjson-build = "0.9" +protoc-bin-vendored = "3" +tonic-prost-build = "0.14" + +[dev-dependencies] +tempfile = "3" diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..b725f13 --- /dev/null +++ b/Justfile @@ -0,0 +1,128 @@ +BIN_NAME := "agent-tools" + +_help: + @just -l + +build: + cargo build --locked + +build-release: + cargo build --locked --release + +build-sq: build-release + mkdir -p sqbin + cp target/release/{{BIN_NAME}} sqbin/{{BIN_NAME}}.exoskeleton + +build-bl-release: + cargo build --locked --release --bin bl + +# Download protos from schema-registry and generate Rust code +protos: + #!/usr/bin/env bash + set -euo pipefail + + echo "📥 Downloading proto files..." + rm -rf protos + mkdir -p protos + + # Download all protos without transitive deps (-i) to avoid pulling in + # ~250 arcade/UI/franklin protos via client_renderable.proto + bin/schema-registry get --save-to=protos -i \ + google/api/annotations.proto \ + google/api/http.proto \ + squareup/cash/kgoose/api/v3/tool_endpoint_service.proto \ + squareup/cash/kgoose/api/v3/tool_endpoint_messages.proto \ + squareup/cash/kgoose/api/v3/agent_config_messages.proto \ + squareup/cash/kgoose/api/v3/extension_selection_config.proto \ + squareup/cash/kgoose/api/v3/extension_messages.proto \ + squareup/cash/kgoose/api/v3/chat_messages.proto \ + squareup/cash/kgoose/api/v3/activity_messages.proto \ + squareup/cash/kgoose/api/v3/profile_messages.proto \ + squareup/cash/kgoose/api/v3/common_messages.proto \ + squareup/cash/kgoosememorystore/api/v1beta1/memory.proto \ + squareup/common/pii.proto \ + squareup/common/governance/v0/semantic_types.proto \ + squareup/common/governance/v0/common.proto \ + squareup/common/governance/v0/consumer_personal_data.proto \ + squareup/common/governance/v0/merchant_data.proto \ + squareup/common/governance/v0/payment_card_data.proto \ + squareup/common/governance/v0/employee_personal_data.proto + + # Remove imports and messages we don't need to avoid lots of proto imports + echo "🔧 Cleaning up proto files..." + + PROTO_FILES_TO_CLEAN=( + "protos/squareup/cash/kgoose/api/v3/chat_messages.proto" + "protos/squareup/cash/kgoose/api/v3/activity_messages.proto" + ) + + cleanup_proto_file() { + local proto_file="$1" + + if [[ -f "$proto_file" ]]; then + echo " 🔧 Cleaning up $(basename "$proto_file")..." + + # Remove specific imports + sed -i '' '/import "squareup\/cash\/kgoose\/api\/v3\/client_renderable.proto";/d' "$proto_file" + sed -i '' '/import "squareup\/cash\/kgoose\/api\/v3\/customer_context.proto";/d' "$proto_file" + + # Comment out ClientRenderable lines (optional or required, any field number) + sed -i '' -E 's/^[[:space:]]*(optional )?(squareup\.cash\.kgoose\.api\.v3\.)?ClientRenderable.*=.*[0-9]*.*;/ \/\/ &/' "$proto_file" + + # Comment out CustomerContext lines (optional or required, any field number) + sed -i '' -E 's/^[[:space:]]*(optional )?(squareup\.cash\.kgoose\.api\.v3\.)?CustomerContext.*=.*[0-9]*.*;/ \/\/ &/' "$proto_file" + + echo " ✅ Successfully cleaned up $(basename "$proto_file")" + else + echo " ⚠️ $(basename "$proto_file") not found, skipping cleanup" + fi + } + + for proto_file in "${PROTO_FILES_TO_CLEAN[@]}"; do + cleanup_proto_file "$proto_file" + done + + echo "✅ Proto cleanup complete" + cargo build --locked + +run *args: + cargo run -- {{args}} + +describe-commands: + cargo run -- --describe-commands + +update-extensions-catalog: build-sq + bash script/update-extensions-catalog + +fmt: + cargo fmt --all + +fmt-check: + cargo fmt --all -- --check + +lint: fmt-check + cargo clippy --locked --all-targets --all-features -- -D warnings + +test: + cargo test --locked --all-features + +package-smoke: build-sq + ./sqbin/{{BIN_NAME}}.exoskeleton --version + +# Build and run the isolated, deterministic Docker acceptance harness for bl skills. +bl-cli-docker-acceptance: + docker build --tag bl-cli-acceptance --file docker/acceptance/Dockerfile . + docker run --rm bl-cli-acceptance + +check: fmt-check lint test + +ci-lint: fmt-check lint + +ci-test: test package-smoke + +ci: ci-lint ci-test + +setup: install-hooks + +install-hooks: + lefthook install diff --git a/README.md b/README.md index 95b884d..6839976 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,145 @@ -# builderlab-cli README +# BuilderLab CLI -Congrats, project leads! You got a new project to grow! +`bl` is the command-line interface for BuilderLab. It provides authenticated +access to BuilderLab skills, agents, workspaces, and apps, along with the +`bl tools` command for discovering connected tool extensions. -This stub is meant to help you form a strong community around your work. It's yours to adapt, and may -diverge from this initial structure. Just keep the files seeded in this repo, and the rest is yours to evolve! +The CLI is written in Rust and is built from this repository. Run `bl --help` +to see the current command surface: -## Introduction +```text +auth Manage BuilderLab marketplace authentication +workspace Manage BuilderLab workspaces +apps Manage apps through Apps Platform +config Get and set bl preferences +skills Manage BuilderLab skills +agents Manage BuilderLab marketplace agents +completions Generate shell completions +tools Discover auth-backed tool extensions +``` -Orient users to the project here. This is a good place to start with an assumption -that the user knows very little - so start with the Big Picture and show how this -project fits into it. +## Quick start -Then maybe a dive into what this project does. +This repository uses the Hermit-managed toolchain. From the repository root: -Diagrams and other visuals are helpful here. Perhaps code snippets showing usage. +```bash +source ./bin/activate-hermit +just build-bl-release +target/release/bl --help +``` -Project leads should complete, alongside this `README`: +For a debug build during development: -* [CODEOWNERS](./CODEOWNERS) - set project lead(s) -* [CONTRIBUTING.md](./CONTRIBUTING.md) - Fill out how to: install prereqs, build, test, run, access CI, chat, discuss, file issues -* [Bug-report.md](.github/ISSUE_TEMPLATE/bug-report.md) - Fill out `Assignees` add codeowners @names -* [config.yml](.github/ISSUE_TEMPLATE/config.yml) - remove "(/add your discord channel..)" and replace the url with your Discord channel if applicable +```bash +cargo build --locked --bin bl +./target/debug/bl --version +``` -The other files in this template repo may be used as-is: +Authenticate before using commands that access BuilderLab services: -* [GOVERNANCE.md](./GOVERNANCE.md) -* [LICENSE](./LICENSE) +```bash +./target/debug/bl auth login +./target/debug/bl auth status +./target/debug/bl skills list +``` -## Project Resources +The CLI can also configure the organization used for service routing: -| Resource | Description | -| ------------------------------------------ | ------------------------------------------------------------------------------ | -| [CODEOWNERS](./CODEOWNERS) | Outlines the project lead(s) | -| [GOVERNANCE.md](./GOVERNANCE.md) | Project governance | -| [LICENSE](./LICENSE) | Apache License, Version 2.0 | +```bash +./target/debug/bl config set org +./target/debug/bl config get org +``` + +Use `--json` for machine-readable output and `--verbose` for request +diagnostics. Do not include credentials or other sensitive data when sharing +verbose output. + +## Local development + +Install the repository tools and git hooks once: + +```bash +./bin/hermit install rustup just lefthook +source ./bin/activate-hermit +just setup +``` + +Run the standard checks: + +```bash +just fmt-check +just lint +just test +just ci +``` + +Run the isolated Docker acceptance harness for the skills workflows: + +```bash +just bl-cli-docker-acceptance +``` + +For browser-based authentication and local service testing, see: + +- [BuilderLab Auth Flow](docs/bl-auth-flow.md) +- [BuilderLab Local Auth Testing](docs/bl-auth-local-testing.md) + +## Configuration + +The most useful configuration options are: + +| Variable | Purpose | +| --- | --- | +| `BL_HOME` | Override the BuilderLab state directory. | +| `BL_SKILLS_HOME` | Override the installed skills directory. | +| `BL_SKILLS_CONFIG` | Select an explicit skills configuration file. | +| `BL_SKILLS_PROFILE` | Select a skills configuration profile. | +| `BL_AUTH_STORAGE` | Select authentication storage, including `file` for local testing. | +| `BL_AUTH_STORAGE_FILE` | Path used when file-backed auth storage is selected. | +| `KGOOSE_BASE_URL` | Override the backend base URL for local development. | +| `BL_KGOOSE_PLAYPEN` | Route backend requests through a named development playpen. | + +For example, a local backend can be used without changing the stored profile: + +```bash +KGOOSE_BASE_URL=http://localhost:8080 \ + ./target/debug/bl --local-dev skills list +``` + +Authentication storage defaults to the operating-system keyring where +supported. The local auth guide documents file-backed storage for tests and +development without modifying keyring state. + +## Project layout + +- `src/bl/` contains the `bl` command implementations. +- `crates/builderlab-auth/` contains browser login, session storage, workspace, + and organization-routing support. +- `src/lib.rs` wires the CLI runtime and test harness together. +- `tests/bl_e2e.rs` contains the offline and mock-service end-to-end coverage. +- `docker/acceptance/` contains the isolated skills acceptance harness. +- `docs/RELEASING-bl.md` describes release builds and downstream packaging. + +## Release and distribution + +Build a release binary with: + +```bash +source ./bin/activate-hermit +just build-bl-release +target/release/bl --version +``` + +This repository owns the `bl` binary. Application packaging and the installed +command link are managed by the consuming BuilderLab application; this repo +does not provide standalone installers or platform archives. See +[RELEASING-bl.md](docs/RELEASING-bl.md) for the release boundary and packaging +integration. + +## Contributing + +- [Contributing guide](CONTRIBUTING.md) +- [Report a bug or request a feature](https://github.com/block/builderlab-cli/issues/new/choose) +- [Security policy](SECURITY.md) +- [Block Open Source governance](GOVERNANCE.md) +- [Apache License 2.0](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8481282 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +## Block Open Source Security Policy + +Please report security vulnerabilities privately through the repository's +**Security** tab using **Report a vulnerability**. Do not disclose a +vulnerability in a public GitHub issue. + +See the [Block Open Source Security Policy](https://github.com/block/.github/blob/main/SECURITY.md) +for the reporting and disclosure guidelines. diff --git a/bin/.just-1.46.0.pkg b/bin/.just-1.46.0.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.just-1.46.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.lefthook-2.1.4.pkg b/bin/.lefthook-2.1.4.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.lefthook-2.1.4.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.rustup-1.29.0.pkg b/bin/.rustup-1.29.0.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.rustup-1.29.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.schema-registry-0.127.1.pkg b/bin/.schema-registry-0.127.1.pkg new file mode 120000 index 0000000..383f451 --- /dev/null +++ b/bin/.schema-registry-0.127.1.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/README.hermit.md b/bin/README.hermit.md new file mode 100644 index 0000000..e889550 --- /dev/null +++ b/bin/README.hermit.md @@ -0,0 +1,7 @@ +# Hermit environment + +This is a [Hermit](https://github.com/cashapp/hermit) bin directory. + +The symlinks in this directory are managed by Hermit and will automatically +download and install Hermit itself as well as packages. These packages are +local to this environment. diff --git a/bin/activate-hermit b/bin/activate-hermit new file mode 100755 index 0000000..fe28214 --- /dev/null +++ b/bin/activate-hermit @@ -0,0 +1,21 @@ +#!/bin/bash +# This file must be used with "source bin/activate-hermit" from bash or zsh. +# You cannot run it directly +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if [ "${BASH_SOURCE-}" = "$0" ]; then + echo "You must source this script: \$ source $0" >&2 + exit 33 +fi + +BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" +if "${BIN_DIR}/hermit" noop > /dev/null; then + eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" + + if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then + hash -r 2>/dev/null + fi + + echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" +fi diff --git a/bin/activate-hermit.fish b/bin/activate-hermit.fish new file mode 100755 index 0000000..0367d23 --- /dev/null +++ b/bin/activate-hermit.fish @@ -0,0 +1,24 @@ +#!/usr/bin/env fish + +# This file must be sourced with "source bin/activate-hermit.fish" from Fish shell. +# You cannot run it directly. +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if status is-interactive + set BIN_DIR (dirname (status --current-filename)) + + if "$BIN_DIR/hermit" noop > /dev/null + # Source the activation script generated by Hermit + "$BIN_DIR/hermit" activate "$BIN_DIR/.." | source + + # Clear the command cache if applicable + functions -c > /dev/null 2>&1 + + # Display activation message + echo "Hermit environment $($HERMIT_ENV/bin/hermit env HERMIT_ENV) activated" + end +else + echo "You must source this script: source $argv[0]" >&2 + exit 33 +end diff --git a/bin/cargo b/bin/cargo new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/cargo @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/cargo-clippy b/bin/cargo-clippy new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/cargo-clippy @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/cargo-fmt b/bin/cargo-fmt new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/cargo-fmt @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/cargo-miri b/bin/cargo-miri new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/cargo-miri @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/clippy-driver b/bin/clippy-driver new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/clippy-driver @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/hermit b/bin/hermit new file mode 100755 index 0000000..87acaad --- /dev/null +++ b/bin/hermit @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://d1abdrezunyhdp.cloudfront.net/square}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="4b006236f2e5e81939229b377bb355e3608f94d73ff8feccbd5792d1ed5699cd" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/bin/hermit.hcl b/bin/hermit.hcl new file mode 100644 index 0000000..cc17d79 --- /dev/null +++ b/bin/hermit.hcl @@ -0,0 +1,4 @@ +manage-git = false + +github-token-auth { +} diff --git a/bin/just b/bin/just new file mode 120000 index 0000000..816066f --- /dev/null +++ b/bin/just @@ -0,0 +1 @@ +.just-1.46.0.pkg \ No newline at end of file diff --git a/bin/lefthook b/bin/lefthook new file mode 120000 index 0000000..6e6dff2 --- /dev/null +++ b/bin/lefthook @@ -0,0 +1 @@ +.lefthook-2.1.4.pkg \ No newline at end of file diff --git a/bin/rls b/bin/rls new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/rls @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/rust-analyzer b/bin/rust-analyzer new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/rust-analyzer @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/rust-gdb b/bin/rust-gdb new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/rust-gdb @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/rust-gdbgui b/bin/rust-gdbgui new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/rust-gdbgui @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/rust-lldb b/bin/rust-lldb new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/rust-lldb @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/rustc b/bin/rustc new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/rustc @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/rustdoc b/bin/rustdoc new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/rustdoc @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/rustfmt b/bin/rustfmt new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/rustfmt @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/rustup b/bin/rustup new file mode 120000 index 0000000..b753bdb --- /dev/null +++ b/bin/rustup @@ -0,0 +1 @@ +.rustup-1.29.0.pkg \ No newline at end of file diff --git a/bin/schema-registry b/bin/schema-registry new file mode 120000 index 0000000..8b5fe99 --- /dev/null +++ b/bin/schema-registry @@ -0,0 +1 @@ +.schema-registry-0.127.1.pkg \ No newline at end of file diff --git a/bl-local-dev-config.yaml b/bl-local-dev-config.yaml new file mode 100644 index 0000000..1134d82 --- /dev/null +++ b/bl-local-dev-config.yaml @@ -0,0 +1,11 @@ +# BuilderLab local development profile. +# Loaded by `bl --local-dev ...` when this file is found in the current +# directory or one of its ancestors. +# Related local services: +# - UI: http://localhost:5173/agent-manager/marketplace +# - App/CLI updates: http://localhost:8080/v1/marketplace/bl/releases/latest +# - Temporal UI: http://localhost:8233 +current_profile: local-dev +profiles: + local-dev: + skills_home: .bl/local-dev/skills diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..7b4e5b1 --- /dev/null +++ b/build.rs @@ -0,0 +1,69 @@ +use std::path::{Path, PathBuf}; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=protos"); + + let protoc = protoc_bin_vendored::protoc_bin_path()?; + unsafe { + std::env::set_var("PROTOC", protoc); + } + + let proto_root = PathBuf::from("protos"); + let service_proto = proto_root.join("squareup/cash/kgoose/api/v3/tool_endpoint_service.proto"); + let messages_proto = + proto_root.join("squareup/cash/kgoose/api/v3/tool_endpoint_messages.proto"); + let descriptor_path = PathBuf::from(std::env::var("OUT_DIR")?).join("proto_descriptor.bin"); + + for proto in [&service_proto, &messages_proto] { + ensure_proto_exists(proto)?; + } + + tonic_prost_build::configure() + .include_file("proto.rs") + .file_descriptor_set_path(&descriptor_path) + .extern_path(".google.protobuf.Struct", "::pbjson_types::Struct") + .extern_path(".google.protobuf.Value", "::pbjson_types::Value") + .extern_path(".google.protobuf.ListValue", "::pbjson_types::ListValue") + .extern_path(".google.protobuf.NullValue", "::pbjson_types::NullValue") + .compile_protos(&[service_proto, messages_proto], &[proto_root])?; + + let descriptor_set = std::fs::read(&descriptor_path)?; + pbjson_build::Builder::new() + .register_descriptors(&descriptor_set)? + .preserve_proto_field_names() + .build(&[ + ".squareup.cash.kgoose.api.v3.CallToolRequest", + ".squareup.cash.kgoose.api.v3.CallToolResponse", + ".squareup.cash.kgoose.api.v3.EmbeddedResource", + ".squareup.cash.kgoose.api.v3.ExtensionInfo", + ".squareup.cash.kgoose.api.v3.ImageContent", + ".squareup.cash.kgoose.api.v3.ListExtensionsRequest", + ".squareup.cash.kgoose.api.v3.ListExtensionsResponse", + ".squareup.cash.kgoose.api.v3.ListToolsRequest", + ".squareup.cash.kgoose.api.v3.ListToolsResponse", + ".squareup.cash.kgoose.api.v3.ResourceAnnotations", + ".squareup.cash.kgoose.api.v3.ResourceContents", + ".squareup.cash.kgoose.api.v3.Role", + ".squareup.cash.kgoose.api.v3.Source", + ".squareup.cash.kgoose.api.v3.StructuredContent", + ".squareup.cash.kgoose.api.v3.Tenancy", + ".squareup.cash.kgoose.api.v3.TextContent", + ".squareup.cash.kgoose.api.v3.ToolConfig", + ".squareup.cash.kgoose.api.v3.UserContent", + ])?; + + Ok(()) +} + +fn ensure_proto_exists(path: &Path) -> Result<(), Box> { + if path.exists() { + return Ok(()); + } + + Err(format!( + "missing required proto `{}`; run `just download-protos` first", + path.display() + ) + .into()) +} diff --git a/crates/builderlab-auth/Cargo.toml b/crates/builderlab-auth/Cargo.toml new file mode 100644 index 0000000..fafbf7f --- /dev/null +++ b/crates/builderlab-auth/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "builderlab-auth" +version = "0.1.0" +edition = "2021" +rust-version = "1.88.0" +license = "Apache-2.0" +publish = false + +[features] +default = [] +blocking-client = ["dep:reqwest"] + +[dependencies] +anyhow = "1" +reqwest = { version = "0.12", optional = true, default-features = false, features = ["blocking", "json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +url = "2.5" + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10" +security-framework-sys = { version = "2.14", default-features = false } diff --git a/crates/builderlab-auth/src/auth.rs b/crates/builderlab-auth/src/auth.rs new file mode 100644 index 0000000..cb5ed96 --- /dev/null +++ b/crates/builderlab-auth/src/auth.rs @@ -0,0 +1,3 @@ +// This is a backend wire-protocol name. Keep the legacy BB prefix until the +// BuilderLab backend migrates the header contract. +pub const SESSION_CREDENTIAL_HEADER: &str = "X-BB-Session-Credential"; diff --git a/crates/builderlab-auth/src/auth_login.rs b/crates/builderlab-auth/src/auth_login.rs new file mode 100644 index 0000000..8f07a26 --- /dev/null +++ b/crates/builderlab-auth/src/auth_login.rs @@ -0,0 +1,541 @@ +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use reqwest::blocking::{Client, ClientBuilder}; +use reqwest::header::{ACCEPT, USER_AGENT}; +use reqwest::redirect::Policy; +use reqwest::StatusCode as HttpStatusCode; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::auth::SESSION_CREDENTIAL_HEADER; +use crate::auth_storage::StoredSessionCredential; + +pub const CLI_USER_AGENT: &str = "sq-kgoose-bl-auth-login"; + +#[derive(Debug, Deserialize)] +pub struct LoginExchangeResponse { + pub session_credential: String, + pub expires_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct AuthMeResponse { + pub subject: Option, + pub email: Option, + pub name: Option, + pub expires_at: Option, + pub workspaces: AuthMeWorkspaces, +} + +impl AuthMeResponse { + pub fn active_workspace_name(&self) -> Result<&str> { + self.workspaces + .active + .first() + .map(|workspace| workspace.name.as_str()) + .ok_or_else(|| anyhow!("/v1/auth/me returned no active workspaces")) + } +} + +#[derive(Debug, Deserialize)] +pub struct AuthMeWorkspaces { + pub active: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AuthMeWorkspace { + pub name: String, +} + +#[derive(Debug)] +pub struct VerifiedLoginSession { + pub credential: StoredSessionCredential, + pub me: AuthMeResponse, +} + +#[derive(Debug, Serialize)] +struct LoginExchangeRequest<'a> { + code: &'a str, +} + +pub fn build_auth_http_client(timeout: Duration) -> Result { + ClientBuilder::new() + .redirect(Policy::none()) + .timeout(timeout) + .build() + .context("build auth login HTTP client") +} + +pub fn exchange_login_code( + client: &Client, + playpen: Option<&str>, + server_url: &str, + code: &str, +) -> Result { + let url = auth_url(server_url, "/v1/auth/login/exchange")?; + let mut request = client + .post(url) + .header(USER_AGENT, CLI_USER_AGENT) + .header(ACCEPT, "application/json") + .json(&LoginExchangeRequest { code }); + if let Some(baggage) = playpen_baggage(playpen) { + request = request.header("Baggage", baggage); + } + let response = request.send().context("exchange login code")?; + let status = response.status(); + if !status.is_success() { + return Err(anyhow!("/v1/auth/login/exchange failed with {status}")); + } + let body = response.text().context("read login exchange response")?; + serde_json::from_str(&body).context("parse login exchange response") +} + +pub fn exchange_login_code_and_verify( + client: &Client, + playpen: Option<&str>, + server_url: &str, + code: &str, +) -> Result { + let exchange = exchange_login_code(client, playpen, server_url, code)?; + let credential = StoredSessionCredential { + session_credential: exchange.session_credential, + expires_at: Some(exchange.expires_at), + }; + let me = + verify_session_credential(client, playpen, server_url, &credential)?.ok_or_else(|| { + anyhow!("exchanged BuilderLab CLI auth session was rejected by /v1/auth/me") + })?; + Ok(VerifiedLoginSession { credential, me }) +} + +pub fn verify_session_credential( + client: &Client, + playpen: Option<&str>, + server_url: &str, + credential: &StoredSessionCredential, +) -> Result> { + let Some(session_credential) = credential.session_credential_header_value() else { + return Ok(None); + }; + let url = auth_url(server_url, "/v1/auth/me")?; + let mut request = client + .get(url) + .header(USER_AGENT, CLI_USER_AGENT) + .header(ACCEPT, "application/json") + .header(SESSION_CREDENTIAL_HEADER, session_credential); + if let Some(baggage) = playpen_baggage(playpen) { + request = request.header("Baggage", baggage); + } + let response = request + .send() + .context("verify stored BuilderLab CLI auth session")?; + let status = response.status(); + if status == HttpStatusCode::UNAUTHORIZED || status == HttpStatusCode::FORBIDDEN { + return Ok(None); + } + if !status.is_success() { + return Err(anyhow!("/v1/auth/me failed with {status}")); + } + let body = response.text().context("read /v1/auth/me response")?; + let me: AuthMeResponse = serde_json::from_str(&body).context("parse /v1/auth/me response")?; + Ok(Some(me)) +} + +pub fn logout_session_credential( + client: &Client, + playpen: Option<&str>, + server_url: &str, + credential: &StoredSessionCredential, +) -> Result { + let Some(session_credential) = credential.session_credential_header_value() else { + return Ok(false); + }; + let url = auth_url(server_url, "/v1/auth/logout")?; + let mut request = client + .post(url) + .header(USER_AGENT, CLI_USER_AGENT) + .header(ACCEPT, "application/json") + .header(SESSION_CREDENTIAL_HEADER, session_credential); + if let Some(baggage) = playpen_baggage(playpen) { + request = request.header("Baggage", baggage); + } + let response = request + .send() + .context("destroy stored BuilderLab CLI auth session")?; + let status = response.status(); + if status == HttpStatusCode::UNAUTHORIZED || status == HttpStatusCode::FORBIDDEN { + return Ok(false); + } + if !status.is_success() { + return Err(anyhow!("/v1/auth/logout failed with {status}")); + } + Ok(true) +} + +pub fn login_url(server_url: &str, callback_url: &str) -> Result { + let mut url = auth_url(server_url, "/v1/auth/login")?; + url.query_pairs_mut() + .append_pair("type", "cli") + .append_pair("returnTo", callback_url); + Ok(url) +} + +pub fn auth_url(server_url: &str, path: &str) -> Result { + let base = Url::parse(server_url).context("server URL must be absolute")?; + let path = format!( + "{}/{}", + base.path().trim_end_matches('/'), + path.trim_start_matches('/') + ); + let mut url = base; + url.set_path(&path); + url.set_query(None); + url.set_fragment(None); + Ok(url) +} + +pub fn playpen_baggage(playpen: Option<&str>) -> Option { + playpen.map(|playpen| format!("kgoose-builderlab-playpen={playpen}")) +} + +#[cfg(test)] +mod tests { + use std::io::{BufRead, BufReader, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::{Arc, Mutex}; + use std::thread; + + use super::*; + + #[test] + fn verify_session_credential_treats_unauthorized_as_invalid() { + let server = SingleResponseServer::start(401, r#"{}"#); + let client = build_auth_http_client(Duration::from_secs(5)).expect("client"); + let credential = StoredSessionCredential { + session_credential: "expired-session".to_string(), + expires_at: None, + }; + + let verified = verify_session_credential(&client, None, &server.base_url, &credential) + .expect("verify session"); + let request = server.finish(); + + assert!(verified.is_none()); + assert_eq!(request.path, "/v1/auth/me"); + assert_eq!( + request.bl_session_credential.as_deref(), + Some("expired-session") + ); + } + + #[test] + fn verify_session_credential_does_not_echo_failure_body() { + let secret = "reflected_session_credential_123456"; + let server = SingleResponseServer::start(500, secret); + let client = build_auth_http_client(Duration::from_secs(5)).expect("client"); + let credential = StoredSessionCredential { + session_credential: secret.to_string(), + expires_at: None, + }; + + let error = verify_session_credential(&client, None, &server.base_url, &credential) + .expect_err("reject failed session check"); + let request = server.finish(); + let message = format!("{error:#}"); + + assert!(message.contains("/v1/auth/me failed with 500")); + assert!(!message.contains(secret)); + assert_eq!(request.path, "/v1/auth/me"); + } + + #[test] + fn verify_session_credential_skips_empty_stored_credential() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind unused server"); + let base_url = format!("http://{}", listener.local_addr().expect("local addr")); + let client = build_auth_http_client(Duration::from_secs(1)).expect("client"); + let credential = StoredSessionCredential { + session_credential: String::new(), + expires_at: None, + }; + + let verified = verify_session_credential(&client, None, &base_url, &credential) + .expect("verify session"); + + assert!(verified.is_none()); + } + + #[test] + fn logout_session_credential_posts_logout_and_accepts_success() { + let server = SingleResponseServer::start(200, r#"{}"#); + let client = build_auth_http_client(Duration::from_secs(5)).expect("client"); + let credential = StoredSessionCredential { + session_credential: "valid-session".to_string(), + expires_at: None, + }; + + let logged_out = logout_session_credential(&client, None, &server.base_url, &credential) + .expect("logout session"); + let request = server.finish(); + + assert!(logged_out); + assert_eq!(request.path, "/v1/auth/logout"); + assert_eq!( + request.bl_session_credential.as_deref(), + Some("valid-session") + ); + } + + #[test] + fn logout_session_credential_treats_unauthorized_as_already_invalid() { + let server = SingleResponseServer::start(401, r#"{}"#); + let client = build_auth_http_client(Duration::from_secs(5)).expect("client"); + let credential = StoredSessionCredential { + session_credential: "expired-session".to_string(), + expires_at: None, + }; + + let logged_out = logout_session_credential(&client, None, &server.base_url, &credential) + .expect("logout session"); + let request = server.finish(); + + assert!(!logged_out); + assert_eq!(request.path, "/v1/auth/logout"); + assert_eq!( + request.bl_session_credential.as_deref(), + Some("expired-session") + ); + } + + #[test] + fn verify_session_credential_accepts_successful_me_response() { + let server = SingleResponseServer::start( + 200, + r#"{"subject":"auth0|user_123","email":"test@example.com","name":"Test User","expires_at":"2026-06-15T00:00:00Z","roles":["ROLE_USER"],"workspaces":{"active":[{"name":"Test Workspace"},{"name":"Other Workspace"}]}}"#, + ); + let client = build_auth_http_client(Duration::from_secs(5)).expect("client"); + let credential = StoredSessionCredential { + session_credential: "valid-session".to_string(), + expires_at: None, + }; + + let verified = verify_session_credential(&client, None, &server.base_url, &credential) + .expect("verify session") + .expect("authenticated"); + let request = server.finish(); + + assert_eq!(verified.subject.as_deref(), Some("auth0|user_123")); + assert_eq!(verified.expires_at.as_deref(), Some("2026-06-15T00:00:00Z")); + assert_eq!( + verified.active_workspace_name().expect("active workspace"), + "Test Workspace" + ); + assert_eq!( + request.bl_session_credential.as_deref(), + Some("valid-session") + ); + assert_eq!(request.path, "/v1/auth/me"); + } + + #[test] + fn login_url_uses_v1_route() { + let url = login_url( + "https://example.com/cash-app/goose", + "http://127.0.0.1:1234/callback", + ) + .expect("login URL"); + + assert_eq!(url.path(), "/cash-app/goose/v1/auth/login"); + } + + #[test] + fn exchange_login_code_uses_v1_route() { + let server = SingleResponseServer::start( + 200, + r#"{"session_credential":"session","expires_at":"2026-06-15T00:00:00Z"}"#, + ); + let client = build_auth_http_client(Duration::from_secs(5)).expect("client"); + + let exchange = exchange_login_code(&client, None, &server.base_url, "one-time-code") + .expect("exchange"); + let request = server.finish(); + + assert_eq!(exchange.session_credential, "session"); + assert_eq!(exchange.expires_at, "2026-06-15T00:00:00Z"); + assert_eq!(request.path, "/v1/auth/login/exchange"); + } + + #[test] + fn exchange_login_code_and_verify_checks_auth_me() { + let server = SequentialResponseServer::start(vec![ + ( + 200, + r#"{"session_credential":"session","expires_at":"2026-06-15T00:00:00Z"}"#, + ), + ( + 200, + r#"{"subject":"auth0|user_123","email":"test@example.com","name":"Test User","expires_at":"2026-06-16T00:00:00Z","roles":["ROLE_USER"],"workspaces":{"active":[{"name":"Test Workspace"},{"name":"Other Workspace"}]}}"#, + ), + ]); + let client = build_auth_http_client(Duration::from_secs(5)).expect("client"); + + let verified = + exchange_login_code_and_verify(&client, None, &server.base_url, "one-time-code") + .expect("verified login"); + let requests = server.finish(); + + assert_eq!(verified.credential.session_credential, "session"); + assert_eq!( + verified.credential.expires_at.as_deref(), + Some("2026-06-15T00:00:00Z") + ); + assert_eq!(verified.me.subject.as_deref(), Some("auth0|user_123")); + assert_eq!( + verified + .me + .active_workspace_name() + .expect("active workspace"), + "Test Workspace" + ); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].path, "/v1/auth/login/exchange"); + assert_eq!(requests[1].path, "/v1/auth/me"); + assert_eq!( + requests[1].bl_session_credential.as_deref(), + Some("session") + ); + } + + #[test] + fn exchange_login_code_and_verify_requires_auth_me_success() { + let server = SequentialResponseServer::start(vec![ + ( + 200, + r#"{"session_credential":"session","expires_at":"2026-06-15T00:00:00Z"}"#, + ), + (401, r#"{}"#), + ]); + let client = build_auth_http_client(Duration::from_secs(5)).expect("client"); + + let error = + exchange_login_code_and_verify(&client, None, &server.base_url, "one-time-code") + .expect_err("auth/me rejection fails login"); + let requests = server.finish(); + + assert!( + format!("{error:#}").contains("rejected by /v1/auth/me"), + "unexpected error: {error:#}" + ); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].path, "/v1/auth/login/exchange"); + assert_eq!(requests[1].path, "/v1/auth/me"); + } + + struct RecordedRequest { + path: String, + bl_session_credential: Option, + } + + struct SingleResponseServer { + base_url: String, + handle: thread::JoinHandle, + } + + impl SingleResponseServer { + fn start(status: u16, body: &'static str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let base_url = format!("http://{}", listener.local_addr().expect("local addr")); + let request = Arc::new(Mutex::new(None)); + let thread_request = Arc::clone(&request); + let handle = thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept request"); + handle_connection(stream, status, body, &thread_request); + thread_request + .lock() + .expect("request mutex") + .take() + .expect("recorded request") + }); + Self { base_url, handle } + } + + fn finish(self) -> RecordedRequest { + self.handle.join().expect("join test server") + } + } + + struct SequentialResponseServer { + base_url: String, + handle: thread::JoinHandle>, + } + + impl SequentialResponseServer { + fn start(responses: Vec<(u16, &'static str)>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let base_url = format!("http://{}", listener.local_addr().expect("local addr")); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + for (status, body) in responses { + let (stream, _) = listener.accept().expect("accept request"); + let request = Arc::new(Mutex::new(None)); + handle_connection(stream, status, body, &request); + requests.push( + request + .lock() + .expect("request mutex") + .take() + .expect("recorded request"), + ); + } + requests + }); + Self { base_url, handle } + } + + fn finish(self) -> Vec { + self.handle.join().expect("join test server") + } + } + + fn handle_connection( + mut stream: TcpStream, + status: u16, + body: &str, + request: &Arc>>, + ) { + let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); + let mut request_line = String::new(); + reader.read_line(&mut request_line).expect("request line"); + let path = request_line + .split_whitespace() + .nth(1) + .expect("request path") + .to_string(); + + let mut bl_session_credential = None; + loop { + let mut line = String::new(); + reader.read_line(&mut line).expect("request header"); + if line == "\r\n" { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case(SESSION_CREDENTIAL_HEADER) { + bl_session_credential = Some(value.trim().to_string()); + } + } + } + *request.lock().expect("request mutex") = Some(RecordedRequest { + path, + bl_session_credential, + }); + + let response = format!( + "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + } +} diff --git a/crates/builderlab-auth/src/auth_storage.rs b/crates/builderlab-auth/src/auth_storage.rs new file mode 100644 index 0000000..702145c --- /dev/null +++ b/crates/builderlab-auth/src/auth_storage.rs @@ -0,0 +1,578 @@ +use std::collections::BTreeMap; +#[cfg(any(debug_assertions, test))] +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +#[cfg(any(debug_assertions, test))] +use std::sync::Mutex; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::config::kgoose_service_url; + +#[cfg(target_os = "macos")] +const KEYRING_SERVICE: &str = "com.squareup.builderlab.cli-auth"; +#[cfg(target_os = "macos")] +const LEGACY_PURPOSE_TOKEN_KEYRING_SERVICE: &str = "com.squareup.builderlab.cli-auth-purpose-token"; +pub const BL_AUTH_STORAGE_ENV_VAR: &str = "BL_AUTH_STORAGE"; +pub const BL_AUTH_STORAGE_FILE_ENV_VAR: &str = "BL_AUTH_STORAGE_FILE"; + +#[derive(Debug, Clone)] +pub struct SessionStorageKey { + profile: String, + server_url: String, +} + +impl SessionStorageKey { + pub fn new(profile: impl Into, server_url: impl Into) -> Self { + Self { + profile: profile.into(), + server_url: server_url.into().trim_end_matches('/').to_string(), + } + } + + pub fn from_profile_and_kgoose_base_url( + profile: impl Into, + kgoose_base_url: &str, + kgoose_service_path: &str, + ) -> Self { + Self::new( + profile, + kgoose_service_url(kgoose_base_url, kgoose_service_path), + ) + } + + #[cfg(target_os = "macos")] + fn account(&self) -> String { + format!("{}@{}", self.profile, self.server_url) + } + + fn hashed_id(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(self.profile.as_bytes()); + hasher.update([0]); + hasher.update(self.server_url.as_bytes()); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } +} + +#[cfg(target_os = "macos")] +fn legacy_compose_token_account(session: &SessionStorageKey) -> String { + format!("compose@{}", session.account()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredSessionCredential { + pub session_credential: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + +impl StoredSessionCredential { + pub fn session_credential_header_value(&self) -> Option { + let session_credential = self.session_credential.trim(); + if session_credential.is_empty() { + None + } else { + Some(session_credential.to_string()) + } + } +} + +pub trait SessionCredentialStorage { + fn kind(&self) -> &'static str; + fn get(&self, key: &SessionStorageKey) -> Result>; + fn set(&self, key: &SessionStorageKey, credential: &StoredSessionCredential) -> Result<()>; + fn delete(&self, key: &SessionStorageKey) -> Result; + fn delete_legacy_purpose_token_cache(&self, _key: &SessionStorageKey) -> Result { + Ok(false) + } +} + +pub fn default_session_storage_for_bl_home( + bl_home: PathBuf, +) -> Result> { + match std::env::var(BL_AUTH_STORAGE_ENV_VAR).as_deref() { + Ok("keyring") => Ok(Box::new(KeyringSessionCredentialStorage)), + #[cfg(any(debug_assertions, test))] + Ok("memory") => Ok(Box::new(InMemorySessionCredentialStorage::default())), + Ok("file") => file_storage_from_env(&bl_home), + Ok(value) if value.starts_with("file:") => { + let path = value.trim_start_matches("file:"); + if path.is_empty() { + anyhow::bail!("{BL_AUTH_STORAGE_ENV_VAR}=file: requires a path"); + } + Ok(Box::new(FileSessionCredentialStorage::new(PathBuf::from( + path, + )))) + } + #[cfg(not(any(debug_assertions, test)))] + Ok("memory") => { + anyhow::bail!( + "{BL_AUTH_STORAGE_ENV_VAR}=memory is only available in non-production builds" + ) + } + Ok(value) => anyhow::bail!( + "unsupported {BL_AUTH_STORAGE_ENV_VAR}={value}; expected {}", + supported_storage_values(), + ), + Err(std::env::VarError::NotPresent) => { + if let Some(path) = std::env::var_os(BL_AUTH_STORAGE_FILE_ENV_VAR) { + return Ok(Box::new(FileSessionCredentialStorage::new(PathBuf::from( + path, + )))); + } + Ok(Box::new(KeyringSessionCredentialStorage)) + } + Err(error) => Err(anyhow!("read {BL_AUTH_STORAGE_ENV_VAR}: {error}")), + } +} + +pub fn stored_session_credential_header_value( + profile: &str, + server_url: &str, + bl_home: PathBuf, +) -> Result> { + #[cfg(not(target_os = "macos"))] + if std::env::var_os(BL_AUTH_STORAGE_ENV_VAR).is_none() + && std::env::var_os(BL_AUTH_STORAGE_FILE_ENV_VAR).is_none() + { + return Ok(None); + } + + let storage = default_session_storage_for_bl_home(bl_home)?; + let storage_key = SessionStorageKey::new(profile, server_url); + Ok(storage + .get(&storage_key)? + .and_then(|credential| credential.session_credential_header_value())) +} + +pub fn stored_session_credential_header_value_for_kgoose_base_url( + profile: &str, + base_url: &str, + service_path: &str, + bl_home: PathBuf, +) -> Result> { + for server_url in kgoose_auth_storage_lookup_urls(base_url, service_path) { + if let Some(credential) = + stored_session_credential_header_value(profile, &server_url, bl_home.clone())? + { + return Ok(Some(credential)); + } + } + + Ok(None) +} + +pub fn kgoose_auth_storage_lookup_urls(base_url: &str, service_path: &str) -> Vec { + let trimmed = base_url.trim_end_matches('/'); + let mut urls = vec![trimmed.to_string()]; + let service_url = kgoose_service_url(trimmed, service_path); + if service_url != trimmed { + urls.push(service_url); + } + urls +} + +fn supported_storage_values() -> &'static str { + if cfg!(debug_assertions) { + "keyring, memory, file, or file:" + } else { + "keyring, file, or file:" + } +} + +fn file_storage_from_env(bl_home: &std::path::Path) -> Result> { + let path = std::env::var_os(BL_AUTH_STORAGE_FILE_ENV_VAR) + .map(PathBuf::from) + .unwrap_or_else(|| bl_home.join("auth-sessions.json")); + Ok(Box::new(FileSessionCredentialStorage::new(path))) +} + +#[cfg(any(debug_assertions, test))] +#[derive(Debug, Default)] +pub struct InMemorySessionCredentialStorage { + entries: Mutex>, +} + +#[cfg(any(debug_assertions, test))] +impl SessionCredentialStorage for InMemorySessionCredentialStorage { + fn kind(&self) -> &'static str { + "memory" + } + + fn get(&self, key: &SessionStorageKey) -> Result> { + Ok(self + .entries + .lock() + .expect("session storage mutex poisoned") + .get(&key.hashed_id()) + .cloned()) + } + + fn set(&self, key: &SessionStorageKey, credential: &StoredSessionCredential) -> Result<()> { + self.entries + .lock() + .expect("session storage mutex poisoned") + .insert(key.hashed_id(), credential.clone()); + Ok(()) + } + + fn delete(&self, key: &SessionStorageKey) -> Result { + Ok(self + .entries + .lock() + .expect("session storage mutex poisoned") + .remove(&key.hashed_id()) + .is_some()) + } +} + +#[derive(Debug)] +pub struct FileSessionCredentialStorage { + path: PathBuf, +} + +impl FileSessionCredentialStorage { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + fn read_entries(&self) -> Result> { + if !self.path.exists() { + return Ok(BTreeMap::new()); + } + let bytes = + fs::read(&self.path).with_context(|| format!("read {}", self.path.display()))?; + serde_json::from_slice(&bytes).with_context(|| format!("parse {}", self.path.display())) + } + + fn write_entries(&self, entries: &BTreeMap) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let json = serde_json::to_vec_pretty(entries).context("serialize auth session storage")?; + fs::write(&self.path, json).with_context(|| format!("write {}", self.path.display()))?; + restrict_permissions(&self.path) + } + + fn legacy_purpose_tokens_path(&self) -> PathBuf { + let mut path = self.path.as_os_str().to_os_string(); + path.push(".purpose-tokens"); + PathBuf::from(path) + } +} + +impl SessionCredentialStorage for FileSessionCredentialStorage { + fn kind(&self) -> &'static str { + "file" + } + + fn get(&self, key: &SessionStorageKey) -> Result> { + Ok(self.read_entries()?.get(&key.hashed_id()).cloned()) + } + + fn set(&self, key: &SessionStorageKey, credential: &StoredSessionCredential) -> Result<()> { + let mut entries = self.read_entries()?; + entries.insert(key.hashed_id(), credential.clone()); + self.write_entries(&entries) + } + + fn delete(&self, key: &SessionStorageKey) -> Result { + let mut entries = self.read_entries()?; + let removed = entries.remove(&key.hashed_id()).is_some(); + if removed { + self.write_entries(&entries)?; + } + Ok(removed) + } + + fn delete_legacy_purpose_token_cache(&self, _key: &SessionStorageKey) -> Result { + let path = self.legacy_purpose_tokens_path(); + if !path.exists() { + return Ok(false); + } + // Purpose-token storage has no remaining readers or writers. Remove + // the obsolete file as a whole instead of rewriting secrets in place. + fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; + Ok(true) + } +} + +#[derive(Debug)] +struct KeyringSessionCredentialStorage; + +impl SessionCredentialStorage for KeyringSessionCredentialStorage { + fn kind(&self) -> &'static str { + "keyring" + } + + fn get(&self, key: &SessionStorageKey) -> Result> { + keyring_get(key) + } + + fn set(&self, key: &SessionStorageKey, credential: &StoredSessionCredential) -> Result<()> { + keyring_set(key, credential) + } + + fn delete(&self, key: &SessionStorageKey) -> Result { + keyring_delete(key) + } + + fn delete_legacy_purpose_token_cache(&self, key: &SessionStorageKey) -> Result { + keyring_delete_legacy_compose_token(key) + } +} + +#[cfg(target_os = "macos")] +fn keyring_get(key: &SessionStorageKey) -> Result> { + use crate::keychain; + + let value = keychain::get_generic_password_unscoped(KEYRING_SERVICE, &key.account()) + .context("read BuilderLab auth session from keyring")?; + + match value { + Some(value) => { + let value = + String::from_utf8(value).context("BuilderLab auth keyring entry was not UTF-8")?; + Ok(Some(parse_stored_session(&value)?)) + } + None => Ok(None), + } +} + +#[cfg(target_os = "macos")] +fn keyring_set(key: &SessionStorageKey, credential: &StoredSessionCredential) -> Result<()> { + use crate::keychain; + + let value = serde_json::to_string(credential).context("serialize auth session")?; + keychain::set_generic_password_unscoped(KEYRING_SERVICE, &key.account(), value.as_bytes()) + .context("write BuilderLab auth session to keyring") +} + +#[cfg(target_os = "macos")] +fn keyring_delete(key: &SessionStorageKey) -> Result { + use crate::keychain; + + keychain::delete_generic_password_unscoped(KEYRING_SERVICE, &key.account()) + .context("delete BuilderLab auth session from keyring") +} + +#[cfg(target_os = "macos")] +fn keyring_delete_legacy_compose_token(key: &SessionStorageKey) -> Result { + use crate::keychain; + + keychain::delete_generic_password_unscoped( + LEGACY_PURPOSE_TOKEN_KEYRING_SERVICE, + &legacy_compose_token_account(key), + ) + .context("delete legacy BuilderLab Compose token from keyring") +} + +#[cfg(not(target_os = "macos"))] +fn keyring_get(_key: &SessionStorageKey) -> Result> { + unsupported_keyring_storage() +} + +#[cfg(not(target_os = "macos"))] +fn keyring_set(_key: &SessionStorageKey, _credential: &StoredSessionCredential) -> Result<()> { + unsupported_keyring_storage() +} + +#[cfg(not(target_os = "macos"))] +fn keyring_delete(_key: &SessionStorageKey) -> Result { + unsupported_keyring_storage() +} + +#[cfg(not(target_os = "macos"))] +fn keyring_delete_legacy_compose_token(_key: &SessionStorageKey) -> Result { + unsupported_keyring_storage() +} + +#[cfg(not(target_os = "macos"))] +fn unsupported_keyring_storage() -> Result { + anyhow::bail!( + "OS keyring browser auth storage is currently only implemented on macOS; set {BL_AUTH_STORAGE_ENV_VAR}=file for local testing" + ) +} + +#[cfg(any(target_os = "macos", test))] +fn parse_stored_session(value: &str) -> Result { + match serde_json::from_str::(value) { + Ok(stored) => Ok(stored), + Err(_) => Ok(StoredSessionCredential { + session_credential: value.to_string(), + expires_at: None, + }), + } +} + +fn restrict_permissions(path: &PathBuf) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = fs::Permissions::from_mode(0o600); + fs::set_permissions(path, permissions) + .with_context(|| format!("chmod 600 {}", path.display()))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_storage_scopes_credentials_by_profile_and_server() { + let directory = std::env::temp_dir().join(format!( + "bl-auth-storage-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let storage = FileSessionCredentialStorage::new(directory.join("sessions.json")); + let local = SessionStorageKey { + profile: "default".to_string(), + server_url: "http://localhost:5173/cash-app/goose".to_string(), + }; + let staging = SessionStorageKey { + profile: "default".to_string(), + server_url: "https://kgoose.stage.sqprod.co/cash-app/goose".to_string(), + }; + let credential = StoredSessionCredential { + session_credential: "local-session".to_string(), + expires_at: Some("2026-06-15T00:00:00Z".to_string()), + }; + + storage.set(&local, &credential).expect("store credential"); + + assert_eq!( + storage + .get(&local) + .expect("read local credential") + .expect("local credential") + .session_credential, + "local-session" + ); + assert!(storage + .get(&staging) + .expect("read staging credential") + .is_none()); + + let _ = fs::remove_dir_all(directory); + } + + #[test] + fn file_storage_delete_removes_only_matching_session() { + let directory = std::env::temp_dir().join(format!( + "bl-auth-storage-delete-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let storage = FileSessionCredentialStorage::new(directory.join("sessions.json")); + let local = SessionStorageKey { + profile: "default".to_string(), + server_url: "http://localhost:5173/cash-app/goose".to_string(), + }; + let staging = SessionStorageKey { + profile: "default".to_string(), + server_url: "https://kgoose.stage.sqprod.co/cash-app/goose".to_string(), + }; + let credential = StoredSessionCredential { + session_credential: "session".to_string(), + expires_at: None, + }; + storage.set(&local, &credential).expect("store local"); + storage.set(&staging, &credential).expect("store staging"); + + assert!(storage.delete(&local).expect("delete local")); + assert!(!storage.delete(&local).expect("delete local again")); + assert!(storage.get(&local).expect("read local").is_none()); + assert!(storage.get(&staging).expect("read staging").is_some()); + + let _ = fs::remove_dir_all(directory); + } + + #[test] + fn file_storage_deletes_the_obsolete_legacy_purpose_token_cache() { + let directory = std::env::temp_dir().join(format!( + "bl-auth-storage-legacy-compose-delete-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&directory).expect("create test directory"); + let storage = FileSessionCredentialStorage::new(directory.join("sessions.json")); + let local = SessionStorageKey::new("default", "http://localhost:5173"); + let path = storage.legacy_purpose_tokens_path(); + fs::write(&path, b"legacy purpose-token contents").expect("write legacy tokens"); + + assert!(storage + .delete_legacy_purpose_token_cache(&local) + .expect("delete legacy purpose-token cache")); + assert!(!storage + .delete_legacy_purpose_token_cache(&local) + .expect("delete legacy purpose-token cache again")); + assert!(!path.exists()); + + let _ = fs::remove_dir_all(directory); + } + + #[test] + fn parse_stored_session_accepts_legacy_raw_credential() { + let stored = parse_stored_session("raw-session").expect("parse raw credential"); + + assert_eq!(stored.session_credential, "raw-session"); + assert_eq!(stored.expires_at, None); + } + + #[test] + fn kgoose_auth_storage_lookup_urls_includes_legacy_service_url() { + assert_eq!( + kgoose_auth_storage_lookup_urls("https://test.blockstaging.build", "/cash-app/goose"), + vec![ + "https://test.blockstaging.build".to_string(), + "https://test.blockstaging.build/cash-app/goose".to_string(), + ] + ); + assert_eq!( + kgoose_auth_storage_lookup_urls( + "https://test.blockstaging.build/cash-app/goose", + "cash-app/goose" + ), + vec!["https://test.blockstaging.build/cash-app/goose".to_string()] + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_keyring_item_shape_uses_legacy_service_and_account() { + let key = + SessionStorageKey::new("default", "https://kgoose.stage.sqprod.co/cash-app/goose/"); + + assert_eq!(KEYRING_SERVICE, "com.squareup.builderlab.cli-auth"); + assert_eq!( + key.account(), + "default@https://kgoose.stage.sqprod.co/cash-app/goose" + ); + assert_eq!( + LEGACY_PURPOSE_TOKEN_KEYRING_SERVICE, + "com.squareup.builderlab.cli-auth-purpose-token" + ); + assert_eq!( + legacy_compose_token_account(&key), + "compose@default@https://kgoose.stage.sqprod.co/cash-app/goose" + ); + } +} diff --git a/crates/builderlab-auth/src/config.rs b/crates/builderlab-auth/src/config.rs new file mode 100644 index 0000000..fa6b1ba --- /dev/null +++ b/crates/builderlab-auth/src/config.rs @@ -0,0 +1,208 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::preferences::BuilderLabPreferences; + +pub const KGOOSE_SERVICE_PATH_ENV_VAR: &str = "KGOOSE_SERVICE_PATH"; +pub const DEFAULT_KGOOSE_SERVICE_PATH: &str = "/cash-app/goose"; +/// Public BuilderLab BFF prefix for BuilderLab-routed hosts. +/// +/// The BFF rewrites this to KGoose's internal `/cash-app/goose` path. +/// Direct KGoose and local hosts use `DEFAULT_KGOOSE_SERVICE_PATH`. +pub const DEFAULT_BUILDERLAB_SERVICE_PATH: &str = "/api/goose"; +pub const KGOOSE_SERVICE_PATH: &str = DEFAULT_KGOOSE_SERVICE_PATH; +pub const BL_HOME_ENV_VAR: &str = "BL_HOME"; +pub const BL_SKILLS_PROFILE_ENV_VAR: &str = "BL_SKILLS_PROFILE"; +pub const DEFAULT_PROFILE_NAME: &str = "default"; +pub const PREFERENCES_FILE_NAME: &str = "config.yaml"; + +pub fn default_bl_home() -> PathBuf { + env::var("HOME") + .map(|home| PathBuf::from(home).join(".bl")) + .unwrap_or_else(|_| PathBuf::from(".bl")) +} + +pub fn default_preferences_path(bl_home: &Path) -> PathBuf { + bl_home.join(PREFERENCES_FILE_NAME) +} + +pub fn read_preferences_file(path: &Path) -> Result { + if !path.exists() { + return Ok(BuilderLabPreferences::default()); + } + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + serde_yaml::from_slice(&bytes).with_context(|| format!("parse {}", path.display())) +} + +pub fn write_preferences_file(path: &Path, preferences: &BuilderLabPreferences) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let yaml = serde_yaml::to_string(preferences).context("serialize skills preferences")?; + fs::write(path, yaml).with_context(|| format!("write {}", path.display())) +} + +pub fn read_optional_env(name: &str) -> Result> { + match env::var(name) { + Ok(value) => Ok(Some(value)), + Err(env::VarError::NotPresent) => Ok(None), + Err(err) => anyhow::bail!("failed to read {name}: {err}"), + } +} + +pub fn normalize_kgoose_service_path(value: &str) -> Result { + canonical_kgoose_service_path(value) + .ok_or_else(|| anyhow::anyhow!("{KGOOSE_SERVICE_PATH_ENV_VAR} must not be empty")) +} + +fn kgoose_host(base_url: &str) -> &str { + base_url + .trim() + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or_default() + .split(':') + .next() + .unwrap_or_default() +} + +pub fn is_loopback_kgoose_base_url(base_url: &str) -> bool { + matches!(kgoose_host(base_url), "localhost" | "127.0.0.1") +} + +pub fn default_kgoose_service_path(local_dev: bool, base_url: &str) -> &'static str { + let host = kgoose_host(base_url); + let direct_host = matches!( + host, + "kgoose.sqprod.co" + | "kgoose.stage.sqprod.co" + | "kgoose.cashappservices.com" + | "kgoose.cashappservicesstaging.com" + ); + if local_dev || direct_host { + DEFAULT_KGOOSE_SERVICE_PATH + } else { + DEFAULT_BUILDERLAB_SERVICE_PATH + } +} + +pub fn normalize_kgoose_base_url(value: &str) -> String { + normalize_kgoose_base_url_with_service_path(value, DEFAULT_KGOOSE_SERVICE_PATH) +} + +pub fn normalize_kgoose_base_url_with_service_path(value: &str, service_path: &str) -> String { + let trimmed = value.trim().trim_end_matches('/'); + let service_path = canonical_kgoose_service_path(service_path); + let base_url = [DEFAULT_KGOOSE_SERVICE_PATH, DEFAULT_BUILDERLAB_SERVICE_PATH] + .into_iter() + .chain(service_path.as_deref()) + .find_map(|suffix| trimmed.strip_suffix(suffix)) + .unwrap_or(trimmed); + base_url.trim_end_matches('/').to_string() +} + +pub fn kgoose_service_url(base_url: &str, service_path: &str) -> String { + let service_path = canonical_kgoose_service_path(service_path).unwrap_or_default(); + format!( + "{}{}", + normalize_kgoose_base_url_with_service_path(base_url, &service_path), + service_path + ) +} + +fn canonical_kgoose_service_path(value: &str) -> Option { + let path = value.trim().trim_matches('/'); + if path.is_empty() { + None + } else { + Some(format!("/{path}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_service_path_matches_the_endpoint_type() { + for base_url in ["https://kgoose.sqprod.co", "https://kgoose.stage.sqprod.co"] { + assert_eq!( + default_kgoose_service_path(false, base_url), + DEFAULT_KGOOSE_SERVICE_PATH + ); + } + assert_eq!( + default_kgoose_service_path(false, "https://test.blockstaging.build"), + DEFAULT_BUILDERLAB_SERVICE_PATH + ); + assert_eq!( + default_kgoose_service_path(false, "http://127.0.0.1:5173"), + DEFAULT_BUILDERLAB_SERVICE_PATH + ); + assert_eq!( + default_kgoose_service_path(true, "http://127.0.0.1:5173"), + DEFAULT_KGOOSE_SERVICE_PATH + ); + } + + #[test] + fn normalize_kgoose_base_url_strips_service_path() { + assert_eq!( + normalize_kgoose_base_url(" https://test.blockstaging.build/cash-app/goose/ "), + "https://test.blockstaging.build" + ); + } + + #[test] + fn kgoose_service_url_appends_service_path() { + assert_eq!( + kgoose_service_url( + "https://test.blockstaging.build/cash-app/goose", + DEFAULT_KGOOSE_SERVICE_PATH + ), + "https://test.blockstaging.build/cash-app/goose" + ); + } + + #[test] + fn kgoose_service_url_normalizes_bare_service_path() { + assert_eq!( + kgoose_service_url("https://test.blockstaging.build", "cash-app/goose"), + "https://test.blockstaging.build/cash-app/goose" + ); + } + + #[test] + fn normalize_kgoose_base_url_strips_custom_service_path() { + assert_eq!( + normalize_kgoose_base_url_with_service_path( + " https://test.blockstaging.build/cash-app/goose-square/ ", + "/cash-app/goose-square" + ), + "https://test.blockstaging.build" + ); + } + + #[test] + fn normalize_kgoose_base_url_strips_known_previous_service_paths() { + assert_eq!( + normalize_kgoose_base_url_with_service_path( + "https://test.blockstaging.build/cash-app/goose", + DEFAULT_BUILDERLAB_SERVICE_PATH + ), + "https://test.blockstaging.build" + ); + assert_eq!( + normalize_kgoose_base_url_with_service_path( + "https://kgoose.sqprod.co/api/goose", + DEFAULT_KGOOSE_SERVICE_PATH + ), + "https://kgoose.sqprod.co" + ); + } +} diff --git a/crates/builderlab-auth/src/keychain.rs b/crates/builderlab-auth/src/keychain.rs new file mode 100644 index 0000000..1b84440 --- /dev/null +++ b/crates/builderlab-auth/src/keychain.rs @@ -0,0 +1,152 @@ +//! macOS Keychain helpers using the modern SecItem* APIs. + +use anyhow::{anyhow, Result}; +use core_foundation::base::{CFType, TCFType}; +use core_foundation::boolean::CFBoolean; +use core_foundation::data::CFData; +use core_foundation::dictionary::CFMutableDictionary; +use core_foundation::number::CFNumber; +use core_foundation::string::CFString; +use security_framework_sys::item::{ + kSecAttrAccount, kSecAttrService, kSecClass, kSecClassGenericPassword, kSecMatchLimit, + kSecReturnData, kSecValueData, +}; +use security_framework_sys::keychain_item::{ + SecItemAdd, SecItemCopyMatching, SecItemDelete, SecItemUpdate, +}; +use std::ptr; + +const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; + +/// Get a generic password from the default keychain scope. +pub fn get_generic_password_unscoped(service: &str, account: &str) -> Result>> { + let mut query = build_query(service, account); + query.set( + unsafe { CFString::wrap_under_get_rule(kSecReturnData) }, + CFBoolean::true_value().as_CFType(), + ); + query.set( + unsafe { CFString::wrap_under_get_rule(kSecMatchLimit) }, + CFNumber::from(1).as_CFType(), + ); + + let mut result: core_foundation::base::CFTypeRef = ptr::null(); + let status = unsafe { SecItemCopyMatching(query.as_concrete_TypeRef(), &mut result) }; + + if status == ERR_SEC_ITEM_NOT_FOUND { + return Ok(None); + } + if status != 0 { + return Err(anyhow!( + "read keychain item (service={service}, account={account}): OSStatus {status}" + )); + } + + let data = unsafe { CFData::wrap_under_create_rule(result as *const _) }; + Ok(Some(data.bytes().to_vec())) +} + +/// Set (add or update) a generic password in the default keychain scope. +pub fn set_generic_password_unscoped(service: &str, account: &str, value: &[u8]) -> Result<()> { + let value_data = CFData::from_buffer(value); + + // Try to update first + let query = build_query(service, account); + let mut update_attrs = CFMutableDictionary::new(); + update_attrs.set( + unsafe { CFString::wrap_under_get_rule(kSecValueData) }, + value_data.as_CFType(), + ); + + let status = unsafe { + SecItemUpdate( + query.as_concrete_TypeRef(), + update_attrs.as_concrete_TypeRef(), + ) + }; + + if status == ERR_SEC_ITEM_NOT_FOUND { + // Item doesn't exist, add it + let mut add_attrs = build_query(service, account); + add_attrs.set( + unsafe { CFString::wrap_under_get_rule(kSecValueData) }, + value_data.as_CFType(), + ); + + let add_status = unsafe { SecItemAdd(add_attrs.as_concrete_TypeRef(), ptr::null_mut()) }; + if add_status != 0 { + return Err(anyhow!( + "add keychain item (service={service}, account={account}): OSStatus {add_status}" + )); + } + return Ok(()); + } + + if status != 0 { + return Err(anyhow!( + "update keychain item (service={service}, account={account}): OSStatus {status}" + )); + } + + Ok(()) +} + +/// Delete a generic password from the default keychain scope. +/// Returns `true` if an item was deleted, `false` if it was not found. +pub fn delete_generic_password_unscoped(service: &str, account: &str) -> Result { + let query = build_query(service, account); + let status = unsafe { SecItemDelete(query.as_concrete_TypeRef()) }; + + if status == ERR_SEC_ITEM_NOT_FOUND { + return Ok(false); + } + if status != 0 { + return Err(anyhow!( + "delete keychain item (service={service}, account={account}): OSStatus {status}" + )); + } + Ok(true) +} + +fn build_query(service: &str, account: &str) -> CFMutableDictionary { + let mut dict = CFMutableDictionary::new(); + dict.set( + unsafe { CFString::wrap_under_get_rule(kSecClass) }, + unsafe { CFType::wrap_under_get_rule(kSecClassGenericPassword as *const _) }, + ); + dict.set( + unsafe { CFString::wrap_under_get_rule(kSecAttrService) }, + CFString::new(service).as_CFType(), + ); + dict.set( + unsafe { CFString::wrap_under_get_rule(kSecAttrAccount) }, + CFString::new(account).as_CFType(), + ); + dict +} + +#[cfg(test)] +mod tests { + use super::*; + use core_foundation::base::TCFType; + use security_framework_sys::item::kSecAttrAccessGroup; + + #[test] + fn build_query_does_not_scope_to_access_group() { + let query = build_query("com.squareup.builderlab.cli-auth", "default@example"); + + assert_eq!(query.len(), 3); + assert!( + query.contains_key(unsafe { CFString::wrap_under_get_rule(kSecClass).as_CFTypeRef() }) + ); + assert!(query.contains_key(unsafe { + CFString::wrap_under_get_rule(kSecAttrService).as_CFTypeRef() + })); + assert!(query.contains_key(unsafe { + CFString::wrap_under_get_rule(kSecAttrAccount).as_CFTypeRef() + })); + assert!(!query.contains_key(unsafe { + CFString::wrap_under_get_rule(kSecAttrAccessGroup).as_CFTypeRef() + })); + } +} diff --git a/crates/builderlab-auth/src/lib.rs b/crates/builderlab-auth/src/lib.rs new file mode 100644 index 0000000..515c80e --- /dev/null +++ b/crates/builderlab-auth/src/lib.rs @@ -0,0 +1,15 @@ +//! Shared BuilderLab auth and org-routing primitives. + +pub mod auth; +#[cfg(feature = "blocking-client")] +pub mod auth_login; +pub mod auth_storage; +pub mod config; +#[cfg(target_os = "macos")] +pub mod keychain; +pub mod org_routing; +pub mod preferences; +#[cfg(feature = "blocking-client")] +pub mod workspace; + +pub use auth::SESSION_CREDENTIAL_HEADER; diff --git a/crates/builderlab-auth/src/org_routing.rs b/crates/builderlab-auth/src/org_routing.rs new file mode 100644 index 0000000..279ecd8 --- /dev/null +++ b/crates/builderlab-auth/src/org_routing.rs @@ -0,0 +1,205 @@ +use anyhow::{Context, Result}; +use url::{Host, Url}; + +use crate::config::normalize_kgoose_base_url_with_service_path; +#[cfg(test)] +use crate::config::DEFAULT_KGOOSE_SERVICE_PATH; + +const ORG_ROUTED_DOMAIN_SUFFIXES: &[&str] = &[".build", ".xyz"]; + +pub fn normalize_org(value: &str) -> Result { + let org = value.trim().to_ascii_lowercase(); + if org.is_empty() { + anyhow::bail!("org cannot be empty"); + } + let valid_chars = org + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + if !valid_chars || org.starts_with('-') || org.ends_with('-') { + anyhow::bail!( + "org must contain only lowercase ASCII letters, numbers, and hyphens, with no leading or trailing hyphen" + ); + } + Ok(org) +} + +pub fn resolve_org_kgoose_base_url( + base_url: &str, + org: Option<&str>, + local_dev: bool, + service_path: &str, +) -> Result { + let base_url = normalize_kgoose_base_url_with_service_path(base_url, service_path); + if local_dev { + return Ok(base_url); + } + + let Some(org) = org else { + return Ok(base_url); + }; + + let org = normalize_org(org)?; + let absolute = if base_url.contains("://") { + base_url + } else { + format!("https://{base_url}") + }; + let mut url = Url::parse(&absolute).context("kGoose base URL must be absolute")?; + match url.host() { + Some(Host::Domain(host)) if should_route_org_host(host) => { + let routed_host = if host.starts_with(&format!("{org}.")) { + host.to_string() + } else { + format!("{org}.{host}") + }; + url.set_host(Some(&routed_host)) + .map_err(|_| anyhow::anyhow!("failed to route kGoose base URL for org"))?; + } + Some(Host::Domain(_)) | Some(Host::Ipv4(_)) | Some(Host::Ipv6(_)) => {} + None => anyhow::bail!("kGoose base URL must include a host"), + } + Ok(url.as_str().trim_end_matches('/').to_string()) +} + +fn is_loopback_domain(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") +} + +fn should_route_org_host(host: &str) -> bool { + !is_loopback_domain(host) + && ORG_ROUTED_DOMAIN_SUFFIXES + .iter() + .any(|suffix| host.ends_with(suffix)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_org_kgoose_base_url_routes_domain_base() { + let routed = resolve_org_kgoose_base_url( + "https://blockstaging.build", + Some("test"), + false, + DEFAULT_KGOOSE_SERVICE_PATH, + ) + .expect("route URL"); + + assert_eq!(routed, "https://test.blockstaging.build"); + } + + #[test] + fn resolve_org_kgoose_base_url_adds_scheme_when_missing() { + let routed = resolve_org_kgoose_base_url( + "blockstaging.build", + Some("test"), + false, + DEFAULT_KGOOSE_SERVICE_PATH, + ) + .expect("route URL"); + + assert_eq!(routed, "https://test.blockstaging.build"); + } + + #[test] + fn resolve_org_kgoose_base_url_keeps_already_routed_host() { + let routed = resolve_org_kgoose_base_url( + "https://test.blockstaging.build", + Some("test"), + false, + DEFAULT_KGOOSE_SERVICE_PATH, + ) + .expect("route URL"); + + assert_eq!(routed, "https://test.blockstaging.build"); + } + + #[test] + fn resolve_org_kgoose_base_url_keeps_loopback_hosts_unrouted() { + let routed = resolve_org_kgoose_base_url( + "http://127.0.0.1:5173", + Some("test"), + false, + DEFAULT_KGOOSE_SERVICE_PATH, + ) + .expect("route URL"); + + assert_eq!(routed, "http://127.0.0.1:5173"); + } + + #[test] + fn resolve_org_kgoose_base_url_keeps_direct_sqprod_hosts_unrouted() { + let routed = resolve_org_kgoose_base_url( + "https://kgoose.sqprod.co", + Some("test"), + false, + DEFAULT_KGOOSE_SERVICE_PATH, + ) + .expect("resolve URL"); + + assert_eq!(routed, "https://kgoose.sqprod.co"); + } + + #[test] + fn resolve_org_kgoose_base_url_keeps_arbitrary_domains_unrouted() { + let routed = resolve_org_kgoose_base_url( + "https://runtime.example.test/base/", + Some("test"), + false, + DEFAULT_KGOOSE_SERVICE_PATH, + ) + .expect("resolve URL"); + + assert_eq!(routed, "https://runtime.example.test/base"); + } + + #[test] + fn resolve_org_kgoose_base_url_routes_xyz_hosts() { + let routed = resolve_org_kgoose_base_url( + "https://kgoose.example.xyz/base/", + Some("test"), + false, + DEFAULT_KGOOSE_SERVICE_PATH, + ) + .expect("resolve URL"); + + assert_eq!(routed, "https://test.kgoose.example.xyz/base"); + } + + #[test] + fn resolve_org_kgoose_base_url_skips_routing_without_org() { + let routed = resolve_org_kgoose_base_url( + "blockstaging.build", + None, + false, + DEFAULT_KGOOSE_SERVICE_PATH, + ) + .expect("resolve URL"); + + assert_eq!(routed, "blockstaging.build"); + } + + #[test] + fn resolve_org_kgoose_base_url_strips_custom_service_path() { + let routed = resolve_org_kgoose_base_url( + "https://blockstaging.build/cash-app/goose-square/", + Some("test"), + false, + "/cash-app/goose-square", + ) + .expect("route URL"); + + assert_eq!(routed, "https://test.blockstaging.build"); + } + + #[test] + fn normalize_org_cleans_and_validates() { + assert_eq!( + normalize_org(" Test-Org \n").expect("normalize"), + "test-org" + ); + assert!(normalize_org("-bad").is_err()); + assert!(normalize_org("bad_underscore").is_err()); + } +} diff --git a/crates/builderlab-auth/src/preferences.rs b/crates/builderlab-auth/src/preferences.rs new file mode 100644 index 0000000..739096e --- /dev/null +++ b/crates/builderlab-auth/src/preferences.rs @@ -0,0 +1,83 @@ +use serde::{Deserialize, Serialize}; + +/// One `bl config` preference key. [`PREFERENCE_KEYS`] is the single source +/// of truth for the key list: `bl config` builds its help text and +/// unknown-key errors from it. +pub struct PreferenceKeySpec { + pub key: &'static str, + pub help: &'static str, +} + +/// Keep in sync with the fields of [`BuilderLabPreferences`] below. +pub const PREFERENCE_KEYS: &[PreferenceKeySpec] = &[ + PreferenceKeySpec { + key: "org", + help: "Org used for access", + }, + PreferenceKeySpec { + key: "targets", + help: "comma-separated default install targets (default: agents)", + }, + PreferenceKeySpec { + key: "install_strategy", + help: "symlink | copy (default: symlink)", + }, + PreferenceKeySpec { + key: "no_auto_updates", + help: "true | false (default: false)", + }, +]; + +/// User preferences stored in `~/.bl/config.yaml`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BuilderLabPreferences { + #[serde(skip_serializing_if = "Option::is_none")] + pub org: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub targets: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub install_strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub no_auto_updates: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drift guard for PREFERENCE_KEYS: the exhaustive struct literal stops + /// compiling when a BuilderLabPreferences field is added, and the + /// assertions catch a missing or stale key entry. + #[test] + fn preference_keys_match_builderlab_preferences_fields() { + let populated = BuilderLabPreferences { + org: Some("test".to_string()), + targets: vec!["agents".to_string()], + install_strategy: Some("symlink".to_string()), + no_auto_updates: Some(false), + }; + let yaml = serde_yaml::to_value(&populated).expect("serialize preferences"); + let fields = yaml + .as_mapping() + .expect("preferences serialize to a mapping") + .keys() + .map(|key| key.as_str().expect("string key").to_string()) + .collect::>(); + let keys = PREFERENCE_KEYS + .iter() + .map(|spec| spec.key) + .collect::>(); + for field in &fields { + assert!( + keys.contains(&field.as_str()), + "BuilderLabPreferences field `{field}` is missing from PREFERENCE_KEYS" + ); + } + for key in &keys { + assert!( + fields.iter().any(|field| field == key), + "PREFERENCE_KEYS entry `{key}` has no BuilderLabPreferences field" + ); + } + } +} diff --git a/crates/builderlab-auth/src/workspace.rs b/crates/builderlab-auth/src/workspace.rs new file mode 100644 index 0000000..30c7a2b --- /dev/null +++ b/crates/builderlab-auth/src/workspace.rs @@ -0,0 +1,140 @@ +use std::fmt; + +use anyhow::{anyhow, Context, Result}; +use reqwest::blocking::Client; +use reqwest::header::{ACCEPT, USER_AGENT}; +use serde::{Deserialize, Serialize}; + +use crate::auth::SESSION_CREDENTIAL_HEADER; +use crate::auth_login::{auth_url, playpen_baggage, CLI_USER_AGENT}; +use crate::auth_storage::StoredSessionCredential; + +const LIST_WORKSPACES_PATH: &str = "/v1/workspaces/list"; +const SWITCH_WORKSPACE_PATH: &str = "/v1/workspaces/switch"; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Workspace { + pub workspace_identifier: Option, + pub display_name: Option, + #[serde(default)] + pub roles: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ListWorkspacesResponse { + #[serde(default)] + pub workspaces: Vec, + pub active_workspace_identifier: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SwitchWorkspaceResponse { + pub workspace: Option, + pub session_credential: Option, +} + +#[derive(Debug)] +pub struct WorkspaceHttpError { + pub status: u16, + pub path: &'static str, + pub body: String, +} + +impl fmt::Display for WorkspaceHttpError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{} failed with {}: {}", + self.path, self.status, self.body + ) + } +} + +impl std::error::Error for WorkspaceHttpError {} + +#[derive(Debug, Serialize)] +struct ListWorkspacesRequest {} + +#[derive(Debug, Serialize)] +struct SwitchWorkspaceRequest<'a> { + workspace_identifier: &'a str, +} + +pub fn list_workspaces( + client: &Client, + playpen: Option<&str>, + server_url: &str, + credential: &StoredSessionCredential, +) -> Result { + post_authenticated_json( + client, + playpen, + server_url, + LIST_WORKSPACES_PATH, + credential, + &ListWorkspacesRequest {}, + ) +} + +pub fn switch_workspace( + client: &Client, + playpen: Option<&str>, + server_url: &str, + credential: &StoredSessionCredential, + workspace_identifier: &str, +) -> Result { + post_authenticated_json( + client, + playpen, + server_url, + SWITCH_WORKSPACE_PATH, + credential, + &SwitchWorkspaceRequest { + workspace_identifier, + }, + ) +} + +fn post_authenticated_json( + client: &Client, + playpen: Option<&str>, + server_url: &str, + path: &'static str, + credential: &StoredSessionCredential, + body: &B, +) -> Result +where + T: for<'de> Deserialize<'de>, + B: Serialize + ?Sized, +{ + let session_credential = credential + .session_credential_header_value() + .ok_or_else(|| anyhow!("stored BuilderLab CLI auth session is empty"))?; + let url = auth_url(server_url, path)?; + let mut request = client + .post(url) + .header(USER_AGENT, CLI_USER_AGENT) + .header(ACCEPT, "application/json") + .header(SESSION_CREDENTIAL_HEADER, session_credential) + .json(body); + if let Some(baggage) = playpen_baggage(playpen) { + request = request.header("Baggage", baggage); + } + let response = request.send().with_context(|| format!("request {path}"))?; + let status = response.status(); + let body = response + .text() + .with_context(|| format!("read {path} response"))?; + if !status.is_success() { + return Err(anyhow::Error::new(WorkspaceHttpError { + status: status.as_u16(), + path, + body: terminal_safe_text(&body), + })); + } + serde_json::from_str(&body).with_context(|| format!("parse {path} response")) +} + +fn terminal_safe_text(value: &str) -> String { + value.chars().flat_map(char::escape_default).collect() +} diff --git a/docker/acceptance/Dockerfile b/docker/acceptance/Dockerfile new file mode 100644 index 0000000..9e6fbb0 --- /dev/null +++ b/docker/acceptance/Dockerfile @@ -0,0 +1,15 @@ +FROM rust:1.91.1-bookworm AS build + +WORKDIR /src +COPY . . +WORKDIR /src +RUN cargo build --locked --release --bin bl + +FROM python:3.12-slim-bookworm + +RUN groupadd --gid 10001 acceptance && useradd --uid 10001 --gid acceptance --create-home --shell /usr/sbin/nologin acceptance +COPY --from=build /src/target/release/bl /usr/local/bin/bl +COPY --chown=acceptance:acceptance --chmod=755 docker/acceptance /opt/bl-acceptance + +USER acceptance +ENTRYPOINT ["/opt/bl-acceptance/run-acceptance.sh"] diff --git a/docker/acceptance/Dockerfile.dockerignore b/docker/acceptance/Dockerfile.dockerignore new file mode 100644 index 0000000..a82d165 --- /dev/null +++ b/docker/acceptance/Dockerfile.dockerignore @@ -0,0 +1,22 @@ +** +!Cargo.toml +!Cargo.lock +!build.rs +!extensions.yaml +!rust-toolchain.toml +!src/ +!src/** +!protos/ +!protos/** +!crates/ +!crates/builderlab-auth/ +!crates/builderlab-auth/** +!docker/ +!docker/acceptance/ +!docker/acceptance/** + +.hermit/ +bin/ +sqbin/ +target/ +**/node_modules/ diff --git a/docker/acceptance/README.md b/docker/acceptance/README.md new file mode 100644 index 0000000..31d999e --- /dev/null +++ b/docker/acceptance/README.md @@ -0,0 +1,30 @@ +# Docker BL Acceptance Harness + +This harness builds the current `bl` binary in Docker and runs it as an unprivileged user. Every run creates a new container-local `HOME`, `BL_HOME`, skills state directory, agent skills directory, and file-backed auth location. It never mounts a developer home or places a credential in an image layer. + +## Offline mock + +From this repository root: + +```bash +just bl-cli-docker-acceptance +``` + +The default mode starts a deterministic local marketplace fixture inside the container. It installs the skills-only `default` bundle, repeats the install, runs an update, validates BL metadata and bundle provenance, and confirms an unmanaged skill file survives. Agent-bundle installation is intentionally outside this contract. + +## Live KGoose or Playpen + +Pass the URL and credential only when starting the container: + +```bash +docker run --rm \ + -e BL_ACCEPTANCE_MODE=live \ + -e BL_MARKETPLACE_BASE_URL=https://kgoose.stage.sqprod.co \ + -e BL_SESSION_CREDENTIAL="$BL_SESSION_CREDENTIAL" \ + -e KGOOSE_PLAYPEN=my-playpen \ + bl-cli-acceptance +``` + +`KGOOSE_PLAYPEN` is optional. The runner forwards it to `bl`; it does not construct HTTP headers, so Playpen routing continues to use the CLI's authoritative Baggage behavior. The runtime credential is written to a container-local file-backed auth store and removed with the temporary home when the container exits. Do not pass a home-volume mount or put credentials in the Dockerfile. + +`BL_ACCEPTANCE_BUNDLE` is available only for targeted diagnostics against a non-release fixture. Release validation must leave it unset so the harness exercises the canonical `default` bundle. diff --git a/docker/acceptance/mock-marketplace.py b/docker/acceptance/mock-marketplace.py new file mode 100644 index 0000000..2064f29 --- /dev/null +++ b/docker/acceptance/mock-marketplace.py @@ -0,0 +1,121 @@ +import argparse +import hashlib +import io +import json +import zipfile +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def artifact_bytes(): + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as archive: + entry = zipfile.ZipInfo("SKILL.md", (2024, 1, 1, 0, 0, 0)) + archive.writestr(entry, "---\nname: Docker Harness\n---\n# Docker Harness\n") + return stream.getvalue() + + +ARTIFACT = artifact_bytes() +ARTIFACT_SHA = hashlib.sha256(ARTIFACT).hexdigest() +SKILL = { + "slug": "docker-harness", + "version_id": "docker-harness-v1", + "content_sha256": ARTIFACT_SHA, +} + + +def plan(installed): + action = "noop" if installed else "install" + operation = { + "action": action, + "skill": SKILL, + "artifact": None, + "installed_via": "bundle:default", + } + if action == "install": + operation["artifact"] = { + "id": "docker-harness-v1", + "download_url": "/v1/marketplace/artifacts/docker-harness-v1/download", + "sha256": ARTIFACT_SHA, + "size_bytes": len(ARTIFACT), + } + return {"plan_id": f"docker-harness-{action}", "operations": [operation], "warnings": []} + + +class Handler(BaseHTTPRequestHandler): + expected_session_credential = None + expected_playpen = None + expected_service_path = None + expected_bundle = None + saw_expected_bundle = False + + def authenticate(self): + if self.expected_session_credential and self.headers.get("X-Bb-Session-Credential") != self.expected_session_credential: + self.send_error(401) + return False + if self.expected_playpen: + baggage = self.headers.get("Baggage", "") + if f"kgoose-builderlab-playpen={self.expected_playpen}" not in baggage: + self.send_error(400) + return False + if self.expected_service_path and not self.path.startswith(f"{self.expected_service_path}/v1/marketplace/"): + self.send_error(404) + return False + return True + + def do_GET(self): + if not self.authenticate(): + return + if self.path.endswith("/v1/marketplace/capabilities"): + self.respond_json({"target_registry": {"agents": {"enabled": True, "global_paths": ["~/.agents/skills"], "project_paths": ["./.agents/skills"], "link_strategies": ["symlink"]}}}) + elif self.path.endswith("/v1/marketplace/skills/docker-harness"): + self.respond_json({"slug": "docker-harness", "name": "Docker Harness", "description": "Deterministic acceptance fixture.", "status": "stable", "enabled": True, "latest_version_id": SKILL["version_id"], "latest_content_sha256": ARTIFACT_SHA, "source_id": "docker-acceptance", "source_revision": "fixture-v1", "latest_version": None}) + elif self.path.endswith("/v1/marketplace/artifacts/docker-harness-v1/download"): + self.send_response(200) + self.send_header("Content-Type", "application/zip") + self.send_header("Content-Length", str(len(ARTIFACT))) + self.end_headers() + self.wfile.write(ARTIFACT) + else: + self.send_error(404) + + def do_POST(self): + if not self.authenticate(): + return + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length) or b"{}") + if self.path.endswith("/v1/marketplace/install-plan"): + if self.expected_bundle and not self.saw_expected_bundle: + expected_target = {"type": "bundle", "slug": self.expected_bundle, "version_id": None} + if expected_target not in payload.get("targets", []): + self.send_error(400, f"expected bundle target {self.expected_bundle}") + return + Handler.saw_expected_bundle = True + self.respond_json(plan(payload.get("installed", []))) + else: + self.send_error(404) + + def respond_json(self, payload): + encoded = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, format, *args): + return + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--expect-session-credential") + parser.add_argument("--expect-playpen") + parser.add_argument("--expect-service-path") + parser.add_argument("--expect-bundle") + args = parser.parse_args() + Handler.expected_session_credential = args.expect_session_credential + Handler.expected_playpen = args.expect_playpen + Handler.expected_service_path = args.expect_service_path + Handler.expected_bundle = args.expect_bundle + ThreadingHTTPServer(("127.0.0.1", args.port), Handler).serve_forever() diff --git a/docker/acceptance/run-acceptance.sh b/docker/acceptance/run-acceptance.sh new file mode 100755 index 0000000..8d33407 --- /dev/null +++ b/docker/acceptance/run-acceptance.sh @@ -0,0 +1,155 @@ +#!/bin/sh +set -eu + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +assert_isolated_path() { + case "$1" in + "$HOME"|"$HOME"/*) ;; + *) fail "acceptance path escapes isolated HOME: $1" ;; + esac +} + +[ "$(id -u)" -ne 0 ] || fail "acceptance runner must not execute as root" + +RUN_ROOT="$(mktemp -d /tmp/bl-acceptance.XXXXXX)" +trap 'rm -rf "$RUN_ROOT"' EXIT HUP INT TERM +export HOME="$RUN_ROOT/home" +export BL_HOME="$HOME/.bl" +export BL_SKILLS_HOME="$BL_HOME/skills" +export BL_SKILLS_PACKAGES_DIR="$HOME/.agents/skills" +export BL_SKILLS_CONFIG="$BL_HOME/skills.yaml" +export BL_AUTH_STORAGE=file +export BL_AUTH_STORAGE_FILE="$BL_HOME/auth-sessions.json" +PROFILE=docker-acceptance +BL_COMMAND="${BL_ACCEPTANCE_BL_PATH:-bl}" +MOCK_MARKETPLACE="${BL_ACCEPTANCE_MOCK_MARKETPLACE:-/opt/bl-acceptance/mock-marketplace.py}" +MOCK_PORT="${BL_ACCEPTANCE_MOCK_PORT:-18080}" + +for path in "$HOME" "$BL_HOME" "$BL_SKILLS_HOME" "$BL_SKILLS_PACKAGES_DIR" "$BL_SKILLS_CONFIG" "$BL_AUTH_STORAGE_FILE"; do + assert_isolated_path "$path" +done +mkdir -p "$BL_HOME" "$BL_SKILLS_HOME" "$BL_SKILLS_PACKAGES_DIR/unmanaged" +printf '%s\n' 'unmanaged files must survive' > "$BL_SKILLS_PACKAGES_DIR/unmanaged/sentinel.txt" +printf '%s\n' "current_profile: $PROFILE" 'profiles:' " $PROFILE: {}" > "$HOME/bl-local-dev-config.yaml" +cd "$HOME" + +write_runtime_credential() { + PROFILE="$PROFILE" python3 - "$BL_AUTH_STORAGE_FILE" "$1" <<'PY' +import hashlib +import json +import os +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +server_url = sys.argv[2].rstrip("/") +key = hashlib.sha256(os.environ["PROFILE"].encode() + b"\0" + server_url.encode()).hexdigest() +path.write_text(json.dumps({key: {"sessionCredential": os.environ["BL_SESSION_CREDENTIAL"]}})) +PY + unset BL_SESSION_CREDENTIAL +} + +write_report() { + if [ -z "${BL_ACCEPTANCE_REPORT_PATH:-}" ]; then + return 0 + fi + python3 - "$BL_ACCEPTANCE_REPORT_PATH" "$HOME" "$BL_HOME" "$BL_SKILLS_HOME" "$BL_SKILLS_PACKAGES_DIR" "$BL_AUTH_STORAGE_FILE" "${BL_ACCEPTANCE_MODE:-mock}" <<'PY' +import json +import os +import pathlib +import sys + +pathlib.Path(sys.argv[1]).write_text(json.dumps({ + "home": sys.argv[2], + "bl_home": sys.argv[3], + "skills_home": sys.argv[4], + "packages_dir": sys.argv[5], + "auth_storage_file": sys.argv[6], + "mode": sys.argv[7], + "uid": os.getuid(), +})) +PY +} + +assert_mock_result() { + package="$BL_SKILLS_PACKAGES_DIR/docker-harness" + test -f "$package/SKILL.md" || fail "mock install did not create the skills-only package" + test -f "$package/.bl-skills-meta.json" || fail "mock install did not create BL metadata" + grep -q 'bl-skills-install/v1' "$package/.bl-skills-meta.json" || fail "mock metadata has unexpected schema" + grep -q 'bundle:default' "$package/.bl-skills-meta.json" || fail "mock metadata lacks bundle provenance" + test -f "$BL_SKILLS_PACKAGES_DIR/unmanaged/sentinel.txt" || fail "mock install removed unmanaged sentinel" + assert_idempotent_result "$RUN_ROOT/repeat-install.json" "repeat install" + assert_idempotent_result "$RUN_ROOT/update.json" "update" +} + +assert_idempotent_result() { + python3 - "$1" "$2" <<'PY' +import json +import pathlib +import sys + +result = json.loads(pathlib.Path(sys.argv[1]).read_text()) +operation = sys.argv[2] +if result.get("installed") != []: + raise SystemExit(f"{operation} reinstalled marketplace content") +if "docker-harness" not in result.get("up_to_date", []): + raise SystemExit(f"{operation} did not report docker-harness as up to date") +PY +} + +run_mock() { + export KGOOSE_BASE_URL="http://127.0.0.1:$MOCK_PORT" + export KGOOSE_SERVICE_PATH=/api/goose + unset BL_KGOOSE_PLAYPEN KGOOSE_PLAYPEN + python3 "$MOCK_MARKETPLACE" --port "$MOCK_PORT" --expect-bundle default >"$RUN_ROOT/mock.log" 2>&1 & + mock_pid=$! + trap 'kill "$mock_pid" 2>/dev/null || true; rm -rf "$RUN_ROOT"' EXIT HUP INT TERM + startup_attempts="${BL_ACCEPTANCE_MOCK_START_ATTEMPTS:-30}" + attempt=1 + while ! python3 -c "import socket; socket.create_connection(('127.0.0.1', $MOCK_PORT), 1).close()" 2>/dev/null; do + if ! kill -0 "$mock_pid" 2>/dev/null; then + cat "$RUN_ROOT/mock.log" >&2 + fail "mock marketplace exited before becoming ready" + fi + if [ "$attempt" -ge "$startup_attempts" ]; then + cat "$RUN_ROOT/mock.log" >&2 + fail "mock marketplace did not become ready after $startup_attempts attempts" + fi + attempt=$((attempt + 1)) + sleep 1 + done + "$BL_COMMAND" --local-dev skills install --bundle default --yes --json >"$RUN_ROOT/first-install.json" + "$BL_COMMAND" --local-dev skills install --bundle default --yes --json >"$RUN_ROOT/repeat-install.json" + "$BL_COMMAND" --local-dev skills update --yes --json >"$RUN_ROOT/update.json" + assert_mock_result + write_report + printf '%s\n' 'Docker mock acceptance passed.' +} + +run_live() { + [ -n "${BL_MARKETPLACE_BASE_URL:-}" ] || fail 'live mode requires BL_MARKETPLACE_BASE_URL at docker run time' + [ -n "${BL_SESSION_CREDENTIAL:-}" ] || fail 'live mode requires BL_SESSION_CREDENTIAL at docker run time' + export KGOOSE_BASE_URL="$BL_MARKETPLACE_BASE_URL" + credential_service_path="${KGOOSE_SERVICE_PATH:-/cash-app/goose}" + case "$credential_service_path" in + /*) ;; + *) credential_service_path="/$credential_service_path" ;; + esac + if [ -n "${KGOOSE_PLAYPEN:-}" ]; then + export BL_KGOOSE_PLAYPEN="$KGOOSE_PLAYPEN" + fi + write_runtime_credential "${KGOOSE_BASE_URL%/}$credential_service_path" + "$BL_COMMAND" --local-dev skills install --bundle "${BL_ACCEPTANCE_BUNDLE:-default}" --yes --json + "$BL_COMMAND" --local-dev skills update --yes --json + write_report +} + +case "${BL_ACCEPTANCE_MODE:-mock}" in + mock) run_mock ;; + live) run_live ;; + *) fail 'BL_ACCEPTANCE_MODE must be mock or live' ;; +esac diff --git a/docs/RELEASING-bl.md b/docs/RELEASING-bl.md new file mode 100644 index 0000000..99f8520 --- /dev/null +++ b/docs/RELEASING-bl.md @@ -0,0 +1,62 @@ +# Building `bl` + +`bl-cli` owns the Rust `bl` binary. It no longer owns standalone app, +installer, archive, DMG, MSI, package, or platform-specific distribution. +Berd.app bundles the `bl` binary and manages the `/usr/local/bin/bl` command +link from the app. + +The Homebrew-backed `sq` command-pack release path is separate and documented in +`docs/RELEASING-sq.md`. + +## Local Build + +Build the release binary: + +```bash +source ./bin/activate-hermit +cargo build --locked --release --bin bl +``` + +Or use the Justfile alias: + +```bash +source ./bin/activate-hermit +just build-bl-release +``` + +Smoke-check the built CLI: + +```bash +target/release/bl --version +target/release/bl --help +``` + +## Berd Integration + +Berd packages `bl` by copying a built binary into the app resources during the +Berd bundle flow. In the parent app repo, this is handled by: + +```text +scripts/prepare-bl-cli-resource.sh +``` + +The packaged app exposes the command from: + +```text +Berd.app/Contents/Resources/bl +``` + +Berd.app owns installing or repairing: + +```text +/usr/local/bin/bl -> /Applications/Berd.app/Contents/Resources/bl +``` + +## Ownership + +Do not add standalone `bl` app, installer, platform archive, DMG, MSI, package, +or Homebrew distribution back to this package. Distribution flows through +Berd.app. + +When changing `bl`, update and merge the version in `Cargo.toml`, run the normal +`bl-cli` checks, and validate the Berd bundle path that consumes the binary. diff --git a/docs/RELEASING-sq.md b/docs/RELEASING-sq.md new file mode 100644 index 0000000..49e2dd5 --- /dev/null +++ b/docs/RELEASING-sq.md @@ -0,0 +1,118 @@ +# Releasing the `sq` command pack + +This repository publishes an `sq` command pack as a single executable module: + +```text +sqbin/agent-tools.exoskeleton +``` + +The publishable contract is: + +1. the executable lives under `sqbin/` +2. the executable uses the `.exoskeleton` suffix +3. the Homebrew formula installs `sqbin` into `prefix/"etc"` +4. the executable responds to `--describe-commands` with JSON that describes the command tree + +This release path is specific to `sq` discovery through Homebrew. It is separate +from the `bl` CLI build path described in `docs/RELEASING-bl.md`. + +## Tag Format + +Use bare semver tags such as `0.1.0`. + +Do not prefix releases with `v`. `../homebrew-formulas/sq-kgoose.rb` currently +uses `tag: version.to_s`, so `v0.1.0` would not match the formula's source tag. + +## Local Verification + +Before cutting a release: + +```bash +source ./bin/activate-hermit +just check +just update-extensions-catalog +just build-sq +./sqbin/agent-tools.exoskeleton --describe-commands +./sqbin/agent-tools.exoskeleton --playpen baxen --help +./sqbin/agent-tools.exoskeleton --playpen baxen utils calculate --help +``` + +`just update-extensions-catalog` refreshes the checked-in root extension list +used by `sq`'s cached `--describe-commands` discovery path. Review and clean up +`extensions.yaml` before publishing. + +## Automated Workflows + +Run the CLI checks before release: + +```bash +source ./bin/activate-hermit +just ci-lint +just ci-test +``` + +GitHub Actions `Bump Formula` runs on bare semver tags and `workflow_dispatch`. +It validates that the tag still matches +`Cargo.toml`, then dispatches `bump_formula` to +`squareup/homebrew-formulas`. + +## One-Time GitHub Setup + +For formula bumping to work, `berd` needs access to the Homebrew update +secrets: + +1. `HOMEBREW_FORMULAS_APP_ID` +2. `HOMEBREW_FORMULAS_PRIVATE_KEY` + +Per `homebrew-formulas`' maintainer docs, the repo also needs to be allowlisted +for `HOMEBREW_FORMULAS_PRIVATE_KEY`. Until those secrets are configured, the +`Bump Formula` workflow will fail fast with an explicit setup error. + +## Release Flow + +Once the version in `Cargo.toml` has been updated and merged: + +```bash +git tag 0.1.0 +git push origin 0.1.0 +``` + +That tag push should: + +1. make the source tag available to the Homebrew formula +2. trigger the formula bump automation in `squareup/homebrew-formulas` + +If the formula update needs to be retried, rerun `Bump Formula` from the Actions +tab with the same semver tag. The workflow validates that the tag still matches +`Cargo.toml` before dispatching the bump. + +## Homebrew Formula + +The formula that publishes this command pack must keep installing `sqbin` into +`prefix/"etc"` so `sq` can discover it from the Homebrew keg. + +The current shape in `../homebrew-formulas/sq-kgoose.rb` is: + +```ruby +class SqKgoose < Formula + version "0.1.0" + stable do + url "https://github.com/block/berd.git", tag: version.to_s + end + + @sq_pack = { + name: "agent-tools", + desc: "Discover auth-backed tool extensions exposed through kGoose" + } + + def install + system "cargo", "build", "--release", "--locked" + system "mkdir", "-p", "sqbin" + system "cp", "target/release/agent-tools", "sqbin/agent-tools.exoskeleton" + (prefix/"etc").install "sqbin" + end +end +``` + +If the formula changes later, preserve the final installed path under +`etc/sqbin`. diff --git a/docs/bl-auth-flow.md b/docs/bl-auth-flow.md new file mode 100644 index 0000000..2f44ec2 --- /dev/null +++ b/docs/bl-auth-flow.md @@ -0,0 +1,39 @@ +# BuilderLab Auth Flow + +This is the intended browser-mediated CLI login flow. Web and CLI can use the same backend Auth0 login path, but the resulting credentials are typed and presented differently. + +```mermaid +sequenceDiagram + participant CLI + participant Browser as User Browser + participant Backend + participant Auth0 + participant Store as Backend Data Store + + CLI->>CLI: Start localhost callback server + CLI->>Browser: Open /v1/auth/login?type=cli&returnTo=http://127.0.0.1:/callback + Browser->>Backend: Begin CLI login + Backend->>Store: Store AuthTransaction with type=cli and loopback returnTo + Backend-->>Browser: Set oauth state cookie, 302 to Auth0 authorize + Browser->>Auth0: Complete Auth0 login + Auth0-->>Browser: 302 to configured /v1/auth/callback with code and state + Browser->>Backend: GET /v1/auth/callback with oauth state cookie + Backend->>Auth0: Exchange code using stored PKCE verifier + Auth0-->>Backend: ID/access token response + Backend->>Store: Create short-lived one-time code for CLI login + Backend-->>Browser: 302 to localhost callback with code + Browser->>CLI: GET /callback?code=... + CLI->>Backend: POST /v1/auth/login/exchange { code } + Backend->>Store: Verify code is valid, unused, unexpired, and type=cli + Backend->>Store: Create cli session credential + Backend-->>CLI: Return cli session credential + CLI->>Backend: API request with X-BB-Session-Credential: +``` + +Security constraints: + +- Web sessions are returned as secure cookies and accepted only as cookies. +- CLI sessions are returned by the exchange endpoint and accepted only via the `X-BB-Session-Credential` header. This is a backend wire-protocol name and is not changed by the BuilderLab product rename. +- Durable session credentials are never placed in redirect URLs. +- The localhost redirect carries only a short-lived, single-use exchange code. +- CLI `returnTo` targets must be loopback URLs. diff --git a/docs/bl-auth-local-testing.md b/docs/bl-auth-local-testing.md new file mode 100644 index 0000000..01b0bd0 --- /dev/null +++ b/docs/bl-auth-local-testing.md @@ -0,0 +1,103 @@ +# BuilderLab Local Auth Testing + +This covers local CLI auth testing for BuilderLab identity. + +The important invariant is that the browser must hit `/v1/auth/login` and `/v1/auth/callback` +on the same host that Auth0 redirects to. kgoose uses an HttpOnly state cookie between +those two requests before it redirects back to the CLI with a short-lived exchange code. + +While BuilderLab staging is only reachable from a laptop through a Kubernetes +port-forward, keep the backend Auth0 redirect URI as: + +```text +http://localhost:5173/cash-app/goose/v1/auth/callback +``` + +Then point the CLI at the same local host. + +For the target login sequence, see [BuilderLab Auth Flow](bl-auth-flow.md). + +## Build + +From this repository root: + +```bash +source ./bin/activate-hermit +cargo build --bin bl +``` + +## Test Against A Port-Forward + +Find the running pod and dynamic Java app port: + +```bash +kubectl -n kgoose-builderlab get pods -o wide + +kubectl -n kgoose-builderlab exec -c kgoose-builderlab -- \ + sh -c "ss -ltnp | awk '/java/ {print \$4}' | tr '\n' ' '" +``` + +Forward local `5173` to the dynamic app port, not the declared `8080` health/admin port: + +```bash +kubectl -n kgoose-builderlab port-forward pod/ 5173: +``` + +In another terminal, run the CLI login command through the port-forward: + +```bash +./target/debug/bl config set org test + +BL_AUTH_STORAGE=file \ +BL_AUTH_STORAGE_FILE="$(pwd)/target/bl-auth-sessions.json" \ +KGOOSE_BASE_URL="http://localhost:5173" \ +KGOOSE_SERVICE_PATH="/cash-app/goose" \ + ./target/debug/bl auth login +``` + +Expected result: + +- the browser opens `http://localhost:5173/cash-app/goose/v1/auth/login` +- kgoose redirects to Auth0 with `redirect_uri=http://localhost:5173/cash-app/goose/v1/auth/callback` +- Auth0 redirects the browser back through the same port-forward +- kgoose validates the state cookie, exchanges the Auth0 code server-side, and redirects to the CLI loopback callback with a one-time exchange code +- the CLI exchanges that code through kgoose and stores the returned session credential + +By default, the CLI stores browser auth sessions in the OS keyring. For local debugging without touching keyring state, use the `BL_AUTH_STORAGE=file` command above. + +## Test In Staging + +Point the CLI at the real staging URL: + +```bash +./target/debug/bl config set org test + +KGOOSE_BASE_URL="https://blockstaging.build" \ + ./target/debug/bl auth login +``` + +The staging command uses BuilderLab's public `/api/goose` BFF prefix by +default. Set `KGOOSE_SERVICE_PATH=/cash-app/goose` only when calling kgoose +directly, such as through the local port-forward above. + +## Test Against A Playpen + +Set `BL_KGOOSE_PLAYPEN` when you need to route backend auth requests to a playpen. Replace `` with your full kgoose playpen route value. +The Chrome extension must be enabled for playpen login so browser requests route through the playpen. + +```bash +BL_AUTH_STORAGE=file \ +BL_AUTH_STORAGE_FILE="$(pwd)/target/bl-auth-sessions.json" \ +BL_KGOOSE_PLAYPEN="jsiblison--cash-usw2" \ +KGOOSE_BASE_URL="https://blockstaging.build" \ + ./target/debug/bl auth login +``` + +## Notes + +- For port-forward testing, use `localhost:5173`, not `127.0.0.1:5173`, so the browser host matches the registered Auth0 callback URL. +- The dynamic Java app port is the port that serves `/cash-app/goose`; `8080` is the health/admin listener. +- Non-local-dev `bl` commands require `org`; set it with `bl config set org ` or let interactive `bl auth login` prompt for it. +- `KGOOSE_BASE_URL` is the pure base URL. For non-local commands, the CLI derives the org-routed host and uses the public `/api/goose` BFF prefix; set `KGOOSE_SERVICE_PATH=/cash-app/goose` when calling kgoose directly. +- `BL_KGOOSE_PLAYPEN` routes bl backend requests with `Baggage: kgoose-builderlab-playpen=`. +- Do not log callback query strings, cookies, or returned session credentials. diff --git a/docs/sq-integration.md b/docs/sq-integration.md new file mode 100644 index 0000000..9686c9a --- /dev/null +++ b/docs/sq-integration.md @@ -0,0 +1,215 @@ +# How to integrate a commandline tool with sq + + +## Why sq? + +`sq`'s [job](https://go/common-toolchain) is to make the long tail of CLIs at Block discoverable and observable without locking CLI authors into a single language, framework, or repo. + +### Hello World + +Paste the following into your terminal to define `sq echo`: + +``` +cd ~mkdir sqbinecho '#!/usr/bin/env sh# SUMMARY: Writes its arguments to stdout# HELP: USAGE# sq echo [string...]## EXAMPLES# sq echo hello worldecho "$@"' > sqbin/echochmod +x sqbin/echo +``` + +Now if you type `sq`, you'll see `echo` in the list of commands with its summary text: + +``` +$ sqUSAGE sq []…COMMANDS IN ~/sqbin echo Writes its arguments to stdout +``` + +If you type `sq help echo`, you'll see its help text: + +``` +$ sqUSAGE sq echo [string...]EXAMPLES sq echo hello world +``` + +And if you type `sq echo hello world`, you'll see "hello world". + +### Summary and Help + +If you write your command in a compiled language (like Go), make sure that it responds to two flags, `--summary` and `--help`, with the appropriate text. [Here's an example](https://github.com/squareup/sq-ssh/blob/d9f9a72ae24e5feb43502d47255be152c4954a2d/main.go#L254-L264) from `sq ssh`. + +If you compose your command as a shell script, you may either respond to `--summary` and `--help` or may include the SUMMARY and HELP magic comments after the shebang (`#!`) like in the [example above](#hello-world). + +Modules (menus of nested commands) need only a SUMMARY; and that's supplied as a magic comment in a file named `.sq-module`. [Here's an example](https://github.com/squareup/sq-sentry/blob/c6c72fe63b82b02ad8689ebd0e6626f9672a9c0c/sqbin/sentry/.sq-module) from `sq sentry`. + +[examples/bash](https://github.com/squareup/sq/tree/9883e5f84b38d037909690dbf00f82469d8e14a6/examples/bash) and [examples/go](https://github.com/squareup/sq/tree/9883e5f84b38d037909690dbf00f82469d8e14a6/examples/go) are sample CLIs implemented this way. + +### Subcommands + +To integrate a command line tool that already supports its own subcommands requires a different approach. + +Your CLI should: + +1. Have the extension `.exoskeleton` +2. Respond to `--describe-commands` by printing JSON to standard output describing the structure of the tool. + +`sq util` is an example. Typing `sq util` will list the standard menu with four subcommands, but all of the subcommands live in a binary named `util.exoskeleton`: + +``` +$ sq which util lint/opt/homebrew/etc/sqbin/util.exoskeleton$ sq which util usage/opt/homebrew/etc/sqbin/util.exoskeleton +``` + +And you can invoke `util.exoskeleton` with `--describe-commands` to see the structure of the `util` CLI: + +``` +$ $(sq which util) --describe-commands{ "name": "util", "summary": "Back-of-House utilities for sq", "commands": [ { "name": "discover", "summary": "Describe the commands that sq would discover in given paths" }, { "name": "kegs", "summary": "List the installed kegs that provide sq commands" }, { "name": "lint", "summary": "Lint commands in a given path" }, { "name": "usage", "summary": "List available commands along with their usage" } ]} +``` + +[examples/go+kong](https://github.com/squareup/sq/tree/9883e5f84b38d037909690dbf00f82469d8e14a6/examples/go%2Bkong) is an example of a Kong CLI that implements this `--describe-commands` flag. + +### Conventions + +#### Conventions for SUMMARY text + +- Keep the SUMMARY text short — under 80 characters +- It should be only a single sentence but **_not_** end with a period +- It should start with an imperative verb + + ###### GOOD + + ``` + ssh Connect to services in Block's datacenters + ``` + + ###### BAD + + ``` + ssh Connects to services in Block's datacenters + ``` + + ``` + ssh This lets you connect to services in Block's datacenters + ``` + + +#### Conventions for HELP text + +- Each section of the Help text should have its own heading and indented content +- Headings should be in all caps (`sq` will automatically make these Bold White (`\e[1m`)) +- Indentation is always a multiple of 3 spaces +- Make USAGE the first section +- If applicable, include an OPTIONS section to document any flags your CLI accepts +- Include an EXAMPLES section +- Make SUPPORT the last section + - Name the Slack channel where users can reach out for support + - Optionally, identify the repo where pull requests are welcome + +[Here's an example](https://github.com/squareup/sq-ssh/blob/d9f9a72ae24e5feb43502d47255be152c4954a2d/main.go#L25-L67) from `sq ssh`. + +### Distributing a Pack + +You can distribute a pack of commands for `sq` with a Homebrew Formula. + +We recommend structuring your project so that executables are in `./sqbin`. You can author shell scripts directly in this path or compile binaries to it. There are two advantages to this: + +1. It yields a better local development experience. Since `sq` [discovers](/docs/tools/sq-cli/concepts/architecture) commands `./sqbin` first, while you're iterating on your commands, you'll be able to run them with `sq`. +2. It allows your Homebrew Formula to be incredibly minimal (see below). + +If you have more than one executable, group them into a subdirectory within `sqbin` and add a `.sq-module` file. For example, to ship `sq sentry search` and `sq sentry events` as commands and `sq sentry` as a menu that lists them, you would structure your project as follows: + +``` +$ tree -a sqbinsqbin└── sentry ├── .sq-module ├── events └── search +``` + +If you structure your project this way — with executables in `sqbin` — your Homebrew Formula will just need to install that path into its keg. Homebrew will take care of symlinking everything under `etc` into `/usr/local` or `/opt/homebrew`, where `sq` will [discover](/docs/tools/sq-cli/concepts/architecture) it. + +If our [Hello World example](#hello-world) were in a repo named **sq-echo**, a minimal formula to install it would look like this: + +``` +class SqEcho < Formula version "1.0.0" url "https://github.com/squareup/sq-echo.git", tag: version.to_s # This formula publishes a pack of `sq` commands # The following metadata governs how it appears in `sq packs list` @sq_pack = { name: "echo", desc: "Writes its arguments to stdout" } def install (prefix/"etc").install "sqbin" endend +``` + +(In the future, we intend to generate this file automatically.) + +### Completions + +`sq` supports tab-completion on command names. If the user attempts to tab-complete on a command's arguments or flags, `sq` will invoke the command with `--complete`. + +> ### Illustration +> +> If the user types +> +> ``` +> $ sq pair jack rw +> ``` +> +> then the [Bash](https://github.com/squareup/sq/blob/9883e5f84b38d037909690dbf00f82469d8e14a6/etc/bash-completion.sh) / [Zsh](https://github.com/squareup/sq/blob/9883e5f84b38d037909690dbf00f82469d8e14a6/etc/zsh-completion.sh) completion scripts will execute: +> +> ``` +> $ sq complete pair jack rw +> ``` +> +> and `sq` will invoke `pair` like this: +> +> ``` +> $ $(sq which pair) --complete -- jack rw +> ``` +> +> Its output looks like: +> +> ``` +> rwurwaggonerrwiggintonrwallsrweatherlyrwhiterwoodsrwidyanti:4 +> ``` +> +> This output has two parts: +> +> 1. A list of suggestions for completing the argument `rw` (one per line) +> 2. A directive prefixed with `:` + +When `sq` executes a subcommand with `--complete`, if it exits nonzero or produces output that isn't parsable by [the shellcomp package](https://github.com/square/exoskeleton/tree/main/pkg/shellcomp), `sq` will tell the shell not to perform any completions. + +To support completions, a command just needs to respond to the flag `--complete` and write suggestions to standard output, followed by a [directive](https://github.com/square/exoskeleton/tree/main/pkg/shellcomp#directives). ([Here is a sample Ruby implementation](https://github.com/squareup/sq-pair/pull/1/files).) + +Go projects may import the package `"github.com/square/exoskeleton/pkg/shellcomp"`. + +### Metrics + +`sq` automatically collects usage metrics for your command. To see a dashbaord for `sq echo`, navigate to [https://square.cloud.looker.com/dashboards/16914](https://square.cloud.looker.com/dashboards/16914) and select `sq echo` from the **Command** filter. + +You'll be able to see your CLI's + +- **Failure Rate** and the breakdown of exit codes +- **Daily Active Users** and how sticky your users are (how frequently they use the tool) +- **CSAT**, solicited by [@csat-bot](https://github.com/squareup/csat-bot#csat-bot) +- and more + +If you'd like to filter your usage by application-specific dimensions, your CLI can send additional labels to `sq` and you can select one or more labels with the **Labels** filter in that dashboard. + +#### Sending Additional Labels + +`sq` will give a value to the environment variable `SQ_METRICS_PIPE` when it invokes your subcommand. The variable identifies a named pipe. The pipe accepts one or more messages, separated by newlines. (A final trailing newlines is required.) Each message begins with a directive. At this time, the only support directive is `LABELS` which expects to be followed with a whitespace-separated list of labels to add to the usage metric. + +###### Bash + +``` +echo "LABELS foo bar" >> "$SQ_METRICS_PIPE" +``` + +###### Ruby + +``` +File.open(ENV["SQ_METRICS_PIPE"], "a") { |f| f.write "LABELS app:cluster-zkserver app:haas app:panfake app:fidelius app:bletchley app:trunk\n" } +``` + +###### Go + +``` +if f, err := os.OpenFile(os.Getenv("SQ_METRICS_PIPE"), os.O_APPEND|os.O_WRONLY, 0644); err != nil { log.Fatal(err)} else { f.Write([]byte("LABELS infra:ski\n")) f.Close()} +``` + +### Best Practices + +#### Exit Codes + +Use [semantic exit codes](https://github.com/square/exit/#the-codes) to get [more value](https://developer.squareup.com/blog/command-line-observability-with-semantic-exit-codes/) out of `sq`'s dashboards. + +In most languages, if your command line tool crashes, it sets its exit status to 1 If it exits naturally, it sets its exit status to 0. You can get more value out of your telemetry (for example, you can bisect user errors and system errors) by explicitly setting exit statuses when you exit early. For example, if the user has entered an invalid input and you've displayed some kind of validation error, exit 80 (Usage Error). If it makes an API call to a server and the server returns 504 or 503, exit 101 (Unavailable). + +#### See also + +- [the recommended way of structuring a project](#distributing-a-pack) +- the conventions for [summary](#conventions-for-summary-text) and [help](#conventions-for-help-text) text +- [clig.dev](https://clig.dev/), Command Line Interface Guidelines \ No newline at end of file diff --git a/docs/sq-overview.md b/docs/sq-overview.md new file mode 100644 index 0000000..00566a0 --- /dev/null +++ b/docs/sq-overview.md @@ -0,0 +1,74 @@ +# sq + +`sq` is Block's Common Toolchain and provides access to a modular suite of commandline tools. + +info + +`sq` is preinstalled on Block laptops for all engineers. + +(If not: run `brew install square/formula/sq` or refer to [sq](https://github.com/squareup/sq)) + +You can list available commands by typing `sq` at the command line. Example: + +``` +$ sqUSAGE sq []COMMANDS apps: Configure backend services to run locally kochiku Find or start a Kochiku build and view it in a browser packs: Add or remove optional packs of commands pair Get or set your current pairing session ssh Connect to services in Block's datacenters update Update sq and all installed packs +``` + +You can read docs for any command by typing `sq help ` or by visiting the commandline reference. + +tip + +`sq packs` will show you additional collections of commands and you can install them with `sq packs add`. + +--- + +# Architecture + +Like [oclif](https://oclif.io/) and [Cobra](https://github.com/spf13/cobra), `sq` is [a framework](https://github.com/square/exoskeleton#exoskeleton) that provides consistent menus, help pages, tab-completion, and suggestions. `sq` also installs and auto-updates commands on-demand and instruments their [reliability, performance, and usage](https://go/sq-dash). But `sq` differs from other CLI frameworks in that subcommands of `sq` aren't objects in TypeScript or Go but executables external to it in predictable locations. + +Each subcommand maps to a standalone and separate executable, which allows the subcommand to be implemented in different languages and be released on different schedules. + +`sq` provides a common entrypoint as a framework for commandline tools. + +## Example with `cowsay` + +If `cowsay` is an executable, it will appear as a subcommand of `sq` if it is located in: + +1. `./sqbin` where `.` is the current working directory or any of its ancestors +2. `/opt/homebrew/etc/sqbin` or `/usr/local/etc/sqbin` + +In the following scenario + +``` +$ cd /Users/jack/Development/java$ sq cowsay +``` + +`sq` will look for an executable named `cowsay` in these paths, in order; and it will stop at the first match: + +1. `/Users/jack/Development/java/sqbin/cowsay` +2. `/Users/jack/Development/sqbin/cowsay` +3. `/Users/jack/sqbin/cowsay` +4. `/Users/sqbin/cowsay` +5. `/sqbin/cowsay` +6. `/opt/homebrew/etc/sqbin/cowsay` +7. `/usr/local/etc/sqbin/cowsay` + +Nested commands can be represented with subdirectories. For example, `sq mysql pull` maps to a path like `/opt/homebrew/etc/sqbin/mysql/pull`. + +[discovery.go](https://github.com/square/exoskeleton/blob/main/discovery.go) in [https://github.com/square/exoskeleton](https://github.com/square/exoskeleton) implements `sq`'s requirements for command discovery. `sq` uses exoskeleton for command discovery [here](https://github.com/squareup/sq/blob/main/internal/sq/sq.go#L58). + +--- + +# Metadata + +`sq` uses 5 pieces of metadata about each command (or module): + +- **name** — (required) for `sq` to identify a command and display it in menus +- **summary** — (required) a short description (80 characters max) for `sq` to display in menus +- **help** — (required) documentation for `sq` to display in response to `sq COMMAND -h`, `sq COMMAND --help`, or `sq help COMMAND` +- **version** — (optional) a version string (X.Y.Z) for `sq` to display in response to `sq COMMAND --version` or `sq version COMMAND` +- **formula** — (optional) the Homebrew formula that packages a command for `sq` to check for updates and to report with metrics + +For commands installed with Homebrew, **version** and **formula** are extracted from their keg. + +The [Integration Guide](/docs/tools/sq-cli/guides/integration) describes how to write new commands for `sq`. \ No newline at end of file diff --git a/extensions.yaml b/extensions.yaml new file mode 100644 index 0000000..27cca8c --- /dev/null +++ b/extensions.yaml @@ -0,0 +1,135 @@ +# Generated via `just update-extensions-catalog`, then curated manually. +- name: advocatebot + about: Cash App Support Advocate tools — knowledge articles and case management. +- name: ai-app-info + about: Block app metadata — owning team, Slack channel, and Kubernetes context. +- name: airflow + about: Inspect DAGs and task runs across internal Airflow clusters (read-only). +- name: airtable + about: Read and write Airtable bases, tables, and records. +- name: appsecreporter + about: Security Command Center — manage issues and exceptions. +- name: asana + about: Manage Asana projects and tasks. +- name: bitdrift + about: Query detailed mobile telemetry captured when remote Bitdrift workflows trigger device uploads; setup is workflow config, not mobile code. +- name: block-data + about: Query Block Metric Store data and related analytics. +- name: bookshelf + about: Searches Square's public support-center articles (Bookshelf semantic search). +- name: bugsnag + about: Browse and triage Bugsnag error reports for mobile and client apps. +- name: builderlab + about: BuilderLab — Block's task orchestration system for engineering automation. +- name: ci-results + about: Fetch CI build analysis and diagnose build failures. +- name: clear + about: Convert event leads to Marketo-compliant CSV via AI-guided pipeline. +- name: contentful + about: Read and write Contentful CMS content. +- name: crow + about: Search Crow knowledge and query published world models. +- name: datadog + about: Query Datadog metrics, logs, monitors, and dashboards. +- name: datadog-tidal + about: Datadog API access scoped to the Tidal org. +- name: docebo + about: Search Block Academy courses and learning content. +- name: emergetools + about: Compare mobile app build sizes and inspect build metadata via Emerge Tools for binary-size triage. +- name: envoy + about: Make HTTP requests to Block internal services via Envoy. +- name: epoch + about: Read-only access to company events in Epoch. +- name: figma + about: Access your Figma files and design resources. +- name: feature-flag-bot + about: List LaunchDarkly feature flags whose code references are eligible for automated removal (rolled-out, server-only, single-variation, still referenced). +- name: glean + about: Search and read Block's internal knowledge base, and chat with Glean AI. +- name: gmail + about: Search, read, and draft Gmail messages. +- name: google-calendar + about: Read and manage events across your Google Calendars. +- name: google-drive + about: Search, read, create, and edit Google Drive files. +- name: incidentio + about: Manage Block incidents in Incident.io — list, view, and update. +- name: intersect + about: Trigger and manage headless Goose jobs via Intersect. +- name: iterable + about: Query and manage Iterable campaigns, lists, and users. +- name: jira + about: Manage Jira issues, comments, and boards. +- name: kds + about: KDS oncall debugging — look up chits by ID, bill, or order. +- name: kds-reporting + about: KDS reporting oncall — search historical tickets and devices. +- name: kds-sync-hub + about: KDS Sync Hub on-call tools — Sync Hub chit and audit-log lookups for sync-hub-rail investigations. +- name: launchdarkly + about: Manage LaunchDarkly feature flags. +- name: linear + about: Manage Linear issues, projects, and cycles. +- name: lookout + about: Read Lookout release intelligence, Execution Review projects, and Blueprint source health. +- name: looker-oncall + about: Looker API access for oncall triage. +- name: maria + about: Interact with Maria, Square's internal marketing assistant. +- name: marketingaihub + about: Browse Square Marketing AI tools, articles, and G2 tiles. +- name: marketo + about: Access Marketo assets, leads, and bulk extract APIs. +- name: marle + about: Square marketing legal compliance — AI-assisted review and guidance. +- name: netsnap + about: Read-only access to on-premises network device data. +- name: notion + about: Search, create, and manage pages, databases, views, and comments in Notion. +- name: omni + about: Query Omni Analytics data and models via natural language, pick topics, and search Omni docs. +- name: orders + about: Look up Square orders, bills, and payments. +- name: oracle-scm + about: Oracle Supply Chain Management — inventory, POs, and logistics. +- name: pagerduty + about: Manage PagerDuty on-call schedules, alerts, and incidents. +- name: risk + about: Access Block Risk data and insights. +- name: sales + about: Access Salesforce data and generate sales quotes. +- name: salesforce-sq + about: Query Square Salesforce data with schema discovery. +- name: slack + about: Search, read, and send Slack messages. +- name: query-expert + about: Discover Snowflake tables, find data experts, and validate SQL. +- name: regulator + about: "Query Regulator for Square merchant info: business, payments, hardware, and more" +- name: release-owl + about: Read Block release trains, stages, schedules, and changelogs. +- name: sentry + about: Query Sentry for error rates and system metrics. +- name: sourcegraph + about: Search code and browse files across repositories via Sourcegraph. +- name: support-agent-tools + about: Block employee tools for support and regulatory actions. +- name: support-content + about: Semantic search over Cash App support articles. +- name: testrack + about: Inspect and run commands on Testrack hardware nodes — limited to hw_testrack_users. +- name: triage-copilot + about: Read source-aware health metrics for hardware test suites. +- name: thehub + about: Employee experience tools — Loop, feedback, and org data. +- name: todoist + about: Manage your Todoist tasks and projects. +- name: ventana + about: Analyze calls, chats, and emails at scale for Moneybot, Square, and Managerbot. +- name: websearch + about: Search the web for up-to-date information. +- name: workday + about: Manage time off, inbox tasks, and org data in Workday. +- name: edge-feedback + about: Recognize, collect and store user steering for extensions from internal use cases diff --git a/kochiku.yml b/kochiku.yml new file mode 100644 index 0000000..8de6a00 --- /dev/null +++ b/kochiku.yml @@ -0,0 +1,5 @@ +test_command: script/ci + +targets: + - type: lint + - type: test diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..87815a5 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,16 @@ +skip_lfs: true +output: + - success + - failure +pre-push: + parallel: true + commands: + lint: + run: just lint + test: + run: just test +pre-commit: + commands: + fmt: + run: just fmt + stage_fixed: true diff --git a/protos/google/api/annotations.proto b/protos/google/api/annotations.proto new file mode 100644 index 0000000..417edd8 --- /dev/null +++ b/protos/google/api/annotations.proto @@ -0,0 +1,31 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +import "google/api/http.proto"; +import "google/protobuf/descriptor.proto"; + +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "AnnotationsProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +extend google.protobuf.MethodOptions { + // See `HttpRule`. + HttpRule http = 72295728; +} diff --git a/protos/google/api/http.proto b/protos/google/api/http.proto new file mode 100644 index 0000000..bb3af8e --- /dev/null +++ b/protos/google/api/http.proto @@ -0,0 +1,370 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "HttpProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +// Defines the HTTP configuration for an API service. It contains a list of +// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method +// to one or more HTTP REST API methods. +message Http { + // A list of HTTP configuration rules that apply to individual API methods. + // + // **NOTE:** All service configuration rules follow "last one wins" order. + repeated HttpRule rules = 1; + + // When set to true, URL path parameters will be fully URI-decoded except in + // cases of single segment matches in reserved expansion, where "%2F" will be + // left encoded. + // + // The default behavior is to not decode RFC 6570 reserved characters in multi + // segment matches. + bool fully_decode_reserved_expansion = 2; +} + +// gRPC Transcoding +// +// gRPC Transcoding is a feature for mapping between a gRPC method and one or +// more HTTP REST endpoints. It allows developers to build a single API service +// that supports both gRPC APIs and REST APIs. Many systems, including [Google +// APIs](https://github.com/googleapis/googleapis), +// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC +// Gateway](https://github.com/grpc-ecosystem/grpc-gateway), +// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature +// and use it for large scale production services. +// +// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies +// how different portions of the gRPC request message are mapped to the URL +// path, URL query parameters, and HTTP request body. It also controls how the +// gRPC response message is mapped to the HTTP response body. `HttpRule` is +// typically specified as an `google.api.http` annotation on the gRPC method. +// +// Each mapping specifies a URL path template and an HTTP method. The path +// template may refer to one or more fields in the gRPC request message, as long +// as each field is a non-repeated field with a primitive (non-message) type. +// The path template controls how fields of the request message are mapped to +// the URL path. +// +// Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/{name=messages/*}" +// }; +// } +// } +// message GetMessageRequest { +// string name = 1; // Mapped to URL path. +// } +// message Message { +// string text = 1; // The resource content. +// } +// +// This enables an HTTP REST to gRPC mapping as below: +// +// - HTTP: `GET /v1/messages/123456` +// - gRPC: `GetMessage(name: "messages/123456")` +// +// Any fields in the request message which are not bound by the path template +// automatically become HTTP query parameters if there is no HTTP request body. +// For example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get:"/v1/messages/{message_id}" +// }; +// } +// } +// message GetMessageRequest { +// message SubMessage { +// string subfield = 1; +// } +// string message_id = 1; // Mapped to URL path. +// int64 revision = 2; // Mapped to URL query parameter `revision`. +// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`. +// } +// +// This enables a HTTP JSON to RPC mapping as below: +// +// - HTTP: `GET /v1/messages/123456?revision=2&sub.subfield=foo` +// - gRPC: `GetMessage(message_id: "123456" revision: 2 sub: +// SubMessage(subfield: "foo"))` +// +// Note that fields which are mapped to URL query parameters must have a +// primitive type or a repeated primitive type or a non-repeated message type. +// In the case of a repeated type, the parameter can be repeated in the URL +// as `...?param=A¶m=B`. In the case of a message type, each field of the +// message is mapped to a separate parameter, such as +// `...?foo.a=A&foo.b=B&foo.c=C`. +// +// For HTTP methods that allow a request body, the `body` field +// specifies the mapping. Consider a REST update method on the +// message resource collection: +// +// service Messaging { +// rpc UpdateMessage(UpdateMessageRequest) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "message" +// }; +// } +// } +// message UpdateMessageRequest { +// string message_id = 1; // mapped to the URL +// Message message = 2; // mapped to the body +// } +// +// The following HTTP JSON to RPC mapping is enabled, where the +// representation of the JSON in the request body is determined by +// protos JSON encoding: +// +// - HTTP: `PATCH /v1/messages/123456 { "text": "Hi!" }` +// - gRPC: `UpdateMessage(message_id: "123456" message { text: "Hi!" })` +// +// The special name `*` can be used in the body mapping to define that +// every field not bound by the path template should be mapped to the +// request body. This enables the following alternative definition of +// the update method: +// +// service Messaging { +// rpc UpdateMessage(Message) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "*" +// }; +// } +// } +// message Message { +// string message_id = 1; +// string text = 2; +// } +// +// +// The following HTTP JSON to RPC mapping is enabled: +// +// - HTTP: `PATCH /v1/messages/123456 { "text": "Hi!" }` +// - gRPC: `UpdateMessage(message_id: "123456" text: "Hi!")` +// +// Note that when using `*` in the body mapping, it is not possible to +// have HTTP parameters, as all fields not bound by the path end in +// the body. This makes this option more rarely used in practice when +// defining REST APIs. The common usage of `*` is in custom methods +// which don't use the URL at all for transferring data. +// +// It is possible to define multiple HTTP methods for one RPC by using +// the `additional_bindings` option. Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/messages/{message_id}" +// additional_bindings { +// get: "/v1/users/{user_id}/messages/{message_id}" +// } +// }; +// } +// } +// message GetMessageRequest { +// string message_id = 1; +// string user_id = 2; +// } +// +// This enables the following two alternative HTTP JSON to RPC mappings: +// +// - HTTP: `GET /v1/messages/123456` +// - gRPC: `GetMessage(message_id: "123456")` +// +// - HTTP: `GET /v1/users/me/messages/123456` +// - gRPC: `GetMessage(user_id: "me" message_id: "123456")` +// +// Rules for HTTP mapping +// +// 1. Leaf request fields (recursive expansion nested messages in the request +// message) are classified into three categories: +// - Fields referred by the path template. They are passed via the URL path. +// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They +// are passed via the HTTP +// request body. +// - All other fields are passed via the URL query parameters, and the +// parameter name is the field path in the request message. A repeated +// field can be represented as multiple query parameters under the same +// name. +// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL +// query parameter, all fields +// are passed via URL path and HTTP request body. +// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP +// request body, all +// fields are passed via URL path and URL query parameters. +// +// Path template syntax +// +// Template = "/" Segments [ Verb ] ; +// Segments = Segment { "/" Segment } ; +// Segment = "*" | "**" | LITERAL | Variable ; +// Variable = "{" FieldPath [ "=" Segments ] "}" ; +// FieldPath = IDENT { "." IDENT } ; +// Verb = ":" LITERAL ; +// +// The syntax `*` matches a single URL path segment. The syntax `**` matches +// zero or more URL path segments, which must be the last part of the URL path +// except the `Verb`. +// +// The syntax `Variable` matches part of the URL path as specified by its +// template. A variable template must not contain other variables. If a variable +// matches a single path segment, its template may be omitted, e.g. `{var}` +// is equivalent to `{var=*}`. +// +// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL` +// contains any reserved character, such characters should be percent-encoded +// before the matching. +// +// If a variable contains exactly one path segment, such as `"{var}"` or +// `"{var=*}"`, when such a variable is expanded into a URL path on the client +// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The +// server side does the reverse decoding. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{var}`. +// +// If a variable contains multiple path segments, such as `"{var=foo/*}"` +// or `"{var=**}"`, when such a variable is expanded into a URL path on the +// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded. +// The server side does the reverse decoding, except "%2F" and "%2f" are left +// unchanged. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{+var}`. +// +// Using gRPC API Service Configuration +// +// gRPC API Service Configuration (service config) is a configuration language +// for configuring a gRPC service to become a user-facing product. The +// service config is simply the YAML representation of the `google.api.Service` +// proto message. +// +// As an alternative to annotating your proto file, you can configure gRPC +// transcoding in your service config YAML files. You do this by specifying a +// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same +// effect as the proto annotation. This can be particularly useful if you +// have a proto that is reused in multiple services. Note that any transcoding +// specified in the service config will override any matching transcoding +// configuration in the proto. +// +// The following example selects a gRPC method and applies an `HttpRule` to it: +// +// http: +// rules: +// - selector: example.v1.Messaging.GetMessage +// get: /v1/messages/{message_id}/{sub.subfield} +// +// Special notes +// +// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the +// proto to JSON conversion must follow the [proto3 +// specification](https://developers.google.com/protocol-buffers/docs/proto3#json). +// +// While the single segment variable follows the semantics of +// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String +// Expansion, the multi segment variable **does not** follow RFC 6570 Section +// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion +// does not expand special characters like `?` and `#`, which would lead +// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding +// for multi segment variables. +// +// The path variables **must not** refer to any repeated or mapped field, +// because client libraries are not capable of handling such variable expansion. +// +// The path variables **must not** capture the leading "/" character. The reason +// is that the most common use case "{var}" does not capture the leading "/" +// character. For consistency, all path variables must share the same behavior. +// +// Repeated message fields must not be mapped to URL query parameters, because +// no client library can support such complicated mapping. +// +// If an API needs to use a JSON array for request or response body, it can map +// the request or response body to a repeated field. However, some gRPC +// Transcoding implementations may not support this feature. +message HttpRule { + // Selects a method to which this rule applies. + // + // Refer to [selector][google.api.DocumentationRule.selector] for syntax + // details. + string selector = 1; + + // Determines the URL pattern is matched by this rules. This pattern can be + // used with any of the {get|put|post|delete|patch} methods. A custom method + // can be defined using the 'custom' field. + oneof pattern { + // Maps to HTTP GET. Used for listing and getting information about + // resources. + string get = 2; + + // Maps to HTTP PUT. Used for replacing a resource. + string put = 3; + + // Maps to HTTP POST. Used for creating a resource or performing an action. + string post = 4; + + // Maps to HTTP DELETE. Used for deleting a resource. + string delete = 5; + + // Maps to HTTP PATCH. Used for updating a resource. + string patch = 6; + + // The custom pattern is used for specifying an HTTP method that is not + // included in the `pattern` field, such as HEAD, or "*" to leave the + // HTTP method unspecified for this rule. The wild-card rule is useful + // for services that provide content to Web (HTML) clients. + CustomHttpPattern custom = 8; + } + + // The name of the request field whose value is mapped to the HTTP request + // body, or `*` for mapping all request fields not captured by the path + // pattern to the HTTP body, or omitted for not having any HTTP request body. + // + // NOTE: the referred field must be present at the top-level of the request + // message type. + string body = 7; + + // Optional. The name of the response field whose value is mapped to the HTTP + // response body. When omitted, the entire response message will be used + // as the HTTP response body. + // + // NOTE: The referred field must be present at the top-level of the response + // message type. + string response_body = 12; + + // Additional HTTP bindings for the selector. Nested bindings must + // not contain an `additional_bindings` field themselves (that is, + // the nesting may only be one level deep). + repeated HttpRule additional_bindings = 11; +} + +// A custom pattern is used for defining custom HTTP verb. +message CustomHttpPattern { + // The name of this custom HTTP verb. + string kind = 1; + + // The path matched by this custom verb. + string path = 2; +} diff --git a/protos/squareup/cash/kgoose/api/v3/activity_messages.proto b/protos/squareup/cash/kgoose/api/v3/activity_messages.proto new file mode 100644 index 0000000..19cae2a --- /dev/null +++ b/protos/squareup/cash/kgoose/api/v3/activity_messages.proto @@ -0,0 +1,79 @@ +syntax = "proto2"; + +package squareup.cash.kgoose.api.v3; + +option java_package = "com.squareup.protos.cash.kgoose.api.v3"; + + +message Activity { + oneof activity_type { + TileCreationActivity tile_creation = 1; + + // Deprecated: Use `ClientRenderingUpdateActivity` instead. + PlasmaFlowCompletionActivity plasma_flow_completion = 2 [deprecated = true]; + + // Deprecated: Use `ClientRenderingUpdateActivity` instead. + ClientCardUpdateActivity client_card_update = 3; + + ClientRenderingUpdateActivity client_rendering_update = 4; + + MemoryUpdateActivity memory_update = 5; + + // Add more activity types here in the future + } + + optional string id = 101; // Unique ID for the activity for deduplication + + optional int64 created = 102; // Timestamp of when the activity was created +} + +message ClientCardUpdateActivity { + optional string tool_request_id = 1; // Unique identifier for the tool request + + // Summary of the client card completion, e.g., "Client card $5 bitcoin purchase completes" + optional string summary = 2; + + optional ClientCardUpdateStatus status = 3; // Status of the client card completion +} + +// Updates client-rendered UI components, rendered from server-side tool responses, based on state changes. +// If a `ClientRenderingUpdateActivity` is found for the given `tool_request_id`, the most recent +// `client_renderable` should override the original `client_renderable` in the tool response. +// e.g. updating a "Send $20" action card to "Sent $20" after a customer completes the payment flow. +message ClientRenderingUpdateActivity { + // Required tool request id associated with the rendering being updated + optional string tool_request_id = 1; + + // Required JSON representation of ClientRenderable proto + // optional ClientRenderable client_renderable = 2; +} + +enum ClientCardUpdateStatus { + CLIENT_CARD_UPDATE_STATUS_UNSPECIFIED = 0; + + /* The client card update has been finished successfully */ + CLIENT_CARD_UPDATE_STATUS_SUCCESS = 1; + + /* The client card update has failed */ + CLIENT_CARD_UPDATE_STATUS_FAILED = 2; +} + +message TileCreationActivity { + optional string tile_id = 1; // ID of the tile associated with this activity + optional string tile_title = 2; // title of the tile +} + +message PlasmaFlowCompletionActivity { + optional string tool_request_id = 1; + + // Summary of the Plasma flow completion, e.g., "$5 bitcoin purchase completes" + optional string summary = 2; +} + +message MemoryUpdateActivity { + // ID of the message associated with the memory update + optional string origin_message_id = 1; + + // Label to describe memory action taken in UI + optional string label = 2; +} diff --git a/protos/squareup/cash/kgoose/api/v3/agent_config_messages.proto b/protos/squareup/cash/kgoose/api/v3/agent_config_messages.proto new file mode 100644 index 0000000..5e87b16 --- /dev/null +++ b/protos/squareup/cash/kgoose/api/v3/agent_config_messages.proto @@ -0,0 +1,200 @@ +syntax = "proto2"; + +package squareup.cash.kgoose.api.v3; + +option java_package = "com.squareup.protos.cash.kgoose.api.v3"; + +import "squareup/cash/kgoose/api/v3/common_messages.proto"; +import "squareup/cash/kgoose/api/v3/extension_selection_config.proto"; + +// Lifecycle status of an AgentConfig version. +enum AgentConfigStatus { + // Default/unknown. + AGENT_CONFIG_STATUS_UNSPECIFIED = 0; + // Can be targeted by PushMessages when referenced by name + version. + AGENT_CONFIG_STATUS_ACTIVE = 1; + // The singular default version for a config name. Used when PushMessages specifies a name + // but no version. At most one primary per config name. + AGENT_CONFIG_STATUS_PRIMARY = 2; + // Retired. Not expected to be used. Preserved for audit history. + AGENT_CONFIG_STATUS_INACTIVE = 3; +} + +// A named, versioned set of settings that configure kgoose agent behavior. +// This is the successor to ProfileConfig, unifying ServiceProfileConfig and UserProfileConfig +// into a single configuration type. +message AgentConfig { + // The preferred model to be used. + optional Model preferred_model = 1; + + // Extension selection policy. Controls which extensions are available to the agent + // and how they are selected. When present, takes precedence over legacy extension + // fields in ServiceProfileConfig (e.g. preferred_backend_tools, no_backend_tools). + optional ExtensionSelectionConfig extension_selection_config = 2; +} + +// A reference to a named AgentConfig, used on PushMessagesRequest to specify +// which agent configuration to use. +message AgentConfigReference { + // Required. The config name, e.g., "moneybot". + optional string name = 1; + + // Optional version label. If omitted, the latest active version is used. + optional string version = 2; +} + +// How a ClientConfiguration was sourced. +enum ConfigSource { + CONFIG_SOURCE_UNSPECIFIED = 0; + // Configuration was provided by the client in the request body (agent_config_ref). + CONFIG_SOURCE_CLIENT = 1; + // Configuration was overridden via a request header (staging/playpen only). + CONFIG_SOURCE_HEADER = 2; +} + +// Records the configuration that was specified before a Task begins. +message ClientConfiguration { + // The name of the agent configuration, e.g., "managerbot" + optional string config_name = 1; + + // The version label of the agent configuration, e.g., "3.2.1" + optional string config_version = 2; + + // Deprecated: use config_source instead. Legacy string values: "client", "header", "default". + optional string source = 3 [deprecated = true]; + + // Full snapshot of the AgentConfig at the time of the request + optional AgentConfig config = 4; + + // How the configuration was sourced. + optional ConfigSource config_source = 5; +} + +// ===== CRUD Messages ===== + +message CreateAgentConfigRequest { + // Required. Unique name for this configuration, e.g., "moneybot", "managerbot". + required string name = 1; + + // Optional human-readable description of this configuration version. + optional string description = 2; + + // Required. Human-friendly version label, e.g., "3.2.1". + // Must be unique within this config name. + optional string version_label = 3; + + // Optional product grouping for admin UI filtering, e.g., "moneybot". + optional string product = 4; + + // Optional freeform tags for categorization, e.g., ["production", "moneybot"]. + repeated string tags = 5; + + // Required. The agent configuration settings. + optional AgentConfig agent_config = 6; +} + +message CreateAgentConfigResponse { + optional AgentConfigRecord record = 1; +} + +message GetAgentConfigRequest { + // Required. The config name to fetch. + required string name = 1; + + // Optional. If provided, fetches this specific version label. + // If omitted, returns the latest version. + optional string version_label = 2; +} + +message GetAgentConfigResponse { + optional AgentConfigRecord record = 1; +} + +message UpdateAgentConfigRequest { + // Required. The config name to update (creates a new version). + required string name = 1; + + // Optional updated description. + optional string description = 2; + + // Required. Human-friendly version label, e.g., "3.2.1". + // Must be unique within this config name. + optional string version_label = 3; + + // Optional updated product. + optional string product = 4; + + // Optional updated tags (replaces existing tags). + repeated string tags = 5; + + // Required. The new agent configuration settings. + required AgentConfig agent_config = 6; +} + +message UpdateAgentConfigResponse { + optional AgentConfigRecord record = 1; +} + +message DeleteAgentConfigRequest { + // Required. The config name to soft-delete. + required string name = 1; +} + +message DeleteAgentConfigResponse {} + +message ListAgentConfigsRequest { + // Optional. Filter by product. + optional string product = 1; +} + +message ListAgentConfigsResponse { + repeated AgentConfigRecord records = 1; +} + +// A stored agent configuration with metadata. +message AgentConfigRecord { + // The config name. + optional string name = 1; + + // Human-friendly version label. + optional string version_label = 2; + + // Product grouping. + optional string product = 3; + + // Freeform tags. + repeated string tags = 4; + + // The agent configuration. + optional AgentConfig agent_config = 5; + + // Who created this version. + optional string created_by = 6; + + // When this version was created, in epoch millis. + optional int64 created_at = 7; + + // Human-readable description. + optional string description = 8; + + // Lifecycle status of this config version. + optional AgentConfigStatus status = 9; +} + +// ===== SetConfigStatus Messages ===== + +message SetConfigStatusRequest { + // Required. The config name. + optional string name = 1; + + // Required. The version label identifying which version to transition. + // Must match a version_label previously set via CreateAgentConfig or UpdateAgentConfig. + optional string version_label = 2; + + // Required. The target status. + optional AgentConfigStatus status = 3; +} + +message SetConfigStatusResponse { + optional AgentConfigRecord record = 1; +} diff --git a/protos/squareup/cash/kgoose/api/v3/chat_messages.proto b/protos/squareup/cash/kgoose/api/v3/chat_messages.proto new file mode 100644 index 0000000..cf93b67 --- /dev/null +++ b/protos/squareup/cash/kgoose/api/v3/chat_messages.proto @@ -0,0 +1,598 @@ +syntax = "proto2"; + +package squareup.cash.kgoose.api.v3; + +option java_package = "com.squareup.protos.cash.kgoose.api.v3"; + +import "squareup/cash/kgoose/api/v3/agent_config_messages.proto"; +import "squareup/cash/kgoose/api/v3/profile_messages.proto"; +import "squareup/cash/kgoose/api/v3/common_messages.proto"; +import "squareup/cash/kgoose/api/v3/activity_messages.proto"; + +import "squareup/common/pii.proto"; +import "squareup/common/governance/v0/semantic_types.proto"; +import "google/protobuf/struct.proto"; +import "squareup/cash/kgoosememorystore/api/v1beta1/memory.proto"; + +// ===== Enums ===== +enum Role { + ROLE_UNSPECIFIED = 0; + ROLE_USER = 1; + ROLE_ASSISTANT = 2; + ROLE_SYSTEM = 3; +} + +enum MessageType { + MESSAGE_TYPE_UNSPECIFIED = 0; + MESSAGE_TYPE_TEXT = 1; + MESSAGE_TYPE_IMAGE = 2; + MESSAGE_TYPE_TOOL_REQUEST = 3; + MESSAGE_TYPE_TOOL_RESPONSE = 4; + MESSAGE_TYPE_SUMMARY = 5; + MESSAGE_TYPE_THINKING = 8; + MESSAGE_TYPE_REDACTED_THINKING = 9; + MESSAGE_TYPE_FILE = 10; +} + +enum PushMessageStatus { + PUSH_MESSAGE_STATUS_DO_NOT_USE = 0; + PUSH_MESSAGE_STATUS_FAILED_BUSY_PROCESSING = 1; + PUSH_MESSAGE_STATUS_SUBMITTED = 2; +} + +enum ChatSessionStatus { + CHAT_SESSION_STATUS_UNSPECIFIED = 0; + + /* Session is initialized and ready for interaction. We will use this + * status to indicate that the session is created but no messages have been sent yet. + */ + CHAT_SESSION_STATUS_INITIALIZED = 1; + + /* Session is idle, no active processing */ + CHAT_SESSION_STATUS_IDLE = 2; + + /* Session is currently processing a request */ + CHAT_SESSION_STATUS_PROCESSING = 3; + + /* Session is waiting for user input to continue */ + CHAT_SESSION_STATUS_NEED_CLIENT_INPUT = 4; + + /* Session has been terminated, no further interaction possible */ + CHAT_SESSION_STATUS_TERMINATED = 5; + + /* Session is canceling the last user message */ + CHAT_SESSION_STATUS_CANCELLING = 6; + + /* Session is waiting for a permission lease to be granted or denied */ + CHAT_SESSION_STATUS_WAITING_FOR_PERMISSION_LEASE = 7; +} + +/* Tenancy for isolating sessions by visibility/purpose */ +enum Tenancy { + /* The default space for conversations, visible to users */ + TENANCY_DEFAULT = 0; + /* Moneybench (integration test) conversations */ + TENANCY_MONEYBENCH = 1; + /* Moneybot suggestions conversations that shouldn't be visible for the user */ + TENANCY_MONEYBOT_SUGGESTIONS = 2; + /* Load testing from shadow */ + TENANCY_SHADOW = 3; + /* Managerbot Tasks */ + TENANCY_MANAGERBOT_TASKS = 4; + /* Pathfinder */ + TENANCY_PATHFINDER = 5; + /* Moneybot suggestions that are precomputed on app launch/foreground event */ + TENANCY_MONEYBOT_PRECOMPUTED_SUGGESTIONS = 6; + /* Managerbot insights */ + TENANCY_MANAGERBOT_INSIGHTS = 7; + /* Moneybot suggestions that are fetched from Promoter service */ + TENANCY_MONEYBOT_PROMOTER_SUGGESTIONS = 8; + /* G2 conversations */ + TENANCY_G2 = 9; + /* Moneybot memory extraction */ + TENANCY_MONEYBOT_MEMORY_EXTRACTION = 10; + /* Evaluate quality of Moneybot memory extraction */ + TENANCY_MONEYBOT_MEMORY_EVALUATION = 11; + /* Moneybot next best action workflow */ + TENANCY_MONEYBOT_NEXT_BEST_ACTION = 12; + /* Moneybot SMS conversations */ + TENANCY_MONEYBOT_SMS = 13; + /* External unauthenticated conversations */ + TENANCY_EXTERNAL = 14; + /* Cash Support Advocate Copilot - Default or Unclassified Features */ + TENANCY_CASH_ADVOCATE_COPILOT_DEFAULT = 15; + /* Cash Support Advocate Copilot Suggestion Pills */ + TENANCY_CASH_ADVOCATE_COPILOT_SUGGESTION_PILLS = 16; + /* Cash Support Advocate Bot - Default or Unclassified Features */ + TENANCY_CASH_ADVOCATE_BOT_DEFAULT = 17; + /* Moneybot user summary generation (admin endpoint) */ + TENANCY_MONEYBOT_USER_SUMMARY = 18; + /* Cashbot offline experimentation */ + TENANCY_CASHBOT_OFFLINE = 19; + /* Afterpaybot offline experimentation */ + TENANCY_AFTERPAYBOT_OFFLINE = 20; + /* BuilderLab slackbot */ + TENANCY_BUILDERLAB_SLACKBOT = 21; + /* Customer reengagement workflow */ + TENANCY_CUSTOMER_REENGAGEMENT = 22; + /* Managerbot mobile web sessions */ + TENANCY_MANAGERBOT_MOBILE = 23; + /* Moneybot proactive notification workflow */ + TENANCY_MONEYBOT_PROACTIVE_NOTIFICATION = 24; + /* Moneybot home prompt rephrase structured output */ + TENANCY_MONEYBOT_HOME_PROMPT_REPHRASE = 25; + /* Managerbot widget creation subagent sessions */ + TENANCY_MANAGERBOT_WIDGET_CREATE = 26; + /* Moneybot Home conversations */ + TENANCY_MONEYBOT_HOME = 27; + /* Moneybot default from home conversations */ + TENANCY_MONEYBOT_DEFAULT_FROM_HOME = 28; + /* Moneybot default from notification conversations */ + TENANCY_MONEYBOT_DEFAULT_FROM_NOTIFICATION = 29; + /* Default tenancy for programmatic tool calls via ToolEndpointService */ + TENANCY_KGOOSE_TOOL_CALL_ENDPOINTS_DEFAULT = 30; + /* Moneybot in-chat prompts selection */ + TENANCY_MONEYBOT_IN_CHAT_PROMPTS = 31; +} + +// ==== Decryption Config ==== +message McpDecryptionConfig { + repeated McpKeyDescriptor mcp_key_descriptor = 1; + + optional string client_key = 3 [(squareup.redacted) = true, (squareup.governance.v0.consumer) = AUTH_TOKEN, (squareup.governance.v0.merchant) = AUTH_TOKEN]; +} + +message McpKeyDescriptor { + optional string extension_name = 1; + optional string identifier = 2; +} + +// ===== Message ===== + +message InputMessage { + repeated MessageContent message_contents = 1 [(squareup.governance.v0.consumer) = USER_GENERATED_TEXT, (squareup.governance.v0.merchant) = USER_GENERATED_TEXT]; + optional bool hidden = 2; + // Client assigned message id (UUID). Most clients should let the server assign the id. If specified, it must be a unique UUID v4 string. + optional string id = 3; + // this is used if we are seeding push messages with conversation from outside, if role is not provided + // we assume it is USER role, for normal conversation it can be left blank + optional Role role = 4; + // we allow users to seed history so this field can be used to provide time for the seeded messages + // do not set this field for regular conversation and we will use current timestamp + optional int64 created = 5; +} + +message Message { + optional string id = 1; // Always server assigned + optional Role role = 2; + optional int64 created = 3; + repeated MessageContent content = 4; + optional bool deleted = 5 [default = false]; + optional ErrorInfo llm_call_error_info = 6; // error information if there was an error calling the llm + + message ErrorInfo { + optional bool is_error = 1; + optional string cause = 2; + } +} + +message MessageContent { + optional MessageType type = 1; + + oneof content { + TextContent text = 2; + ImageContent image = 3; + ToolRequest tool_request = 4; + ToolResponse tool_response = 5; + ThinkingContent thinking = 6; + RedactedThinkingContent redacted_thinking = 7; + FileContent file = 8; + } +} + +message TextContent { + optional string text = 1 [(squareup.redacted) = true]; +} + +message ImageContent { + optional string data = 1 [(squareup.redacted) = true]; + optional string mime_type = 2; + optional bool requires_presigned_url = 3; // Indicates if the image requires a presigned URL for access + optional string s3_uri = 4; // Original S3 reference (populated at runtime for LLM context) +} + +message FileContent { + optional string data = 1 [(squareup.redacted) = true]; // S3 reference after upload + optional string mime_type = 2; // File MIME type + optional bool requires_presigned_url = 3; + optional string s3_uri = 4; + optional string filename = 5; // Original filename + optional uint64 size_bytes = 6; // File size +} + +message EmbeddedResource { + optional google.protobuf.Struct meta = 1; + optional ResourceContents resource = 3; + optional string type = 5; +} + +message ResourceAnnotations { + repeated Role audience = 1; + optional string last_modified = 2; // ISO 8601 timestamp + optional double priority = 3; +} + +message ResourceContents { + optional google.protobuf.Struct meta = 1; + optional string uri = 2; + optional string mime_type = 3; + optional string text = 4 [(squareup.redacted) = true]; + optional string blob = 5 [(squareup.redacted) = true]; + optional ResourceAnnotations annotations = 6; +} + +message ToolResponse { + optional string id = 1; + optional string status = 2; // "success" or "error" + repeated UserContent results = 3; + optional string error = 4; + + // The name of the extension that produced this tool response. + optional string extension_name = 5; +} + +message ToolRequest { + optional string id = 1; + optional string status = 2; // "success" or "error" + + oneof result { + ToolCall value = 3; + string error = 4; // Error message as string + } + + optional string tooltip = 5 [(squareup.redacted) = true]; // Tooltip for the tool request + optional string tooltip_category = 6 [(squareup.redacted) = true]; // Tooltip category for the tool request +} + +message ToolCall { + optional string name = 1; + optional string arguments = 2 [(squareup.redacted) = true]; + optional bool needs_approval = 3 [default = false]; +} + +message UserContent { + oneof content { + TextContent text = 1; + ImageContent image = 2; + + // Below are client-specific content types, kgoose will convert them to json content for LLM to read + // ClientRenderable client_renderable = 3 [deprecated = true]; // Deprecated: Use structured_content instead. + + StructuredContent structured_content = 4; + EmbeddedResource resource = 5; + } +} + +message StructuredContent { + optional google.protobuf.Struct data = 1; + + // this is required by moneybot client, since andriod and ios client can't use Struct type directly + // optional ClientRenderable client_renderable = 2; +} + +message ThinkingContent { + optional string thinking = 1 [(squareup.redacted) = true]; + optional string signature = 2 [(squareup.redacted) = true]; +} + +message RedactedThinkingContent { + optional string data = 1 [(squareup.redacted) = true]; +} + +// ==== Session and ChatContexts ==== +message FollowUpChatContext { + optional string tile_id = 1; + optional string tile_result_id = 2; // This is the session id of the tile refresh +} + +message CreateTileFromTileContext { + optional string tile_id = 1; + optional string creator = 2; +} + +message EditTileContext { + optional string tile_id = 1; +} + +message EditWidgetContext { + optional string widget_id = 1; +} + +message RenderContext { + optional int32 width = 1; // Width in density independent pixels + optional int32 height = 2; // Height in density independent pixels +} + +message SpaceContext { + optional string id = 1; // The ID of the space + optional string creator = 2; // The creator/owner of the space. Required for shared spaces to enable proper access control and tile retrieval. +} + +message SlackContext { + optional string channel_id = 1; + optional string thread_timestamp = 2; +} + +message Session { + optional string id = 1; + optional string name = 2; + optional int64 created = 3; + optional int64 updated = 4; + optional string tile_id = 5; + optional bool async_process = 6 [deprecated = true]; + optional ChatSessionStatus chat_session_status = 7; + repeated Activity activities = 8; + optional string space_id = 9; // ID of the space this session belongs to + optional Source chat_source = 10; + optional ChatContext chat_context = 11; + // Timestamp (epoch millis) of the most recent message in the session + optional int64 last_message_at = 12; + // Timestamp (epoch millis) when the user last read/viewed the session (only populated when include_read_state=true) + optional int64 last_read_at = 13; + // only populated when include_read_state=true + optional bool has_unread_messages = 14; + + // Additional metadata for the session (e.g., icon slug for Managerbot) + map metadata = 15; +} + +/** NOTE: Changes here should be backwards compatible because it is saved in the SessionEntity */ +message ChatContext { + // the interface where the chat is initiated + optional Source source = 1; + + optional FollowUpChatContext follow_up_chat_context = 2; + + optional SpaceContext space = 3; // The space where the chat is initiated, if applicable + + optional string time_zone = 4; // IANA Time Zone Database time zone, e.g. "America/New_York". + + optional CreateTileFromTileContext create_tile_from_tile_context = 5; + + optional EditTileContext edit_tile_context = 6; // Editing tiles and automations + + // The extra customer context applied in the chat. It will override other customer contexts if + // exists. + // optional CustomerContext customer_context = 7; + + // Tenancy for filtering conversations by visibility/purpose, default for standard user conversations is TENANCY_DEFAULT + optional Tenancy tenancy = 8; + + // If true, the bot will prompt the user for input when needed. + optional bool bot_prompts_you = 9; + + optional string insight_id = 10; + + // ID for tracking which list of suggestions were shown + optional string suggestions_id = 11; + + optional ScriptConversionContext script_conversion_context = 12; + + optional MoneybotContext moneybot_context = 13; + + // Context for Managerbot task creation via /v3/create-task endpoint. + optional ManagerbotContext managerbot_context = 14; + + // Render context for the client display dimensions + optional RenderContext render_context = 15; + + // If true, the session will auto-terminate after processing the current task. + // Useful for quick 1-turn conversations where the client knows no follow-up is needed. + optional bool auto_terminate = 16; + + optional SlackContext slack_context = 17; + + optional EditWidgetContext edit_widget_context = 18; // Editing widgets via chat +} + +// ===== Kickoff ===== +// This type is used by clients to kickoff new sessions, it is not directly used in kgoose server +// DEPRECATED: This message is deprecated use the one in 'squareup.cash.kgoose.client.ClientKickoffParams' instead. +message ClientKickoffParams{ + repeated InputMessage kickoff_messages = 1; + optional bool should_auto_send = 2; + optional string session_id = 3; + optional Tenancy tenancy = 4; +} + +// ===== Requests and Responses ===== +message PushMessagesRequest { + optional string session_id = 1; + repeated InputMessage messages = 2 [(squareup.redacted) = true]; + + optional string profile_id = 3; + optional ProfileConfig profile_config = 4; + + optional OnBehalfOf on_behalf_of = 5; + + // TODO: This can be removed after we enable Slack OAuth + optional McpDecryptionConfig mcp_decryption_config = 6 [deprecated = true]; + + // provide additional context about the chat + optional ChatContext chat_context = 7; + + // If session_id is not provided, session name to for the new session + optional string session_name = 8; + + // TTL in seconds for the session. If set, the session will expire after this duration. + // Reading/writing to existing session will NOT reset the TTL. + optional int32 session_ttl_in_seconds = 9; + + // If set, the server will skip the LLM and execute this tool call directly. + // Used by client-side confirmation cards to trigger a tool call without an LLM round-trip. + optional ToolCall force_tool_call = 10; + + // Reference a named AgentConfig from the registry. When set, the server loads the + // configuration and applies it. Takes precedence over profile_id/profile_config + // for fields that are set on the AgentConfig. + optional AgentConfigReference agent_config_ref = 11; +} + +message PushMessagesResponse { + optional string session_id = 1; + optional PushMessageStatus status = 2; +} + +message GetMessagesRequest { + optional string session_id = 1; + optional string message_cursor = 2; + optional OnBehalfOf on_behalf_of = 3; +} + +// Token usage corresponds to the currently active model +message TokenUsageInfo { + optional string model_name = 1; + optional int32 token_limit = 2; + optional int32 token_usage = 3; +} + +// existing GetMessagesResponse, not changed +message GetMessagesResponse { + repeated Message messages = 1 [(squareup.redacted) = true]; + optional string next_cursor = 2; + optional ChatSessionStatus status = 3; + optional string session_name = 4; + repeated Activity session_activities = 5; + optional TokenUsageInfo token_usage_info = 6; +} + +message GetMessagesStreamResponse { + oneof response { + GetMessagesResponse get_messages_response = 1; + DeltaMessageContent delta_message_content = 2; + } + + message DeltaMessageContent { + // the current ongoing streamed message id + optional string streaming_message_id = 1; + + // currently only text content is supported in delta + // we may stream other content types in the future + optional MessageContent message_content = 2; + + // indicates if this is the final part of the message content. + // NOTE: this is different from the final overall message in the response stream. + optional bool is_final = 3; + + // indicates if this is the start of a new message content. + optional bool is_start = 4; + } +} + +message CancelLastUserMessageRequest { + optional string session_id = 1; + optional OnBehalfOf on_behalf_of = 2; +} + +message CancelLastUserMessageResponse { + optional bool cancelled = 1; + optional string message = 2; + optional ChatSessionStatus session_status = 3; +} + +message WarmupCustomerContextRequest {} + +message WarmupCustomerContextResponse {} + +message ReplaceMessageSummaryRequest { + optional string session_id = 1; + optional OnBehalfOf on_behalf_of = 2; + optional string message_id = 3; + optional Message message = 4; + optional string idempotency_token = 5; +} + +message ReplaceMessageSummaryResponse { + optional bool success = 1; + optional string error_msg = 2; + optional Message message = 3; + optional Message original_message = 4; +} + +message ScriptConversionContext { + optional string tile_id = 1; +} + +message MoneybotContext { + enum TriggerSource { + TRIGGER_SOURCE_UNSPECIFIED = 0; + TRIGGER_SOURCE_DEFICIT_PREDICTED = 1; + TRIGGER_SOURCE_RECURRING_CHARGE = 2; + } + + // version of Moneybot to use, if it is provided, we use this specific version, otherwise we the version from the launchdarkly flag + optional int32 version = 1; + + // whether to extract the memory from this conversation for future use + optional bool should_extract_memory = 2; + + // whether the customer is a new customer for Moneybot usage + optional bool is_new_customer = 3; + + // Legal and compliance risk tags for the customer (e.g., "TOPIC_INVESTMENT_ADVICE", "TOPIC_CRYPTOCURRENCY") + // When this list is non-empty, the high-risk system preamble is used + repeated string legal_and_compliance_tags = 4; + + // The source session ID for memory extraction. Used to track which session the memories were extracted from. + // This is passed in MCP tool call headers (not in LLM context) when calling upsert_memories. + optional string source_session_id = 5; + + // Whether to retrieve and inject memories into the system preamble. + // If null, falls back to the moneybot-memory-injection Amplitude experiment. + optional bool enable_memory_retrieval = 6; + + // Client-provided memories to inject into the system preamble. + // If non-empty, these memories are used instead of fetching from the memory store. + repeated kgoosememorystore.api.v1beta1.Memory memories = 7; + + // When true, memory extraction runs but upsert_memories calls skip persistence. + // Used by moneybench for testing memory extraction without writing to the store. + optional bool memory_extraction_dry_run = 8; + + // Whether to enable freeform memory extraction (in addition to financial profile). + // If null, falls back to the ENABLE_FREEFORM_MEMORIES feature flag. + // When true, allows extraction of freeform memories even if the feature flag is disabled. + optional bool enable_freeform_memories = 9; + + // When true, skips LLM-based session name generation. + // Useful for reducing LLM costs and latency in automated/benchmark scenarios. + optional bool skip_session_name_generation = 10; + + // When true, skips LLM-based tooltip generation for tool requests. + // Useful for reducing LLM costs and latency in automated/benchmark scenarios. + optional bool skip_tooltip_generation = 11; + + // The trigger source that initiated this Moneybot conversation. + // Used to pass additional context (e.g., forecast data) when the trigger is DEFICIT_PREDICTED. + optional TriggerSource trigger_source = 12; + +} + +message DataAttribute { + optional string data = 1; + optional string description = 2; +} + +// Context for Managerbot task creation via /v3/create-task endpoint. +// These values are passed directly to avoid LLM hallucination. +message ManagerbotContext { + // Whether the task should immediately start executing after creation. + optional bool immediate_execution = 1; + + message EvergreenMetricsInfo { + optional string id = 1; + optional string description = 2; + map data_attributes = 3; + } + optional EvergreenMetricsInfo evergreen_metrics_info = 2; +} diff --git a/protos/squareup/cash/kgoose/api/v3/common_messages.proto b/protos/squareup/cash/kgoose/api/v3/common_messages.proto new file mode 100644 index 0000000..81a220f --- /dev/null +++ b/protos/squareup/cash/kgoose/api/v3/common_messages.proto @@ -0,0 +1,157 @@ +syntax = "proto2"; + +package squareup.cash.kgoose.api.v3; + +option java_package = "com.squareup.protos.cash.kgoose.api.v3"; + + +message OnBehalfOf { + optional string token = 1; + optional TokenType type = 2; +} + +enum TokenType { + TOKEN_TYPE_DO_NOT_USER = 0; + TOKEN_TYPE_CASH_CUSTOMER = 1; + TOKEN_TYPE_SQUARE_CUSTOMER = 2; + TOKEN_TYPE_BLOCK_INTERNAL = 3; + // Deprecated. Use Block Internal instead and differentiate traffic using ChatContext.Source + TOKEN_TYPE_CASH_ADVOCATE = 4; +} + +enum ModelProvider { + MODEL_PROVIDER_UNSPECIFIED = 0; + MODEL_PROVIDER_DATABRICKS = 1; + MODEL_PROVIDER_GROQ = 2; + MODEL_PROVIDER_CEREBRAS = 3; + MODEL_PROVIDER_OPENAI = 5; + MODEL_PROVIDER_GONDOLA = 6 [deprecated=true]; // Deprecated: Gondola provider is no longer supported + MODEL_PROVIDER_OPENROUTER = 7; + MODEL_PROVIDER_ANTHROPIC = 8; +} + +message Model { + optional ModelProvider provider = 1; + optional string name = 2; +} + +// Source in ChatContext +enum Source { + SOURCE_UNSPECIFIED = 0; + SOURCE_REGULAR_CHAT = 1; // regular chat + SOURCE_CREATE_TILE = 2; // Tile creation + SOURCE_TILE_FOLLOW_UP_CHAT = 3; // Tile follow up chat + SOURCE_TASK_FORM_SUBMISSION = 4; // Form submission for Task tiles + SOURCE_CREATE_TILE_FROM_TILE = 5; // Tile creation from shared tile + SOURCE_CREATE_AUTOMATION = 6; // [deprecated] Automation creation + SOURCE_AUTOMATION_FOLLOW_UP_CHAT = 7; // [deprecated] Automation follow up chat + SOURCE_EDIT_TILE = 8; // Dedicated tile editing + SOURCE_EDIT_AUTOMATION = 9; // [deprecated] Dedicated automation editing + SOURCE_MONEYBOT_CHAT = 10; // Moneybot regular chat + SOURCE_TILE_REFRESH = 11; // Tile refresh + SOURCE_LANGFUSE_EXPERIMENT = 12; // Langfuse experiment + SOURCE_CREATE_SPACE_APP = 13; // Space-app creation + SOURCE_INSIGHT_FOLLOW_UP_CHAT = 14; // Insight follow up chat + SOURCE_CASHBOT = 15; // Cash support bot + SOURCE_PATHFINDER = 16; // Pathfinder (gen-ai supportcenter) + SOURCE_CASH_ADVOCATE_COPILOT = 17; // Cash advocate copilot + SOURCE_SQUARE_ADVOCATE_COPILOT = 18; // Square advocate copilot + SOURCE_CREATE_TASK_PLAN = 19; + SOURCE_SCRIPT_CONVERSION = 20; // Script conversion (e.g., English to Lua) + SOURCE_MANAGERBOT_CHAT = 21; // Managerbot regular chat + SOURCE_MANAGERBOT_EVERGREEN_METRICS = 22; // Managerbot evergreen metrics + SOURCE_MANAGERBOT_EMAILS = 23; // Managerbot linked email chats + SOURCE_CASHAPP_BUG_REPORTING = 24; // Cash App bug reporting + SOURCE_SQUAREBOT = 25; // Square support bot + SOURCE_SALESBOT = 26; // Sales bot for external chat + SOURCE_CASH_ADVOCATE_BOT = 27; // Cash Advocate Bot + SOURCE_APP_SEC_REPORTER = 28; // AppSecReporter AI-powered bundle summary and remediation guidance + SOURCE_BUILDERLAB = 29; // BuilderLab + SOURCE_CUSTOMER_REENGAGEMENT = 30; // Customer reengagement workflow + SOURCE_SCHEDULED_TASK = 31 [deprecated = true]; // [deprecated] Scheduled task execution + SOURCE_RISK_DEMO = 32; // Risk demo + SOURCE_CLAUDIUS = 33; // Claudius + SOURCE_G2_APP = 34; // G2 Cloudflare-hosted app tool calls + SOURCE_MANAGERBOT_COACHMARK = 35; // Managerbot coachmark + SOURCE_MONEYBOT_DIRECT_EXECUTION = 36; // Moneybot direct tool execution from client (e.g., on-tap confirmation card) + SOURCE_MANAGERBOT_EDIT_WIDGET = 37; + SOURCE_KGOOSE_TOOL_CALL_ENDPOINTS_DEFAULT = 38; // Default source for programmatic tool calls via ToolEndpointService + SOURCE_MANAGERBOT_SMS = 39; // Managerbot SMS relay sessions + SOURCE_AFTERPAY_BOT = 40; // Afterpay support bot + SOURCE_GENIE = 41; // Genie server-driven UI generation + SOURCE_FILING_BROKER = 42; // Filing broker AI-powered investigation note generation + SOURCE_SQUARE_ADVOCATE_BOT = 43; // Square Advocate Bot + SOURCE_MANAGERBOT_DIRECT_EXECUTION = 44; // Managerbot direct tool execution from client + SOURCE_VERIFICATIONS_BOT = 45; // Verifications Bot + SOURCE_REGULATOR = 46; // Regulator compliance assistant + SOURCE_NEIGHBORHOODSBOT = 47; // Neighborhoodsbot — Neighborhoods worldview Slack bot + SOURCE_AM_HUB = 48; // Smart Hub AI for Square Account Managers + SOURCE_QUINN = 49; // Quinn conversational agent + SOURCE_SQ_AGENT_TOOLS = 50; // sq agent-tools CLI (squareup/sq-kgoose) +} + +enum ActionStatus { + ACTION_STATUS_UNSPECIFIED = 0; // Default value, should not be used. + ACTION_STATUS_SUCCESS = 1; // The action was completed successfully. + ACTION_STATUS_FAILED = 2; // The action failed, but the flow completed. +} + +message AccessLevelInfo { + + // todo(trudolf): this access_level has the space-sharing-mode and access-level convoluted + oneof access_level { + bool owner = 1; + bool view_only = 2; + AccessDenied access_denied = 3; + bool preview_only = 4; + bool subscriber = 5; + } + + message AccessDenied { + repeated AccessErrorInfo access_error_info = 1; + + message AccessErrorInfo { + optional string connection_name = 1; + oneof error { + bool connection_not_enabled = 2; + bool connection_prohibited_from_sharing = 3; + // bad naming. the acl-check was successful and returned the user does not have access + AclCheckFailed acl_check_failed = 4; + // acl check was not successful, e.g. encountered a server error or couldn't read the response + bool acl_check_error = 5; + } + + message AclCheckFailed { + repeated string object_ids = 1; + } + } + } +} + +message AdminCapabilities { + optional bool can_manage_tiles = 1; + optional bool can_approve_changes = 2; + optional bool can_manage_admins = 3; + optional bool can_manage_space = 4; +} + +// Represents the completion of a Square workflow. +message SquareWorkflowUpdate { + // Unique identifier for the originating workflow/tool request. + optional string tool_request_id = 1; + + // Customer identity token associated with the session. + optional string customer_token = 2; + + // Target session ID in which the message should be inserted. + optional string session_id = 3; + + // Natural language summary describing the outcome of the workflow. + optional string summary = 4; + + // Optional workflow completion status. + optional ActionStatus action_status = 5; + + // Optional error message if workflow failed. + optional string error_message = 6; +} diff --git a/protos/squareup/cash/kgoose/api/v3/extension_messages.proto b/protos/squareup/cash/kgoose/api/v3/extension_messages.proto new file mode 100644 index 0000000..014155d --- /dev/null +++ b/protos/squareup/cash/kgoose/api/v3/extension_messages.proto @@ -0,0 +1,63 @@ +syntax = "proto2"; + +package squareup.cash.kgoose.api.v3; + +option java_package = "com.squareup.protos.cash.kgoose.api.v3"; + +// Configuration details for any extensions or tools +message ExtensionConfig { + optional string name = 1; + + optional string instruction = 2; + + repeated ToolConfig tools = 3; + optional int32 version = 4; + + // Whether this extension should be auto-enabled + // When false, the extension requires the LLM to use platform__enable_Extension + // Defaults to TRUE for backward compatibility, all client_tools are currently auto_picked + optional bool auto_picked = 5 [default = true]; + + // For derived extensions (e.g., synthetic extensions created from extension_group metadata), + // this is the name of the parent extension that provides the MCP backend. + // Used for routing tool calls and OAuth checks. Null for root extensions. + optional string parent_extension = 6; +} + +message ToolConfig { + optional string tool = 1; + optional string description = 2; + /* JSON schema configuration for this tool. Must be a valid JSON schema object with at minimum: + - "type": "object" (required by JsonSchema.validate() in goosellm) + - "properties": {} (required by JsonSchema.validate() in goosellm) + + Example minimal valid schema: {"type": "object", "properties": {}}*/ + optional string config_json = 3; + /* MCP-compliant _meta field as JSON string. + See: https://modelcontextprotocol.io/specification/2025-11-25/basic/index#general-fields + + Used for tool-level metadata like "com.squareup.kgoose/client_input_pending" to indicate + that a tool should block for client input. */ + optional string meta_json = 4; + + /* Indicates whether this tool performs mutation actions (e.g., creating, updating, or deleting data) + as opposed to read-only actions. Extracted from the tool's _meta field using the key + "com.squareup.kgoose/mutates_state". Defaults to false if not specified. */ + optional bool mutates_state = 5; + + // Deprecated: use ui_visibility in meta_json instead. Kept for proto compatibility. + optional bool direct_execution_only = 6 [deprecated = true]; +} + +// Overrides for tool descriptions in backend extensions. +message ToolDescriptionOverride { + optional string tool_name = 1; + optional string description = 2; +} + +// Overrides for extension instructions and tool descriptions. +message ExtensionDescriptionOverride { + optional string extension_name = 1; + optional string instruction = 2; + repeated ToolDescriptionOverride tool_overrides = 3; +} diff --git a/protos/squareup/cash/kgoose/api/v3/extension_selection_config.proto b/protos/squareup/cash/kgoose/api/v3/extension_selection_config.proto new file mode 100644 index 0000000..ea69ece --- /dev/null +++ b/protos/squareup/cash/kgoose/api/v3/extension_selection_config.proto @@ -0,0 +1,55 @@ +syntax = "proto2"; + +package squareup.cash.kgoose.api.v3; + +option java_package = "com.squareup.protos.cash.kgoose.api.v3"; + +import "squareup/cash/kgoose/api/v3/extension_messages.proto"; +import "squareup/cash/kgoose/api/v3/profile_messages.proto"; + +// Controls which backend extensions are available to the agent. +enum BackendExtensionAvailability { + // All registered extensions for the deployment are available. + // `allowed_backend_extensions` entries are only used for auto_picked settings + // (tool_names is ignored since all tools are available). + BACKEND_EXTENSION_AVAILABILITY_ALL = 0; + // Only extensions listed in `allowed_backend_extensions` are available + // (plus baseline extensions like platform/utils). + // Entries control availability, auto_picked, and tool_names filtering. + BACKEND_EXTENSION_AVAILABILITY_ALLOWLIST = 1; + // No backend extensions are used at all. + // `allowed_backend_extensions` must be empty. + BACKEND_EXTENSION_AVAILABILITY_NONE = 2; +} + +// Configuration that controls which extensions are available to an agent and how they are selected. +// Used by ExtensionRouting to determine the final set of extensions for an LLM call. +// +// This is the single source of truth for extension selection policy within an AgentConfig. +// Runtime context (chat source, tenancy, feature flags, message history) remains outside this config +// and is applied as an overlay by ExtensionRouting. +message ExtensionSelectionConfig { + // Controls which backend extensions are available. + // See BackendExtensionAvailability enum for details on each mode. + optional BackendExtensionAvailability backend_extension_availability = 1; + + // Backend extension configuration. Interpretation depends on `backend_extension_availability`. + repeated ExtensionTools allowed_backend_extensions = 2; + + // Denylist of backend extensions and their tools. Applied after selection to + // remove specific extensions or tools. Empty tool_names means the entire + // extension is disabled. + // + // If a tool appears in both allowed and disabled lists, disabled wins. + repeated ExtensionTools disabled_backend_extensions = 3; + + // Whether the LLM can dynamically enable extensions via the + // platform__enable_extensions tool. When false, all available extensions + // are enabled immediately. Defaults to true when absent. + optional bool enable_tool_picker = 4; + + // Client-supplied extension definitions (tools handled by the client, not + // by backend MCP servers). Each entry is a full ExtensionConfig with tools, + // instructions, and auto_picked settings. + repeated ExtensionConfig client_extensions = 5; +} diff --git a/protos/squareup/cash/kgoose/api/v3/profile_messages.proto b/protos/squareup/cash/kgoose/api/v3/profile_messages.proto new file mode 100644 index 0000000..d51c597 --- /dev/null +++ b/protos/squareup/cash/kgoose/api/v3/profile_messages.proto @@ -0,0 +1,152 @@ +syntax = "proto2"; + +package squareup.cash.kgoose.api.v3; + +option java_package = "com.squareup.protos.cash.kgoose.api.v3"; + +import "squareup/cash/kgoose/api/v3/common_messages.proto"; +import "squareup/cash/kgoose/api/v3/extension_messages.proto"; +import "squareup/common/pii.proto"; +import "google/protobuf/struct.proto"; + +message CreateProfileRequest { + // The description of the profile. + optional string description = 1; + + optional ProfileConfig profile_config = 2; +} + +message CreateProfileResponse { + optional Profile profile = 1; +} + +message GetProfileRequest { + // Profile ID to fetch + required string id = 1; +} + +message GetProfileResponse { + optional Profile profile = 1; +} + +message UpdateProfileRequest { + // Profile ID to update + required string id = 1; + + // The description of the profile. + optional string description = 2; + + repeated ExtensionTools add_preferred_backend_tools = 3; + + repeated ExtensionTools remove_preferred_backend_tools = 4; + + optional string update_system_preamble = 5; +} + +message UpdateProfileResponse { + optional Profile profile = 1; +} + +message DeleteProfileRequest { + // Profile ID to delete + required string id = 1; +} + +message DeleteProfileResponse {} + +message ExtensionTools { + required string extension_name = 1; + repeated string tool_names = 2; // if tool_names are empty, the whole extension is affected + optional bool auto_picked = 3; // Whether the extension should be auto-picked. Defaults to false. +} + +message PromptConfigurationSettings { + // Appends sentences describing and reinforcing the current date. + optional bool include_current_date = 1; + + // Appends sentences describing and reinforcing the current hour of the day, in UTC. + optional bool include_current_hour = 2; + + // Appends sentences describing the timezone in IANA Time Zone Database time zone, e.g. "America/New_York". + optional bool include_time_zone = 3; + + // Appends a static sentence that describes how Goose uses models and tools (legacy) + optional bool include_goose_model_description = 4; + + // Appends a default response guideline to return content in Markdown + optional bool include_response_guidelines = 5; + + // When true, repeats the date/time information at the end of the prompt for emphasis. Defaults to true. + optional bool include_time_emphasis = 6; +} + +message ServiceProfileConfig { + // This system prompt gets prepended to the core goose system prompt. + optional string system_preamble = 1; + + // The allowlist of backend extensions. Tool-level configuration is not supported. + // If not specified, all possible extensions are considered + repeated ExtensionTools preferred_backend_tools = 2; + + // The list of frontend/client tools to be used. + repeated ExtensionConfig client_tools = 3; + + // The preferred model to be used. + optional Model preferred_model = 4; + + // Extra args to pass to the provider completion + optional google.protobuf.Struct extra_args = 5; + + // If true, kgoose will not use any backend tools. + optional bool no_backend_tools = 6; + + // The list of backend tools to disable. + repeated ExtensionTools disabled_backend_tools = 7; + + // Whether to enable tool picker feature to selectively enable extensions + optional bool enable_tool_picker = 8; + + // The system prompt, with ONLY the required extension description addition + optional string system_prompt = 9; + + // Configuration for prompt construction settings + // for backwards compatibility support, if not supplied, then all options are treated as true + optional PromptConfigurationSettings prompt_configuration_settings = 10; + + // Overrides for backend extension instructions and tool descriptions. + repeated ExtensionDescriptionOverride backend_extension_overrides = 11; +} + +// Contains the subset of ServiceProfileConfig that can should be able to configured by a +// a user +message UserProfileConfig { + // The list of frontend/client tools to be used. + repeated ExtensionConfig client_tools = 1; + + // Extra args to pass to the provider completion + optional google.protobuf.Struct extra_args = 2; + + // The preferred model to be used. + optional Model preferred_model = 3; +} + +message ProfileConfig { + oneof profile_config { + ServiceProfileConfig service_profile = 1 [(squareup.redacted) = true]; + UserProfileConfig user_profile = 2 [(squareup.redacted) = true]; + } +} + +message Profile { + // Profile ID + optional string id = 1; + + // The description of the profile. + optional string description = 2; + + // Profile timestamp in EpochMilli + optional int64 created_at = 3; + optional int64 updated_at = 4; + + optional ProfileConfig profile_config = 5; +} diff --git a/protos/squareup/cash/kgoose/api/v3/tool_endpoint_messages.proto b/protos/squareup/cash/kgoose/api/v3/tool_endpoint_messages.proto new file mode 100644 index 0000000..1a37c28 --- /dev/null +++ b/protos/squareup/cash/kgoose/api/v3/tool_endpoint_messages.proto @@ -0,0 +1,121 @@ +syntax = "proto2"; + +package squareup.cash.kgoose.api.v3; + +option java_package = "com.squareup.protos.cash.kgoose.api.v3"; + +import "squareup/cash/kgoose/api/v3/common_messages.proto"; +import "squareup/cash/kgoose/api/v3/extension_messages.proto"; +import "squareup/cash/kgoose/api/v3/chat_messages.proto"; + +// ── ListExtensions ── + +message ListExtensionsRequest {} + +message ExtensionInfo { + // Name identifier of the extension + optional string name = 1; + // Human-readable description of the extension + optional string description = 2; + // DEPRECATED: use auth_satisfied_for_caller instead. + // True if the caller currently lacks the credentials needed to invoke this extension. + // Despite the name, this also covers service-credential gating for service callers, not only OAuth. + // Reflects current caller auth state, not the extension's intrinsic requirements — once OAuth is + // connected, this flips to false. + optional bool requires_oauth = 3 [deprecated = true]; + // DEPRECATED: use auth_satisfied_for_caller instead. + // The name is misleading — this is true for any + // extension the caller currently has access to, including extensions with a static_bearer_token, + // extensions that are not auth-gated at all, and service callers with a configured service credential. + // It is the exact inverse of requires_oauth. + optional bool oauth_connected = 4 [deprecated = true]; + // Number of tools provided by this extension + optional int32 tool_count = 5; + // Whether any tool of this extension requires a real user identity (i.e. cannot be executed by a service principal + // without a permission lease). Mirrors the `com.squareup.kgoose/requires_user_auth` tool _meta flag. + optional bool any_tool_requires_user_auth = 6; + + // True if the caller currently has the credentials needed to invoke this extension's tools — covering + // OAuth being connected, a configured service credential for service callers, a static_bearer_token, + // or no auth being required at all. Reflects current caller state, not an intrinsic property of the + // extension. + optional bool auth_satisfied_for_caller = 7; +} + +message ListExtensionsResponse { + repeated ExtensionInfo extensions = 1; +} + +// ── ListTools ── + +message ListToolsRequest { + // Name of the extension to list tools for + optional string extension_name = 1; +} + +message ListToolsResponse { + // Name of the extension these tools belong to + optional string extension_name = 1; + // Description/instruction for the extension + optional string extension_description = 2; + // Tools available in this extension (reuses ToolConfig from extension_messages.proto) + repeated ToolConfig tools = 3; +} + +// ── CallTool ── + +message CallToolRequest { + // Name of the extension containing the tool + optional string extension_name = 1; + // Name of the tool to call + optional string tool_name = 2; + // JSON-encoded arguments for the tool + optional string arguments_json = 3; + // Optional headers to forward to the extension + map headers = 4; + // Optional chat source for metrics and extension client resolution. Defaults to SOURCE_BUILDERLAB. + optional Source source = 5; + // Optional tenancy for metrics. Defaults to TENANCY_DEFAULT. + optional Tenancy tenancy = 6; +} + +message CallToolResponse { + // Content returned from the tool call (reuses UserContent from chat_messages.proto) + repeated UserContent content = 1; + // Whether the tool call resulted in an error + optional bool is_error = 2; + // JSON-encoded structured content from the tool + optional string structured_content_json = 3; +} + +// ── ExecuteTool (Cash App client direct execution) ── + +message ExecuteToolRequest { + // Name of the extension containing the tool (e.g., "savings-piggybank") + optional string extension_name = 1; + // Name of the tool to execute (e.g., "transfer_to_savings") + optional string tool_name = 2; + // JSON-encoded arguments for the tool + optional string arguments_json = 3; + // Chat session ID to anchor activity/card updates to + optional string session_id = 4; + // Tool request ID from the preview card, used to update the correct card in chat + optional string tool_request_id = 5; +} + +message ExecuteToolSuccess { + optional string client_route_url = 1; + // Content returned from the tool call (reuses UserContent from chat_messages.proto) + repeated UserContent content = 2; +} + +message ExecuteToolError { + optional string message = 1; +} + +message ExecuteToolResponse { + oneof result { + ExecuteToolSuccess success = 1; + ExecuteToolError error = 2; + } +} diff --git a/protos/squareup/cash/kgoose/api/v3/tool_endpoint_service.proto b/protos/squareup/cash/kgoose/api/v3/tool_endpoint_service.proto new file mode 100644 index 0000000..8e31e32 --- /dev/null +++ b/protos/squareup/cash/kgoose/api/v3/tool_endpoint_service.proto @@ -0,0 +1,29 @@ +syntax = "proto2"; + +package squareup.cash.kgoose.api.v3; + +option java_package = "com.squareup.protos.cash.kgoose.api.v3"; + +import "google/api/annotations.proto"; +import "squareup/cash/kgoose/api/v3/tool_endpoint_messages.proto"; + +// Service for programmatic access to kgoose extensions and tools. +// Enables CLI and other clients to discover and invoke tools dynamically. +service ToolEndpointService { + // List all available extensions and their OAuth status for the calling user + rpc ListExtensions(ListExtensionsRequest) returns (ListExtensionsResponse); + + // List all tools available in a specific extension + rpc ListTools(ListToolsRequest) returns (ListToolsResponse); + + // Invoke a specific tool in an extension with the given arguments + rpc CallTool(CallToolRequest) returns (CallToolResponse); + + // Execute a tool directly from the Cash App client (e.g., on-tap confirmation card). + // Uses Cash App auth, writes chat activity for card updates, and extracts client_renderable. + rpc ExecuteTool(ExecuteToolRequest) returns (ExecuteToolResponse) { + option (google.api.http) = { + post: "/cash-app/goose-cash/v3/execute-tool" + }; + } +} diff --git a/protos/squareup/cash/kgoosememorystore/api/v1beta1/memory.proto b/protos/squareup/cash/kgoosememorystore/api/v1beta1/memory.proto new file mode 100644 index 0000000..7901ba7 --- /dev/null +++ b/protos/squareup/cash/kgoosememorystore/api/v1beta1/memory.proto @@ -0,0 +1,261 @@ +syntax = "proto2"; +// package suffix v*beta* means these protos are not checked for backwards compatibility. +// When the interface is ready to be consumed, rename the package to v1, and the backwards +// compatibility checks will ensure there are no breaking changes. +// For more information, see: https://github.com/squareup/cash-proto-tools#backwards-compatibility-check +package squareup.cash.kgoosememorystore.api.v1beta1; + +option java_package = "com.squareup.protos.cash.kgoosememorystore.api.v1"; + +// Import the standard timestamp definition +import "google/protobuf/timestamp.proto"; + +/** A memory item extracted from a conversation. */ +message Memory { + // Unique identifier for this memory + optional string id = 1; + + // The creator (customer token) this memory belongs to + optional string creator = 2; + + // The type of memory + optional MemoryType type = 3; + + // Text content/description for freeform memories or nuance for structured facts + optional string content = 4; + + // Confidence score from 0.0 to 1.0 indicating how explicit the information was + optional float confidence = 5; + + // The session ID(s) where this memory was extracted from + repeated string source_session_ids = 6; + + // Relevant tags/topics assigned to the memory + repeated MemoryTopicTag topic_tags = 9; + + // The session ID(s) where memory extraction was triggered + repeated string memory_extraction_session_ids = 10; + + // When this memory was created + optional google.protobuf.Timestamp created_at = 7; + + // When this memory was updated + optional google.protobuf.Timestamp updated_at = 8; + + // Lifecycle status of the memory + optional MemoryStatus status = 11; + + // IDs of memories this memory replaces (if any) + repeated string replaces_ids = 12; + + // ID of the memory that replaced this one (if applicable) + optional string replaced_by_id = 13; + + // Origin of the memory's creation + optional MemoryOrigin origin = 14; + + // Machine-readable identifier for core Financial Profile fields + optional FinancialKey financial_key = 15; + + // Numeric value if available for financial profile fields + optional double amount = 16; + + // Optional hard TTL for time-bound facts (e.g., "I'll be in NYC until March 15"). + // Memory auto-transitions to EXPIRED after this date. + optional google.protobuf.Timestamp expires_at = 17; + + // Resets when memory extraction detects a conversation that corroborates an existing memory, + // or when external data confirms the fact still holds. Distinct from updated_at which fires + // on any edit. Injected so the model can distinguish recently confirmed facts from stale ones. + optional google.protobuf.Timestamp last_reinforced_at = 18; + + // Increments each time memory extraction detects corroboration (or external confirmation). + // A memory reinforced 12 times carries more weight than one reinforced once, even at the same + // last_reinforced_at. + optional uint32 reinforcement_count = 19; + + // Distinguishes CURRENT vs PLANNED vs HISTORICAL vs RECURRING. + optional TemporalQualifier temporal_qualifier = 20; + + // next tag: 21 +} + +/** The input parameters needed to create or update a Memory. */ +message InputMemory { + // Unique identifier for this memory + optional string id = 1; + + // The creator (customer token) this memory belongs to + optional string creator = 2; + + // The type of memory + optional MemoryType type = 3; + + // Text content/description for freeform memories or nuance for structured facts + optional string content = 4; + + // Confidence score from 0.0 to 1.0 indicating how explicit the information was + optional float confidence = 5; + + // The session ID(s) where this memory was extracted from + repeated string source_session_ids = 6; + + // Relevant tags/topics assigned to the memory + repeated MemoryTopicTag topic_tags = 7; + + // The session ID(s) where memory extraction was triggered + repeated string memory_extraction_session_ids = 8; + + // Lifecycle status of the memory + optional MemoryStatus status = 9; + + // IDs of memories this input memory replaces + repeated string replaces_ids = 10; + + // ID of the memory that replaced this one + optional string replaced_by_id = 11; + + // Origin of the memory's creation + optional MemoryOrigin origin = 12; + + // Machine-readable identifier for core Financial Profile fields + optional FinancialKey financial_key = 13; + + // Numeric value if available for financial profile fields + optional double amount = 14; + + // Optional hard TTL for time-bound facts. + optional google.protobuf.Timestamp expires_at = 15; + + // See Memory.last_reinforced_at. + optional google.protobuf.Timestamp last_reinforced_at = 16; + + // See Memory.reinforcement_count. + optional uint32 reinforcement_count = 17; + + // See Memory.temporal_qualifier. + optional TemporalQualifier temporal_qualifier = 18; + + // next tag: 19 +} + +enum MemoryType { + // Protobuf enums must start with a 0 value, usually reserved for 'unspecified'. + MEMORY_TYPE_UNSPECIFIED = 0; + + // User likes/dislikes, communication style preferences + MEMORY_TYPE_USER_PREFERENCE = 1; + + // Facts about the user (location, business details, etc.) + MEMORY_TYPE_FACTUAL_INFO = 2; + + // High-level summary of conversation + MEMORY_TYPE_CONVERSATION_SUMMARY = 3; + + // Repeated requests, common workflows + MEMORY_TYPE_BEHAVIORAL_PATTERN = 4; + + // User intentions and future goals using Moneybot/Cash App + MEMORY_TYPE_OPEN_LOOP = 5; +} + +/** Enum representing structured memory topics. */ +enum MemoryTopicTag { + MEMORY_TOPIC_TAG_UNSPECIFIED = 0; + + // Living situation, family context, or personal background that affects decisions + MEMORY_TOPIC_TAG_HOUSEHOLD_AND_CONTEXT = 1; + + // Stated objectives, milestones, or time-bound plans the user is working toward + MEMORY_TOPIC_TAG_GOALS_AND_TIMELINES = 2; + + // Comfort level with uncertainty, tradeoffs, or potential downside + MEMORY_TOPIC_TAG_RISK_TOLERANCE = 3; + + // Spending habits, budgeting behavior, or cashflow-related patterns + MEMORY_TOPIC_TAG_CASHFLOW_AND_SPENDING = 4; + + // How the user prefers information to be communicated or delivered + MEMORY_TOPIC_TAG_COMMUNICATION_PREFERENCES = 5; + + // Longer-term planning related to finances, savings, or financial strategy + MEMORY_TOPIC_TAG_FINANCIAL_PLANNING = 6; + + // Relevant persistent information that does not clearly fit another topic + MEMORY_TOPIC_TAG_OTHER = 7; +} + +enum MemoryStatus { + MEMORY_STATUS_UNSPECIFIED = 0; + + MEMORY_STATUS_ACTIVE = 1; + + MEMORY_STATUS_REPLACED = 2; + + // Memory is no longer valid due to expires_at passing. + MEMORY_STATUS_EXPIRED = 3; +} + +enum MemoryOrigin { + MEMORY_ORIGIN_UNSPECIFIED = 0; + + // Extracted by kgoose from conversation via async memory extraction flow + MEMORY_ORIGIN_SESSION_EXTRACTION = 1; + + // MCP upsert tool was called by LLM + MEMORY_ORIGIN_MCP = 2; + + // Memory was manually added/edited by employee in toolbox + MEMORY_ORIGIN_TOOLBOX = 3; + + // Memory was manually added by user in UI + MEMORY_ORIGIN_USER_EDIT = 4; +} + +enum TemporalQualifier { + TEMPORAL_QUALIFIER_UNSPECIFIED = 0; + TEMPORAL_QUALIFIER_CURRENT = 1; + TEMPORAL_QUALIFIER_PLANNED = 2; + TEMPORAL_QUALIFIER_HISTORICAL = 3; + TEMPORAL_QUALIFIER_RECURRING = 4; +} + +/** Financial Fields from +* https://docs.google.com/document/d/1YstNHxM4ErKx_u0B1bczukp64HGNMA8UquObMtEO-DI/edit?tab=t.px3kai2le2bk#heading=h.sjkwvcd3rsyc +**/ +enum FinancialKey { + FINANCIAL_KEY_UNSPECIFIED = 0; + + // Identity Context + FINANCIAL_KEY_HOUSEHOLD_SIZE = 1; + + // Income Reality + FINANCIAL_KEY_MONTHLY_INCOME = 2; + FINANCIAL_KEY_INCOME_STABILITY = 3; // High-level classification + + // Spending Patterns + FINANCIAL_KEY_MONTHLY_EXPENSES = 4; + FINANCIAL_KEY_HOUSING_PAYMENT = 5; // Rent or Mortgage + + // Liquidity Position + FINANCIAL_KEY_CHECKING_BALANCE = 6; + FINANCIAL_KEY_SAVINGS_BALANCE = 7; + + // Long-Term Position + FINANCIAL_KEY_RETIREMENT_BALANCE = 8; + FINANCIAL_KEY_BROKERAGE_BALANCE = 9; + + // Debt Reality + FINANCIAL_KEY_CREDIT_CARD_DEBT = 10; + FINANCIAL_KEY_STUDENT_LOAN_DEBT = 11; + FINANCIAL_KEY_AUTO_LOAN_DEBT = 12; + FINANCIAL_KEY_MORTGAGE_DEBT = 13; + + // Intent + FINANCIAL_KEY_PRIMARY_GOAL = 14; + + FINANCIAL_KEY_RISK_TOLERANCE = 15; + FINANCIAL_KEY_EMPLOYMENT_STATUS = 16; + FINANCIAL_KEY_NUMBER_DEPENDENTS = 17; + FINANCIAL_KEY_EMPLOYMENT_TYPE = 18; +} \ No newline at end of file diff --git a/protos/squareup/common/governance/v0/common.proto b/protos/squareup/common/governance/v0/common.proto new file mode 100644 index 0000000..dc9566f --- /dev/null +++ b/protos/squareup/common/governance/v0/common.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +package squareup.governance.v0; + +option java_package = "com.squareup.protos.common.governance.v0"; + +message Common { + enum SemanticType { + UNKNOWN = 0; + NOT_IN_RUBRICS = 1; + } +} diff --git a/protos/squareup/common/governance/v0/consumer_personal_data.proto b/protos/squareup/common/governance/v0/consumer_personal_data.proto new file mode 100644 index 0000000..f45eb9b --- /dev/null +++ b/protos/squareup/common/governance/v0/consumer_personal_data.proto @@ -0,0 +1,137 @@ +syntax = "proto2"; +package squareup.governance.v0; + +// DO NOT EDIT!!! +// This file was auto-generated from https://github.com/squareup/dsl-framework/blob/ac75c6fa1371fe4950a4ec1b20e9b952d6265e91/rubrics/releases/for-dsl-engine/rubrics_v0.json using /script/EngineModelGenerator.py. + +option java_package = "com.squareup.protos.common.governance.v0"; + +message ConsumerPersonalData { + enum SemanticType { + DO_NOT_USE = 0; + // Facial images used for identity verification, such as on a photo of a Government ID document. + GOV_ID_FACIAL_IMAGE = 1; + // Facial images not used for identity verification, such as photographs or videos used in other contexts. + GENERIC_FACIAL_IMAGE = 2; + // Handwriting, including a handwritten signature, such as on a photo of a Government ID document. + HANDWRITING = 3; + // A person's full name. + FULL_NAME = 4; + // Personal email address (including hashed email address). + EMAIL_ADDRESS = 5; + // Residential postal address. + RESIDENTIAL_ADDRESS = 6; + // Personal phone number (including hashed phone number): seven (7) digits or more. + PHONE_NUMBER = 7; + // Username, $Cashtag, social media handle. + USERNAME = 8; + // Bank account number (with more than defined by COARSE_BANK_ACCOUNT_NUMBER). + BANK_ACCOUNT_NUMBER = 9; + // Payment Card Primary Account Number (PAN) (more than defined by COARSE_PAYMENT_CARD_PAN). + PAYMENT_CARD_PAN = 10; + // Social Security Number (SSN) + SSN = 11; + // Passport Number + PASSPORT_NUMBER = 12; + // Driver's License Number + DRIVERS_LICENSE_NUMBER = 13; + // Individual Tax ID Number (ITIN) + ITIN = 14; + // Authentication tokens and session tokens. + AUTH_TOKEN = 15; + // Consumer's password/passcode. + PASSWORD = 16; + // Plaid tokens and similar bank account access credentials. + BANK_ACCESS_TOKEN = 17; + // A first name of a person + FIRST_NAME = 18; + // A middle name or middle initial of a person + MIDDLE_NAME = 19; + // A last name of a person (surname) + LAST_NAME = 20; + // The street name and street number of a consumer's residence, including any additional information about unit/apt/suite/etc. + STREET_OF_RESIDENCE = 21; + // Residential ZIP/postal code + POSTAL_CODE_OF_RESIDENCE = 22; + // City of residence + CITY_OF_RESIDENCE = 23; + // State/Region/Province/County of residence + REGION_OF_RESIDENCE = 24; + // Country of residence (e.g. Customer Region/Country Code). + COUNTRY_OF_RESIDENCE = 25; + // Personal phone number: up to six (6) digits + COARSE_PHONE_NUMBER = 26; + // Device IDs (including Advertising ID and other unique identifiers that corresponds with an individual's mobile device). + DEVICE_ID = 27; + // Customer ID. + CUSTOMER_ID = 28; + // Gender. + GENDER = 29; + // Precise Date of Birth (DOB). + DATE_OF_BIRTH = 30; + // The day of the month that a customer was born. + BIRTH_DAY_OF_MONTH = 31; + // The month a customer was born. + BIRTH_MONTH = 32; + // The year a customer was born. + BIRTH_YEAR = 33; + // The consumer's age. + AGE = 34; + // Information pertaining to an individual's religious beliefs, philosophical beliefs, or union membership. + BELIEFS_OR_POLITICS = 35; + // Information pertaining to an individual's health, sex life, or sexual orientation. + HEALTH_OR_SEXUALITY = 36; + // Information about an individual's racial or ethnic demographic. + ETHNICITY_OR_RACE = 37; + // Inferences. + INFERENCE = 38; + // Touch events on mobile devices. NOTE: individual touch events are quasi-identifiers and should be regarded as pseudonymous, de-identified data by themselves. Sequences of touch events should be considered as equivalent to the data they represent (e.g., touch event sequences for customer identifiers or authentication efforts should be regarded as identifiable and possibly sensitive). + TOUCH_EVENTS = 39; + // Bank name. + BANK_NAME = 40; + // Bank routing number. + BANK_ROUTING_NUMBER = 41; + // Bank account number (coarse): maximum of four (4) digits + COARSE_BANK_ACCOUNT_NUMBER = 42; + // Coarse Credit Card Primary Account Number (PAN): (a) Last four digits (b) Six digit BIN + last four digits (c) Eight digit BIN + any other two digits. + COARSE_PAYMENT_CARD_PAN = 43; + // Date of Transaction (e.g. a bank deposit, a purchase, etc.) + TRANSACTION_DATE = 44; + // Amount of Transaction (e.g. a bank deposit, a purchase, etc.) + TRANSACTION_AMOUNT = 45; + // Sender/Recipient/Merchant of Transaction (e.g. a bank deposit, a purchase, etc.) + TRANSACTION_PARTY = 46; + // Credit scores (e.g., FICO). + CREDIT_SCORE = 47; + // Last four (4) digits of Social Security Number (SSN). + COARSE_SSN = 48; + // Geolocation (precise): location data with a resolution the same or greater than latitude and longitude with three or more decimal places. + PRECISE_GEOLOCATION = 49; + // Geolocation (coarse): location data with a resolution the same or less than latitude and longitude with two decimal places. + COARSE_GEOLOCATION = 50; + // IP Address. + IP_ADDRESS = 51; + // Free-form text fields and similar user-supplied application content (such as payment notes, email messages, text messages, voice transcripts, etc.), or customer support data generated by the user. + USER_GENERATED_TEXT = 52; + // Tokens used to identify a consumer, such as pseudonyms for Contact information (e.g. a Cash CustomerToken/ or a Fidelius SSN/ITIN token). + PERSON_PSEUDONYM = 53; + // Tokens used to identify a specific financial instrument (e.g. a Fidelius PAN token or bank account token). + INSTRUMENT_PSEUDONYM = 54; + // Tokens used to identify a particular device, such as a customer's phone (e.g. Cash AppToken). + DEVICE_PSEUDONYM = 55; + // Such as a list of contacts in the user's phone, address book, or social graph (see the Contact Information (Precise) category). + CONTACTS = 56; + // Information relating to an individual's income, such as salary, commission, investment income, etc. + INCOME = 57; + // Information relating to an individual's expenses, such as mortgage/rental payments, monthly utilities, debt payments, etc. + EXPENSES = 58; + // Information relating to an individual's assets, such as details on savings accounts, retirement funds, stocks, bonds, and investments. + ASSETS = 59; + // Underwriting decisions. + UNDERWRITING = 60; + // Advertising data, such as information about the advertisements the user has seen. + ADVERTISING_DATA = 61; + // Product Interaction, such as app launches, link/button presses, scrolling information or other information about how the user interacts with the app. (NOTE: If the data contains raw data about the user's touch inputs on mobile, the data will be more sensitive, see TOUCH_EVENTS). + PRODUCT_INTERACTION = 62; + } +} diff --git a/protos/squareup/common/governance/v0/employee_personal_data.proto b/protos/squareup/common/governance/v0/employee_personal_data.proto new file mode 100644 index 0000000..8472770 --- /dev/null +++ b/protos/squareup/common/governance/v0/employee_personal_data.proto @@ -0,0 +1,137 @@ +syntax = "proto2"; +package squareup.governance.v0; + +// DO NOT EDIT!!! +// This file was auto-generated from https://github.com/squareup/dsl-framework/blob/ac75c6fa1371fe4950a4ec1b20e9b952d6265e91/rubrics/releases/for-dsl-engine/rubrics_v0.json using /script/EngineModelGenerator.py. + +option java_package = "com.squareup.protos.common.governance.v0"; + +message EmployeePersonalData { + enum SemanticType { + DO_NOT_USE = 0; + // Facial images used for identity verification, such as on a photo of a Government ID document. Note: This does not include facial images used on company ID badges or for employee profiles on Block systems, e.g. org charts, or photographs and videos used in other contexts. (See GENERIC_FACIAL_IMAGE instead.) + GOV_ID_FACIAL_IMAGE = 1; + // Fingerprints. + FINGERPRINTS = 2; + // Handwriting, including a handwritten signature, such as on a photo of a Government ID document. + HANDWRITING = 3; + // Personal email address (including hashed email address) + PERSONAL_EMAIL_ADDRESS = 4; + // Residential postal address. + RESIDENTIAL_ADDRESS = 5; + // Personal phone number (including hashed phone number). + PERSONAL_PHONE_NUMBER = 6; + // Usernames or social media handles. + PERSONAL_USERNAME = 7; + // Bank account number. + BANK_ACCOUNT_NUMBER = 8; + // Immigration related identification numbers including visa case numbers, Permanent Resident Card (Green Card) numbers, etc. + IMMIGRATION_ID_NUMBER = 9; + // Social Security Number (SSN). + SSN = 10; + // Passport Number. + PASSPORT_NUMBER = 11; + // Driver's License Number. + DRIVERS_LICENSE_NUMBER = 12; + // Individual Tax ID Number (ITIN). + ITIN = 13; + // Facial images used on company ID badges or for employee profiles on Block systems (e.g. org charts). + GENERIC_FACIAL_IMAGE = 14; + // Personal name (first name, last name, middle name, full name, etc.). + NAME = 15; + // Work email address. + WORK_EMAIL_ADDRESS = 16; + // Work phone number. + WORK_PHONE_NUMBER = 17; + // Username (for access to Block systems). + WORK_USERNAME = 18; + // Employee ID. + EMPLOYEE_ID = 19; + // Authentication tokens and session tokens such as Block SSO tokens. + AUTH_TOKEN = 20; + // Employee password (e.g. employee password, employee/job candidate PIN). + PASSWORD = 21; + // The street name and street number of an employee's residence, including any additional information about unit/apt/suite/etc. + STREET_OF_RESIDENCE = 22; + // Residential ZIP/postal code + POSTAL_CODE_OF_RESIDENCE = 23; + // City of residence + CITY_OF_RESIDENCE = 24; + // State/Region/Province/County of residence + REGION_OF_RESIDENCE = 25; + // Country of residence (e.g. Customer Region/Country Code). + COUNTRY_OF_RESIDENCE = 26; + // Device IDs (including unique identifiers that correspond with an individual's mobile device or computer). + DEVICE_ID = 27; + // Gender. + GENDER = 28; + // Precise Date of Birth (DOB). + DATE_OF_BIRTH = 29; + // The day of the month that an employee was born. + BIRTH_DAY_OF_MONTH = 30; + // The month an employee was born. + BIRTH_MONTH = 31; + // The year an employee was born. + BIRTH_YEAR = 32; + // An employee's age. + AGE = 33; + // Citizenship. + CITIZENSHIP = 34; + // Job Title. + JOB_TITLE = 35; + // Business Unit/Division/Department. + ORG_PLACEMENT = 36; + // Physical Work Location. + WORK_LOCATION = 37; + // Hire Date/Termination Date. + EMPLOYMENT_DATES = 38; + // Details of dependents. + DEPENDENTS = 39; + // Emergency Contact Details. + EMERGENCY_CONTACT = 40; + // Education History including licenses, certifications, and qualifications. + EDUCATION = 41; + // Employement History. + EMPLOYMENT_HISTORY = 42; + // Job candidate support data, such as: Replies to job opening, feedback sent to applicants, interview records, references and referrals. + CANDIDATE_DATA = 43; + // Medical Information (physical or mental health or condition). + MEDICAL_INFO = 44; + // Criminal History. + CRIMINAL_HISTORY = 45; + // Any commissioned offense, civil proceeding or sentencing. + LEGAL_HISTORY = 46; + // Information pertaining to background screens including OFAC screens. + BACKGROUND_CHECK = 47; + // Job Performance Information. + JOB_PERFORMANCE = 48; + // Block-sponsored or mandated Training and Development History. + LEARNING_AND_DEVELOPMENT = 49; + // Involvement in extracurricular activities including such as running clubs or Block communities. + EXTRACURRICULARS = 50; + // Bank name. + BANK_NAME = 51; + // Bank routing number. + BANK_ROUTING_NUMBER = 52; + // Bank account number (coarse): maximum of four (4) digits. + COARSE_BANK_ACCOUNT_NUMBER = 53; + // Credit scores (e.g., FICO). + CREDIT_SCORE = 54; + // Payslip dates and time deposited to bank accounts. + PAYSLIP_DATES = 55; + // Payroll information. + PAYROLL_INFORMATION = 56; + // Work Schedule and time card information. + TIMESHEETS = 57; + // Information relating to an employee's income, 401K, equities, and any other parts of an employee's compensation package. + COMPENSATION = 58; + // Tax Information such as withdrawing allowances, tax filing status, and marital status. + TAX_INFORMATION = 59; + // Last four (4) digits of Social Security Number (SSN). + COARSE_SSN = 60; + // IP Address. + IP_ADDRESS = 61; + // Device, log file and other information relating to an individual's use of Block systems, services and/or devices, and information about a user device's interaction with Block systems/services. This includes information collected via cookies and web beacons, like web browser and device characteristics, pixel tags. + SYSTEM_USAGE_LOG = 62; + } +} diff --git a/protos/squareup/common/governance/v0/merchant_data.proto b/protos/squareup/common/governance/v0/merchant_data.proto new file mode 100644 index 0000000..f151a13 --- /dev/null +++ b/protos/squareup/common/governance/v0/merchant_data.proto @@ -0,0 +1,165 @@ +syntax = "proto2"; +package squareup.governance.v0; + +// DO NOT EDIT!!! +// This file was auto-generated from https://github.com/squareup/dsl-framework/blob/ac75c6fa1371fe4950a4ec1b20e9b952d6265e91/rubrics/releases/for-dsl-engine/rubrics_v0.json using /script/EngineModelGenerator.py. + +option java_package = "com.squareup.protos.common.governance.v0"; + +message MerchantData { + enum SemanticType { + DO_NOT_USE = 0; + // Facial images used for identity verification, such as on a photo of a Government ID document. + GOV_ID_FACIAL_IMAGE = 1; + // Seller's voiceprint. + VOICEPRINT = 2; + // Seller's fignerprint. + FINGERPRINT = 3; + // Handwriting, including a handwritten signature, such as on a photo of a Government ID document. + HANDWRITING = 4; + // A seller's full name. + FULL_NAME = 5; + // A seller's personal email address (including hashed email address). + EMAIL_ADDRESS = 6; + // The seller's full residential postal address. + RESIDENTIAL_ADDRESS = 7; + // A seller's personal phone number (including hashed phone number): seven (7) digits or more. + PHONE_NUMBER = 8; + // Username, $Cashtag, social media handle. + USERNAME = 9; + // Bank account number. + BANK_ACCOUNT_NUMBER = 10; + // Payment Card Primary Account Number (PAN) (more than defined by COARSE_PAYMENT_CARD_PAN). + PAYMENT_CARD_PAN = 11; + // Social Security Number (SSN). + SSN = 12; + // Employer Identification Number (EIN). + EIN = 13; + // Passport Number. + PASSPORT_NUMBER = 14; + // Driver's License Number. + DRIVERS_LICENSE_NUMBER = 15; + // Individual Tax ID Number (ITIN). + ITIN = 16; + // Authentication tokens and session tokens. + AUTH_TOKEN = 17; + // Passwords/passcodes. + PASSWORD = 18; + // Security questions and answers. + SECURITY_QUESTION_ANSWER = 19; + // Plaid tokens and similar access credentials for Seller's bank accounts. + BANK_ACCESS_TOKEN = 20; + // A first name of a seller. + FIRST_NAME = 21; + // A middle name or middle initial of a seller. + MIDDLE_NAME = 22; + // A last name of a seller (surname). + LAST_NAME = 23; + // The street name and street number of a seller' residence, including any additional information about unit/apt/suite/etc. + STREET_OF_RESIDENCE = 24; + // Residential ZIP/postal code. + POSTAL_CODE_OF_RESIDENCE = 25; + // City of residence. + CITY_OF_RESIDENCE = 26; + // State/Region/Province/County of residence. + REGION_OF_RESIDENCE = 27; + // Country of residence. + COUNTRY_OF_RESIDENCE = 28; + // Personal phone number: up to six (6) digits. + COARSE_PHONE_NUMBER = 29; + // Seller's device IDs (including Advertising ID and other unique identifiers that corresponds with an individual's mobile device). + DEVICE_ID = 30; + // A seller's full date of birth. + DATE_OF_BIRTH = 31; + // The day of the month a seller was born. + BIRTH_DAY_OF_MONTH = 32; + // The month a seller was born. + BIRTH_MONTH = 33; + // The year a seller was born. + BIRTH_YEAR = 34; + // Gender. + GENDER = 35; + // Personal inferences about the Seller. + INFERENCES = 36; + // Customer ID (of a Seller). + CUSTOMER_ID = 37; + // Information pertaining to a seller's religious beliefs, philosophical beliefs, or union membership. + BELIEFS_OR_POLITICS = 38; + // Information pertaining to a seller's health, sex life, or sexual orientation. + HEALTH_OR_SEXUALITY = 39; + // Information about a seller's racial or ethnic demographic. + ETHNICITY_OR_RACE = 40; + // Touch events on mobile devices. NOTE: individual touch events are quasi-identifiers and should be regarded as pseudonymous, de-identified data by themselves. Sequences of touch events should be considered as equivalent to the data they represent (e.g., touch event sequences for customer identifiers or authentication efforts should be regarded as identifiable and possibly sensitive). + TOUCH_EVENTS = 41; + // Seller's bank name. + BANK_NAME = 42; + // Seller's bank routing number. + BANK_ROUTING_NUMBER = 43; + // Bank account number (coarse): maximum of four (4) digits. + COARSE_BANK_ACCOUNT_NUMBER = 44; + // Coarse Credit Card Primary Account Number (PAN): BIN or last four digits (max ten digits). + COARSE_PAYMENT_CARD_PAN = 45; + // A seller's credit score (e.g., FICO). + CREDIT_SCORE = 46; + // Date of Transaction (e.g. a bank deposit, a purchase, etc.) + TRANSACTION_DATE = 47 [deprecated = true]; + // Amount of Transaction (e.g. a bank deposit, a purchase, etc.) + TRANSACTION_AMOUNT = 48 [deprecated = true]; + // Sender/Recipient/Merchant of Transaction (e.g. a bank deposit, a purchase, etc.) + TRANSACTION_PARTY = 49 [deprecated = true]; + // Last four (4) digits of a seller's Social Security Number (SSN). + COARSE_SSN = 50; + // Geolocation (precise): location data with a resolution the same or greater than latitude and longitude with three or more decimal places. + PRECISE_GEOLOCATION = 51; + // Geolocation (coarse): location data with a resolution the same or less than latitude and longitude with two decimal places. + COARSE_GEOLOCATION = 52; + // Seller's IP Address. + IP_ADDRESS = 53; + // Free-form text fields and similar user-supplied application content (such as payment notes, email messages, text messages, voice transcripts, etc.), or customer support data generated by the user. + USER_GENERATED_TEXT = 54; + // Tokens used to identify a merchant customer, such as pseudonyms for Contact information, such as a Fidelius SSN/ITIN token. + PERSON_PSEUDONYM = 55; + // Tokens used to identify a specific financial instrument, such as a Fidelius PAN token or bank account token (note: this excludes tokens shared with third-parties to facilitate financial transactions). + INSTRUMENT_PSEUDONYM = 56; + // Tokens used to identify a particular device, such as a seller's phone, such as App Tokens. + DEVICE_PSEUDONYM = 57; + // Seller's business zip/postal code. + BUSINESS_POSTAL_CODE = 58; + // Seller's business name. + BUSINESS_NAME = 59; + // Seller's business email address (including hashed email address). + BUSINESS_EMAIL = 60; + // Seller's business postal address. + BUSINESS_ADDRESS = 61; + // Seller's business phone number (including hashed phone number). + BUSINESS_PHONE_NUMBER = 62; + // Seller's business social media handles. + BUSINESS_USERNAME = 63; + // Such as a list of contacts in the user's phone, address book, or social graph (see the Contact Information (Precise) category). + CONTACTS = 64; + // Information relating to a seller's personal income, such as salary, commission, investment income, etc. + INCOME = 65; + // Information relating to a seller's personal expenses, such as mortgage/rental payments, monthly utilities, debt payments, etc. + EXPENSES = 66; + // Information relating to a seller's personal assets, such as details on savings accounts, retirement funds, stocks, bonds, and investments. + ASSETS = 67; + // Underwriting decisions. + UNDERWRITING = 68; + // Advertising data, such as information about the advertisements the Seller has seen. + ADVERTISING_DATA = 69; + // Transaction records (Sales / Purchases). + TRANSACTION_RECORDS = 70; + // Details on chargebacks including chargeback date, amount, reason, and any supporting documentation. + CHARGEBACKS = 71; + // Details regarding any refunds issued for a transaction including refund date, amount, and reason. + REFUNDS = 72; + // Such as app launches, taps, scrolling information or other information about how the user interacts with the Square app. + INTERACTIONS = 73; + // Payroll information. + PAYROLL_INFORMATION = 74; + // Tax forms (e.g., W-2, W-4). + TAX_FORMS = 75; + // Dates and amounts deposited to Seller bank accounts. + BANK_DEPOSIT_RECORD = 76; + } +} diff --git a/protos/squareup/common/governance/v0/payment_card_data.proto b/protos/squareup/common/governance/v0/payment_card_data.proto new file mode 100644 index 0000000..81c11cc --- /dev/null +++ b/protos/squareup/common/governance/v0/payment_card_data.proto @@ -0,0 +1,31 @@ +syntax = "proto2"; +package squareup.governance.v0; + +// DO NOT EDIT!!! +// This file was auto-generated from https://github.com/squareup/dsl-framework/blob/ac75c6fa1371fe4950a4ec1b20e9b952d6265e91/rubrics/releases/for-dsl-engine/rubrics_v0.json using /script/EngineModelGenerator.py. + +option java_package = "com.squareup.protos.common.governance.v0"; + +message PaymentCardData { + enum SemanticType { + DO_NOT_USE = 0; + // The full contents of track 1 or track 2 on the magnetic stripe, or the equivalent data on the chip. + CARD_TRACK = 1; + // The three-digit or four-digit verification code printed on the card. + CARD_VERIFICATION_CODE = 2; + // The (typically four digit) secret value known to the card holder. + CARD_PIN = 3; + // Primary Account Number (PAN) (more than defined by CARD_BIN and CARD_LAST_FOUR). + CARD_PAN = 4; + // Issuer Identification Number (IIN), aka Bank Identification Number (BIN). The first six or eight digits of the PAN (depends on the institution issuing the card), that identifies the financial institution that issued the card. + CARD_BIN = 5; + // Last four digits (or fewer) of a Primary Account Number. + CARD_LAST_FOUR = 6; + // Three-digit or four-digit value in the magnetic-stripe that follows the expiration date of the payment card on the track data. It is used for various things such as defining service attributes, differentiating between international and national interchange, or identifying usage restrictions. + CARD_SERVICE_CODE = 7; + // The expiration date associated with a payment card. + CARD_EXPIRY = 8; + // The name of a cardholder. May be an individual, business, or other given name written on the payment card. + CARDHOLDER_NAME = 9; + } +} diff --git a/protos/squareup/common/governance/v0/semantic_types.proto b/protos/squareup/common/governance/v0/semantic_types.proto new file mode 100644 index 0000000..3ecd2bf --- /dev/null +++ b/protos/squareup/common/governance/v0/semantic_types.proto @@ -0,0 +1,25 @@ +syntax = "proto2"; +package squareup.governance.v0; + +option java_package = "com.squareup.protos.common.governance.v0"; + +import "google/protobuf/descriptor.proto"; +import "squareup/common/governance/v0/common.proto"; +import "squareup/common/governance/v0/consumer_personal_data.proto"; +import "squareup/common/governance/v0/merchant_data.proto"; +import "squareup/common/governance/v0/payment_card_data.proto"; +import "squareup/common/governance/v0/employee_personal_data.proto"; + +extend google.protobuf.FieldOptions { + // Common DSL Rubric + repeated Common.SemanticType common = 13000; + // Consumer Personal Data + repeated ConsumerPersonalData.SemanticType consumer = 13001; + // Merchant Data + repeated MerchantData.SemanticType merchant = 13002; + // Payment Card Data + repeated PaymentCardData.SemanticType pci = 13003; + // Employee Personal Data + repeated EmployeePersonalData.SemanticType employee = 13004; + // reserved 13005 to 13999 +} diff --git a/protos/squareup/common/pii.proto b/protos/squareup/common/pii.proto new file mode 100644 index 0000000..a2d367b --- /dev/null +++ b/protos/squareup/common/pii.proto @@ -0,0 +1,75 @@ +syntax = "proto2"; +package squareup; + +// DO NOT CHANGE THE PACKAGE OR OPTION NAMES WITHOUT ALSO UPDATING THE PROTOBUF RUNTIME +// LIBRARIES (for example, protobuf-java). + +option java_package = "com.squareup.protos.common.pii"; + +import "google/protobuf/descriptor.proto"; + +// Reserved Ids: 22200 - 22299 +extend google.protobuf.FieldOptions { + + /** + * Fields marked with redacted are not to be logged, generally for PCI or PII. + * The Java protobuf library automatically redacts these fields when outputting proto + * fields through TextFormat or AbstractMessage.toString. + */ + optional bool redacted = 22200; + + /** + * Fields marked as tokenizable, can be tokenized in lieu of being redacted. An additional + * normalization strategy can be added to be applied in conjunction with tokenization. + */ + optional bool tokenizable = 22201; + + /** + * Fields marked with a normalization strategy, should have the requested normalization applied + * prior to other transformations (e.g. prior to tokenization). + */ + optional NormalizationStrategy normalization = 22202; + + // 22203, 22204, 22205 are reserved in other packages + + /** + * Fields marked with a data sensitivity level should be used in conjection with `redacted`. + * The annotation gives more granulariyy into what kind of data is in the field. + */ + optional DataSensitivityLevel data_sensitivity_level = 22206; +} + +/** + * An enum for normalization strategies. The values here correspond to those defined in + * {@link com.squareup.crypto.tokenization.NormalizationStrategy}. + */ +enum NormalizationStrategy { + DEFAULT = 1; + EMAIL = 2; +} + +/** + * Used to indicate how sensitive data is in a field. The values here correspond to the PII levels + * defined in http://go/piisemantictypes. + * + * The API Log inspector reads this enum and filters values that are not NONE or BASIC_PII. If the + * field is absent then we assume the value is NONE. For more detail, read the proposal doc: + * https://docs.google.com/document/d/1pMLUuYI-SxGnB85SDffBr95kC3UI4S_5xdRDsZgjkIA/edit# + */ +enum DataSensitivityLevel { + // Contains no sensitive data + NONE = 1; + // Basic PII information such as name, email, etc. + BASIC_PII = 2; + // Payment Card Industry (PCI) Information + PCI = 3; + // Secrets that should be considered highly confidential (password, access token, etc.) + SECRET = 4; + // Business information + BUSINESS_INFORMATION = 5; + // Non-Public Information + NPI = 6; + // Material Non-Public Information + MNPI = 7; +} + diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..47a730b --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.91.1" +components = ["clippy", "rustfmt"] diff --git a/script/ci b/script/ci new file mode 100755 index 0000000..d5ed964 --- /dev/null +++ b/script/ci @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +source ./bin/activate-hermit + +if [[ -z "${TEST_RUNNER:-}" ]]; then + echo "TEST_RUNNER is not set" >&2 + exit 1 +fi + +case "${TEST_RUNNER}" in + lint) + just ci-lint + ;; + test) + just ci-test + ;; + *) + echo "Unknown TEST_RUNNER: ${TEST_RUNNER}" >&2 + exit 1 + ;; +esac diff --git a/script/update-extensions-catalog b/script/update-extensions-catalog new file mode 100644 index 0000000..65056a7 --- /dev/null +++ b/script/update-extensions-catalog @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUTPUT_PATH="${1:-$ROOT_DIR/extensions-new.yaml}" +BACKUP_PATH="${OUTPUT_PATH}.bak" +TEMP_OUTPUT="$(mktemp "${TMPDIR:-/tmp}/agent-tools-extensions.XXXXXX.yaml")" +G2_DIR="${G2_DIR:-$HOME/Development/g2}" +G2_CONFIG_PATH="${G2_CONFIG_PATH:-$G2_DIR/web/src/features/settings/utils/config.ts}" +G2_OAUTH_DESCRIPTIONS_PATH="${G2_OAUTH_DESCRIPTIONS_PATH:-$G2_DIR/web/src/shared/constants/oauthDescriptions.ts}" + +cleanup() { + rm -f "$TEMP_OUTPUT" +} +trap cleanup EXIT + +cargo run -- --write-extensions "$TEMP_OUTPUT" + +if [[ -f "$OUTPUT_PATH" ]]; then + cp -f "$OUTPUT_PATH" "$BACKUP_PATH" +fi + +if [[ ! -f "$G2_CONFIG_PATH" ]]; then + echo "WARNING: g2 repo not found at $G2_DIR" >&2 + echo "Extensions catalog will be generated without g2 OAuth provider data." >&2 + echo "" >&2 + echo "To include g2 providers, clone the repo:" >&2 + echo " git clone https://github.com/squareup/g2 $G2_DIR" >&2 + echo " cd $G2_DIR && git checkout origin/main" >&2 + echo "" >&2 + echo "Or set G2_DIR to point to an existing clone." >&2 + mv "$TEMP_OUTPUT" "$OUTPUT_PATH" + exit 0 +fi + +uv run "$ROOT_DIR/script/update_extensions_catalog.py" \ + "$TEMP_OUTPUT" \ + "$OUTPUT_PATH" \ + "$G2_CONFIG_PATH" \ + "$G2_OAUTH_DESCRIPTIONS_PATH" diff --git a/script/update_extensions_catalog.py b/script/update_extensions_catalog.py new file mode 100644 index 0000000..09f4648 --- /dev/null +++ b/script/update_extensions_catalog.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# /// + +import json +import re +import sys +from pathlib import Path +from typing import Dict, Optional + +HEADER = "# Generated via `just update-extensions-catalog`, then curated manually.\n" + + +def parse_yaml_scalar(raw: str) -> str: + value = raw.split("#", 1)[0].strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + quote = value[0] + value = value[1:-1] + if quote == "'": + return value.replace("''", "'").strip() + return json.loads(f'"{value}"') if "\\" in value else value.strip() + return value + + +def parse_generated_catalog(path: Path) -> Dict[str, str]: + entries: Dict[str, str] = {} + current_name: Optional[str] = None + current_about = "" + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + if not raw_line or raw_line.startswith("#"): + continue + + if raw_line.startswith("- name:"): + if current_name: + entries[current_name] = current_about + current_name = parse_yaml_scalar(raw_line.split(":", 1)[1]) + current_about = "" + continue + + if current_name and raw_line.startswith(" about:"): + current_about = parse_yaml_scalar(raw_line.split(":", 1)[1]) + + if current_name: + entries[current_name] = current_about + + return entries + + +def parse_g2_config_entries(path: Path) -> Dict[str, str]: + entries: Dict[str, str] = {} + in_config = False + current_name: Optional[str] = None + current_display_name = "" + + def flush_current_entry() -> None: + if not current_name or current_name.endswith("_official"): + return + entries[current_name] = current_display_name + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + stripped = raw_line.strip() + if not stripped or stripped.startswith("//"): + continue + + if not in_config: + if stripped == "export const oauthConfig = {": + in_config = True + continue + + indent = len(raw_line) - len(raw_line.lstrip(" ")) + if indent == 0 and stripped == "}": + flush_current_entry() + break + + if indent == 2 and stripped.endswith("{"): + flush_current_entry() + key_match = re.match(r"(?:'([^']+)'|([A-Za-z0-9_-]+)):\s*\{$", stripped) + current_name = (key_match.group(1) or key_match.group(2)) if key_match else None + current_display_name = "" + continue + + if current_name is None or indent < 4: + continue + + display_name_match = re.match(r"displayName:\s*'([^']*)',?$", stripped) + if display_name_match: + current_display_name = display_name_match.group(1).strip() + + return entries + + +def parse_g2_oauth_descriptions(path: Path) -> Dict[str, str]: + if not path.is_file(): + return {} + + entries: Dict[str, str] = {} + source = path.read_text(encoding="utf-8") + match = re.search(r"export const oauthDescriptions.*?=\s*\{(.*?)\}\s*as const", source, re.DOTALL) + if not match: + return entries + + for raw_line in match.group(1).splitlines(): + stripped = raw_line.strip().rstrip(",") + if not stripped: + continue + + description_match = re.match(r"(?:'([^']+)'|([A-Za-z0-9_-]+)):\s*'([^']*)'$", stripped) + if not description_match: + continue + + key = description_match.group(1) or description_match.group(2) + entries[key] = description_match.group(3).strip() + + return entries + + +def normalize_about(name: str, about: str) -> str: + cleaned = about.strip().lstrip("#").strip() + return cleaned or f"{name} tools" + + +def render_yaml_scalar(value: str) -> str: + lowered = value.lower() + if ( + value + and lowered not in {"null", "~", "true", "false"} + and "\n" not in value + and ": " not in value + and " #" not in value + and not value.endswith(":") + and value[0] not in "-?:,[]{}#&*!|>'\"%@`" + ): + return value + return json.dumps(value, ensure_ascii=False) + + +def write_catalog(path: Path, entries: Dict[str, str]) -> None: + rows = [ + {"name": name, "about": normalize_about(name, about)} + for name, about in sorted(entries.items()) + ] + + with path.open("w", encoding="utf-8") as handle: + handle.write(HEADER) + for row in rows: + handle.write(f"- name: {render_yaml_scalar(row['name'])}\n") + handle.write(f" about: {render_yaml_scalar(row['about'])}\n") + + +def main() -> int: + generated_path = Path(sys.argv[1]) + output_path = Path(sys.argv[2]) + g2_config_path = Path(sys.argv[3]) + g2_oauth_descriptions_path = Path(sys.argv[4]) + + entries = parse_generated_catalog(generated_path) + descriptions = parse_g2_oauth_descriptions(g2_oauth_descriptions_path) + + for provider, display_name in parse_g2_config_entries(g2_config_path).items(): + entries.setdefault(provider, descriptions.get(provider, display_name)) + + write_catalog(output_path, entries) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/appkit.rs b/src/appkit.rs new file mode 100644 index 0000000..2859d31 --- /dev/null +++ b/src/appkit.rs @@ -0,0 +1,243 @@ +//! Compatibility wrapper for the Cloudflare-backed internal Block App Kit CLI. +//! +//! This powers `sq agent-tools appkit` and `bl tools appkit`. It is separate +//! from the root `bl apps` command used by the external Apps Platform pilot +//! and must remain available while the internal experience is unchanged. + +use std::io; +use std::process::{Command, ExitStatus, Stdio}; + +use anyhow::{anyhow, Result}; + +use crate::cli::{global_arg_skip_count, APPKIT_COMMAND_NAME}; + +pub(crate) fn is_appkit_command(command_tokens: &[String]) -> bool { + command_tokens.first().map(String::as_str) == Some(APPKIT_COMMAND_NAME) +} + +pub(crate) fn should_run_before_bootstrap(raw_args: &[String]) -> bool { + let Some(position) = appkit_command_position(raw_args) else { + return false; + }; + + position.after_separator + || !raw_args[position.index + 1..] + .iter() + .any(|arg| arg == "--describe-commands") +} + +pub(crate) fn run(raw_args: &[String]) -> Result<()> { + let args = raw_args_after_appkit(raw_args); + exec_appkit(&args) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AppkitCommandPosition { + index: usize, + after_separator: bool, +} + +fn appkit_command_position(raw_args: &[String]) -> Option { + let mut index = 0; + while index < raw_args.len() { + match raw_args[index].as_str() { + APPKIT_COMMAND_NAME => { + return Some(AppkitCommandPosition { + index, + after_separator: false, + }); + } + "--" => { + return (raw_args.get(index + 1).map(String::as_str) == Some(APPKIT_COMMAND_NAME)) + .then_some(AppkitCommandPosition { + index: index + 1, + after_separator: true, + }); + } + value if is_bootstrap_metadata_prefix(value) => return None, + value => { + let skip_count = global_arg_skip_count(value); + if skip_count == 0 { + return None; + } + index += skip_count; + } + } + } + + None +} + +fn is_bootstrap_metadata_prefix(arg: &str) -> bool { + matches!( + arg, + "--describe-commands" | "--summary" | "--write-extensions" + ) || arg.starts_with("--write-extensions=") +} + +fn raw_args_after_appkit(raw_args: &[String]) -> Vec<&str> { + let mut index = 0; + while index < raw_args.len() { + match raw_args[index].as_str() { + APPKIT_COMMAND_NAME => { + return raw_args[index + 1..].iter().map(String::as_str).collect(); + } + "--" => { + if raw_args.get(index + 1).map(String::as_str) == Some(APPKIT_COMMAND_NAME) { + return raw_args[index + 2..].iter().map(String::as_str).collect(); + } + index += 1; + } + value => index += global_arg_skip_count(value).max(1), + } + } + + Vec::new() +} + +fn exec_appkit(args: &[&str]) -> Result<()> { + match child_status(APPKIT_COMMAND_NAME, &[], args) { + Ok(status) => std::process::exit(child_exit_code(status)), + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(command_error(APPKIT_COMMAND_NAME, err)), + } + + match child_status( + "uvx", + &["--from", "mcp_block_app_kit", APPKIT_COMMAND_NAME], + args, + ) { + Ok(status) => std::process::exit(child_exit_code(status)), + Err(err) if err.kind() == io::ErrorKind::NotFound => anyhow::bail!( + "appkit or uvx not found. Install appkit on PATH, or install uv so sq agent-tools can run mcp_block_app_kit on demand." + ), + Err(err) => Err(command_error("uvx", err)), + } +} + +fn child_status(binary: &str, prefix_args: &[&str], args: &[&str]) -> io::Result { + Command::new(binary) + .args(prefix_args) + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() +} + +fn command_error(binary: &str, err: io::Error) -> anyhow::Error { + anyhow!("failed to execute `{binary}`: {err}") +} + +#[cfg(unix)] +fn child_exit_code(status: ExitStatus) -> i32 { + use std::os::unix::process::ExitStatusExt; + + status + .code() + .or_else(|| status.signal().map(|signal| 128 + signal)) + .unwrap_or(1) +} + +#[cfg(not(unix))] +fn child_exit_code(status: ExitStatus) -> i32 { + status.code().unwrap_or(1) +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use std::process::ExitStatus; + + #[cfg(unix)] + use super::child_exit_code; + use super::{is_appkit_command, raw_args_after_appkit, should_run_before_bootstrap}; + + #[test] + fn appkit_command_is_detected_before_extension_loading() { + let tokens = vec!["appkit".to_string(), "deploy".to_string()]; + + assert!(is_appkit_command(&tokens)); + } + + #[test] + fn appkit_command_can_run_before_bootstrap() { + let raw_args = ["appkit", "deploy", "--timeout", "not-a-number", "--summary"] + .into_iter() + .map(String::from) + .collect::>(); + + assert!(should_run_before_bootstrap(&raw_args)); + } + + #[test] + fn appkit_command_before_bootstrap_skips_global_flag_values() { + let raw_args = ["--base-url", "appkit", "appkit", "deploy"] + .into_iter() + .map(String::from) + .collect::>(); + + assert!(should_run_before_bootstrap(&raw_args)); + } + + #[test] + fn appkit_describe_commands_stays_on_metadata_path() { + let nested_describe = ["appkit", "deploy", "--describe-commands"] + .into_iter() + .map(String::from) + .collect::>(); + let root_describe = ["--describe-commands", "appkit"] + .into_iter() + .map(String::from) + .collect::>(); + + assert!(!should_run_before_bootstrap(&nested_describe)); + assert!(!should_run_before_bootstrap(&root_describe)); + } + + #[test] + fn raw_args_after_appkit_preserves_appkit_owned_flags() { + let raw_args = [ + "--base-url", + "https://kgoose.example.test", + "appkit", + "deploy", + "--timeout", + "30", + "--version", + ] + .into_iter() + .map(String::from) + .collect::>(); + + assert_eq!( + raw_args_after_appkit(&raw_args), + vec!["deploy", "--timeout", "30", "--version"] + ); + } + + #[test] + fn raw_args_after_appkit_ignores_appkit_in_global_flag_values() { + let raw_args = [ + "--base-url", + "appkit", + "--playpen=appkit", + "appkit", + "deploy", + ] + .into_iter() + .map(String::from) + .collect::>(); + + assert_eq!(raw_args_after_appkit(&raw_args), vec!["deploy"]); + } + + #[cfg(unix)] + #[test] + fn child_exit_code_preserves_signal_status() { + use std::os::unix::process::ExitStatusExt; + + assert_eq!(child_exit_code(ExitStatus::from_raw(2)), 130); + assert_eq!(child_exit_code(ExitStatus::from_raw(7 << 8)), 7); + } +} diff --git a/src/bin/bl.rs b/src/bin/bl.rs new file mode 100644 index 0000000..b65a40c --- /dev/null +++ b/src/bin/bl.rs @@ -0,0 +1,3 @@ +fn main() { + sq_kgoose::bl_main(); +} diff --git a/src/bl/agents.rs b/src/bl/agents.rs new file mode 100644 index 0000000..19805ba --- /dev/null +++ b/src/bl/agents.rs @@ -0,0 +1,603 @@ +//! Public `bl agents` command surface over the managed Agent Markdown lifecycle. + +use std::fs; + +use anyhow::{Context, Result}; +use clap::{Arg, ArgMatches, Command}; +use serde_json::{json, Value}; + +use super::agents_install::{ + agent_paths, classify, install_or_update, remove, update, validate_slug, AgentLifecycleResult, + AgentLifecycleStatus, AgentOwnership, InstalledAgentMetadata, +}; +use super::agents_models::{AgentDetail, AgentSummary, AgentVersion}; +use super::description::describe_command_tree; +use super::display::print_json; +use super::runner::{self, ensure_org_configured}; +use super::skills::EXIT_CODES_HELP; +use super::skills_api::{exit_codes, CliFailure, MarketplaceClient}; +use super::skills_config::SkillsConfig; + +pub fn agents_command() -> Command { + Command::new("agents") + .about("Manage BuilderLab marketplace agents") + .long_about( + "Discover marketplace agents and manage the Agent Markdown documents that BL owns. \ + Installing always requires an explicit agent slug.", + ) + .after_help(EXIT_CODES_HELP) + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand(Command::new("list").about("List marketplace agents")) + .subcommand( + Command::new("search") + .about("Search marketplace agents") + .arg( + Arg::new("query") + .required(true) + .help("Free-text search query"), + ), + ) + .subcommand(agent_version_command("show", "Show one marketplace agent")) + .subcommand(agent_version_command( + "install", + "Install one marketplace agent", + )) + .subcommand(agent_version_command( + "update", + "Update one managed marketplace agent", + )) + .subcommand(Command::new("installed").about("List BL-managed agents")) + .subcommand( + Command::new("which") + .about("Show one agent's managed location") + .arg(Arg::new("slug").required(true)), + ) + .subcommand( + Command::new("remove") + .about("Remove one BL-managed agent") + .arg(Arg::new("slug").required(true)), + ) +} + +fn agent_version_command(name: &'static str, about: &'static str) -> Command { + Command::new(name) + .about(about) + .arg(Arg::new("slug").required(true)) + .arg( + Arg::new("version") + .long("version") + .value_name("VERSION_ID") + .help("Use a specific marketplace version"), + ) +} + +pub fn run(matches: &ArgMatches) -> Result<()> { + runner::run(matches, dispatch) +} + +fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let command = matches.subcommand_name().unwrap_or("unknown"); + config.style.verbose(&format!("agents command={command}")); + match matches.subcommand() { + Some(("list", _)) => list(config, None), + Some(("search", submatches)) => { + let query = required_slug(submatches, "query")?; + list(config, Some(query)) + } + Some(("show", submatches)) => show(config, submatches), + Some(("install", submatches)) => install(config, submatches, false), + Some(("update", submatches)) => install(config, submatches, true), + Some(("installed", _)) => installed(config), + Some(("which", submatches)) => which(config, submatches), + Some(("remove", submatches)) => remove_agent(config, submatches), + _ => anyhow::bail!("expected an agents subcommand"), + } +} + +fn list(config: &SkillsConfig, query: Option<&str>) -> Result<()> { + ensure_org_configured(config)?; + let items = MarketplaceClient::new(config)?.agents().list_all(query)?; + if config.json { + return print_json(&json!({ "items": items.iter().map(summary_json).collect::>() })); + } + if items.is_empty() { + println!("No marketplace agents found."); + return Ok(()); + } + for item in items { + println!("{} {}", config.style.slug(&item.slug), item.name); + println!(" {} {}", config.style.label("status:"), item.status); + println!( + " {} {}", + config.style.label("version:"), + item.latest_version_id + ); + if !item.description.is_empty() { + println!( + " {} {}", + config.style.label("description:"), + item.description + ); + } + println!(); + } + Ok(()) +} + +fn show(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + ensure_org_configured(config)?; + let slug = required_slug(matches, "slug")?; + config.style.verbose(&format!("agents show slug={slug}")); + let client = MarketplaceClient::new(config)?; + let marketplace = client.agents(); + if let Some(version) = matches.get_one::("version") { + let version = marketplace.version(slug, version)?; + if config.json { + return print_json(&version_json(&version)); + } + display_version(config, &version); + return Ok(()); + } + let detail = marketplace.show(slug)?; + if config.json { + return print_json(&detail_json(&detail)); + } + display_detail(config, &detail); + Ok(()) +} + +fn install(config: &SkillsConfig, matches: &ArgMatches, update_only: bool) -> Result<()> { + ensure_org_configured(config)?; + let slug = required_slug(matches, "slug")?; + let version = matches.get_one::("version").cloned(); + let action = if update_only { "update" } else { "install" }; + config + .style + .verbose(&format!("agents {action} slug={slug}")); + let client = MarketplaceClient::new(config)?; + let result = if update_only { + update(config, &client, slug, version)? + } else { + install_or_update(config, &client, slug, version)? + }; + report_lifecycle(config, result) +} + +fn remove_agent(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + ensure_org_configured(config)?; + let slug = required_slug(matches, "slug")?; + config.style.verbose(&format!("agents remove slug={slug}")); + report_lifecycle(config, remove(config, slug)?) +} + +fn installed(config: &SkillsConfig) -> Result<()> { + ensure_org_configured(config)?; + let state_root = config.bl_home.join("agents").join("installed"); + let entries = match fs::read_dir(&state_root) { + Ok(entries) => entries.collect::>>()?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => return Err(error).with_context(|| format!("read {}", state_root.display())), + }; + let mut items = entries + .into_iter() + .filter(|entry| { + entry + .path() + .extension() + .is_some_and(|extension| extension == "json") + }) + .map(|entry| installed_item(config, entry.path())) + .collect::>>()?; + items.sort_by(|left, right| left["slug"].as_str().cmp(&right["slug"].as_str())); + if config.json { + return print_json(&json!({ "items": items })); + } + if items.is_empty() { + println!("No BL-managed agents installed."); + return Ok(()); + } + for item in items { + display_local_item(config, &item); + } + Ok(()) +} + +fn installed_item(config: &SkillsConfig, state_path: std::path::PathBuf) -> Result { + let slug = state_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + if validate_slug(slug).is_err() { + return Ok(local_item( + "conflict", + slug, + None, + &state_path, + None, + Some("invalid managed-state filename"), + )); + } + let paths = agent_paths(config, slug)?; + let ownership = classify(&paths, slug)?; + config.style.verbose(&format!( + "agents installed slug={slug} ownership={}", + ownership_status(&ownership) + )); + Ok(ownership_item(slug, &paths.target, ownership)) +} + +fn which(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + ensure_org_configured(config)?; + let slug = required_slug(matches, "slug")?; + let paths = agent_paths(config, slug)?; + let ownership = classify(&paths, slug)?; + config.style.verbose(&format!( + "agents which slug={slug} ownership={}", + ownership_status(&ownership) + )); + match ownership { + AgentOwnership::Absent => Err(super::skills_api::failure( + exit_codes::GENERAL, + "not_installed", + format!("agent `{slug}` is not installed; run `bl agents install {slug}`"), + )), + AgentOwnership::ProtectedConflict { reason } => { + Err(agent_conflict(slug, &paths.target, reason)) + } + ownership => { + let item = ownership_item(slug, &paths.target, ownership); + if config.json { + print_json(&item) + } else { + display_local_item(config, &item); + Ok(()) + } + } + } +} + +fn report_lifecycle(config: &SkillsConfig, result: AgentLifecycleResult) -> Result<()> { + if result.status == AgentLifecycleStatus::Conflict { + return Err(agent_conflict( + &result.slug, + &result.path, + result + .reason + .unwrap_or_else(|| "agent ownership conflict".to_string()), + )); + } + let value = lifecycle_json(&result); + if config.json { + return print_json(&value); + } + let action = value["status"] + .as_str() + .unwrap_or("completed") + .replace('_', " "); + config + .style + .success(&format!("Agent `{}` {action}", result.slug)); + println!( + " {} {}", + config.style.label("path:"), + result.path.display() + ); + if let Some(metadata) = result.metadata { + println!( + " {} {}", + config.style.label("version:"), + metadata.version_id + ); + println!( + " {} {}", + config.style.label("source:"), + metadata.source_revision + ); + println!( + " {} {}", + config.style.label("installed via:"), + metadata.installed_via + ); + } + if let Some(reason) = result.reason { + println!(" {} {reason}", config.style.label("reason:")); + } + Ok(()) +} + +fn required_slug<'a>(matches: &'a ArgMatches, name: &str) -> Result<&'a str> { + matches + .get_one::(name) + .map(String::as_str) + .with_context(|| format!("expected {name}")) +} + +fn agent_conflict(slug: &str, path: &std::path::Path, reason: String) -> anyhow::Error { + anyhow::Error::new(CliFailure { + exit_code: exit_codes::FS_CONFLICT, + code: "agent_conflict".to_string(), + message: format!("agent `{slug}` conflicts with local content: {reason}"), + details: Some(json!({ + "status": "conflict", + "slug": slug, + "path": path, + "reason": reason, + })), + }) +} + +fn lifecycle_json(result: &AgentLifecycleResult) -> Value { + local_item( + lifecycle_status(&result.status), + &result.slug, + result.metadata.as_ref(), + &result.path, + result + .metadata + .as_ref() + .map(|metadata| metadata.content_sha256.as_str()), + result.reason.as_deref(), + ) +} + +fn ownership_item(slug: &str, path: &std::path::Path, ownership: AgentOwnership) -> Value { + match ownership { + AgentOwnership::ManagedExact(metadata) => local_item( + "installed", + slug, + Some(&metadata), + path, + Some(&metadata.content_sha256), + None, + ), + AgentOwnership::ManagedMissingFile(metadata) => local_item( + "missing", + slug, + Some(&metadata), + path, + Some(&metadata.content_sha256), + None, + ), + AgentOwnership::ProtectedConflict { reason } => { + local_item("conflict", slug, None, path, None, Some(&reason)) + } + AgentOwnership::Absent => local_item("absent", slug, None, path, None, None), + } +} + +fn local_item( + status: &str, + slug: &str, + metadata: Option<&InstalledAgentMetadata>, + path: &std::path::Path, + content_sha256: Option<&str>, + reason: Option<&str>, +) -> Value { + json!({ + "status": status, + "slug": slug, + "path": path, + "version_id": metadata.map(|metadata| &metadata.version_id), + "content_sha256": content_sha256, + "source": metadata.map(source_json), + "installed_via": metadata.map(|metadata| &metadata.installed_via), + "reason": reason, + }) +} + +fn source_json(metadata: &InstalledAgentMetadata) -> Value { + json!({ + "id": metadata.source_id, + "snapshot_id": metadata.source_snapshot_id, + "revision": metadata.source_revision, + "path": metadata.source_path, + }) +} + +fn summary_json(item: &AgentSummary) -> Value { + json!({ + "slug": item.slug, + "name": item.name, + "description": item.description, + "status": item.status, + "enabled": item.enabled, + "latest_version_id": item.latest_version_id, + "latest_content_sha256": item.latest_content_sha256, + "source": { "id": item.source_id, "revision": item.source_revision, "path": item.source_path }, + "tags": item.tags, + }) +} + +fn detail_json(item: &AgentDetail) -> Value { + let mut value = summary_json(&AgentSummary { + slug: item.slug.clone(), + name: item.name.clone(), + description: item.description.clone(), + status: item.status.clone(), + enabled: item.enabled, + latest_version_id: item.latest_version_id.clone(), + latest_content_sha256: item.latest_content_sha256.clone(), + source_id: item.source_id.clone(), + source_revision: item.source_revision.clone(), + source_path: item.source_path.clone(), + tags: item.tags.clone(), + }); + value["latest_version"] = version_detail_json(&item.latest_version); + value["versions"] = json!(item.versions.iter().map(|version| json!({ "id": version.id, "status": version.status, "content_sha256": version.content_sha256, "created_at": version.created_at })).collect::>()); + value +} + +fn version_detail_json(item: &super::agents_models::AgentVersionDetail) -> Value { + json!({ + "id": item.id, + "slug": item.slug, + "name": item.name, + "status": item.status, + "content_sha256": item.content_sha256, + "created_at": item.created_at, + "artifact": { + "id": item.artifact.id, + "sha256": item.artifact.sha256, + "size_bytes": item.artifact.size_bytes, + "media_type": item.artifact.media_type, + }, + "source": { + "id": item.source.source_id, + "snapshot_id": item.source.snapshot_id, + "revision": item.source.revision, + "path": item.source.path, + }, + }) +} + +fn version_json(item: &AgentVersion) -> Value { + json!({ + "id": item.id, + "slug": item.slug, + "name": item.name, + "status": item.status, + "content_sha256": item.content_sha256, + "created_at": item.created_at, + "artifact": { + "id": item.artifact.id, + "sha256": item.artifact.sha256, + "size_bytes": item.artifact.size_bytes, + "media_type": item.artifact.media_type, + }, + "source": { + "id": item.source.source_id, + "snapshot_id": item.source.snapshot_id, + "revision": item.source.revision, + "path": item.source.path, + }, + }) +} + +fn display_detail(config: &SkillsConfig, item: &AgentDetail) { + println!("{} {}", config.style.slug(&item.slug), item.name); + println!(" {} {}", config.style.label("status:"), item.status); + println!( + " {} {}", + config.style.label("version:"), + item.latest_version_id + ); + println!( + " {} {}", + config.style.label("content sha:"), + item.latest_content_sha256 + ); + println!( + " {} {}", + config.style.label("source:"), + item.source_revision + ); + if !item.description.is_empty() { + println!( + " {} {}", + config.style.label("description:"), + item.description + ); + } +} + +fn display_version(config: &SkillsConfig, item: &AgentVersion) { + println!("{} @ {}", config.style.slug(&item.slug), item.id); + println!(" {} {}", config.style.label("status:"), item.status); + println!( + " {} {}", + config.style.label("content sha:"), + item.content_sha256 + ); + println!( + " {} {}", + config.style.label("source:"), + item.source.revision + ); +} + +fn display_local_item(config: &SkillsConfig, item: &Value) { + println!( + "{} {}", + config + .style + .slug(item["slug"].as_str().unwrap_or("invalid")), + item["status"].as_str().unwrap_or("unknown") + ); + println!( + " {} {}", + config.style.label("path:"), + item["path"].as_str().unwrap_or_default() + ); + if let Some(version) = item["version_id"].as_str() { + println!(" {} {version}", config.style.label("version:")); + } + if let Some(reason) = item["reason"].as_str() { + println!(" {} {reason}", config.style.label("reason:")); + } + println!(); +} + +fn lifecycle_status(status: &AgentLifecycleStatus) -> &'static str { + match status { + AgentLifecycleStatus::Installed => "installed", + AgentLifecycleStatus::Updated => "updated", + AgentLifecycleStatus::UpToDate => "up_to_date", + AgentLifecycleStatus::Removed => "removed", + AgentLifecycleStatus::AlreadyAbsent => "already_absent", + AgentLifecycleStatus::Conflict => "conflict", + } +} + +fn ownership_status(ownership: &AgentOwnership) -> &'static str { + match ownership { + AgentOwnership::Absent => "absent", + AgentOwnership::ManagedExact(_) => "installed", + AgentOwnership::ManagedMissingFile(_) => "missing", + AgentOwnership::ProtectedConflict { .. } => "conflict", + } +} + +pub fn describe_commands() -> Value { + describe_command_tree(&agents_command()) +} + +#[cfg(test)] +mod tests { + use clap::error::ErrorKind; + + use super::*; + + #[test] + fn command_tree_exposes_only_agent_lifecycle_commands() { + let command = agents_command(); + let commands = command + .get_subcommands() + .map(Command::get_name) + .collect::>(); + + assert_eq!( + commands, + [ + "list", + "search", + "show", + "install", + "update", + "installed", + "which", + "remove", + ] + ); + } + + #[test] + fn install_requires_an_explicit_slug() { + let error = agents_command() + .try_get_matches_from(["agents", "install"]) + .expect_err("install without a slug must fail clap parsing"); + + assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); + } +} diff --git a/src/bl/agents_install.rs b/src/bl/agents_install.rs new file mode 100644 index 0000000..9f2d3b1 --- /dev/null +++ b/src/bl/agents_install.rs @@ -0,0 +1,994 @@ +//! Safe local lifecycle for marketplace-managed Agent Markdown documents. + +#![allow(dead_code)] + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_yaml::Value as YamlValue; + +use super::agents_models::{AgentInstallResolution, InstalledAgentRequest}; +use super::skills_api::{exit_codes, failure, MarketplaceClient}; +use super::skills_archive::{extract_zip_safely, sha256_hex, verify_agent_artifact}; +use super::skills_config::{default_agents_agents_dir, kgoose_service_url, SkillsConfig}; +use super::skills_targets::iso8601_utc; + +const RECORD_SCHEMA: &str = "bl-agent-install/v1"; +const LOCK_STALE_SECS: u64 = 15 * 60; + +#[derive(Debug, Clone)] +pub struct AgentPaths { + pub target: PathBuf, + pub state: PathBuf, + lock: PathBuf, +} + +pub fn validate_slug(slug: &str) -> Result<()> { + if slug.is_empty() + || !slug.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) + { + return Err(failure( + exit_codes::FS_CONFLICT, + "invalid_agent_slug", + format!("invalid agent slug `{slug}`"), + )); + } + Ok(()) +} + +pub fn agent_paths(config: &SkillsConfig, slug: &str) -> Result { + validate_slug(slug)?; + let agents_root = if config.local_dev { + config.skills_home.join("agents") + } else { + default_agents_agents_dir() + }; + let state_root = config.bl_home.join("agents").join("installed"); + Ok(AgentPaths { + target: agents_root.join(format!("{slug}.md")), + state: state_root.join(format!("{slug}.json")), + lock: config + .bl_home + .join("agents") + .join("locks") + .join(format!("{slug}.lock")), + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledAgentMetadata { + pub schema_version: String, + pub kind: String, + pub slug: String, + pub version_id: String, + pub content_sha256: String, + pub installed_file_sha256: String, + pub artifact_id: String, + pub artifact_sha256: String, + pub artifact_size_bytes: u64, + pub artifact_media_type: String, + pub source_id: String, + pub source_snapshot_id: String, + pub source_revision: String, + pub source_path: String, + pub server_url: String, + pub installed_at: String, + pub installed_via: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentOwnership { + Absent, + ManagedExact(InstalledAgentMetadata), + ManagedMissingFile(InstalledAgentMetadata), + ProtectedConflict { reason: String }, +} + +pub fn classify(paths: &AgentPaths, slug: &str) -> Result { + validate_slug(slug)?; + let target = fs::symlink_metadata(&paths.target); + let state = fs::symlink_metadata(&paths.state); + match (target, state) { + (Err(target_err), Err(state_err)) + if target_err.kind() == std::io::ErrorKind::NotFound + && state_err.kind() == std::io::ErrorKind::NotFound => + { + Ok(AgentOwnership::Absent) + } + (target, state) => { + let metadata = match state { + Ok(metadata) if metadata.file_type().is_file() => read_metadata(&paths.state), + Ok(_) => Err(anyhow::anyhow!("state path is not a regular file")), + Err(err) => Err(anyhow::Error::new(err)), + }; + let metadata = match metadata { + Ok(metadata) + if metadata.schema_version == RECORD_SCHEMA + && metadata.kind == "agent" + && metadata.slug == slug => + { + metadata + } + Ok(_) => { + return Ok(AgentOwnership::ProtectedConflict { + reason: "state record does not prove ownership of this agent".to_string(), + }) + } + Err(_) => { + return Ok(AgentOwnership::ProtectedConflict { + reason: "state record is missing, malformed, or not a regular file" + .to_string(), + }) + } + }; + match target { + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok(AgentOwnership::ManagedMissingFile(metadata)) + } + Ok(file) if file.file_type().is_file() => { + let bytes = fs::read(&paths.target) + .with_context(|| format!("read {}", paths.target.display()))?; + if sha256_hex(&bytes) == metadata.installed_file_sha256 { + Ok(AgentOwnership::ManagedExact(metadata)) + } else { + Ok(AgentOwnership::ProtectedConflict { + reason: "agent file differs from its managed record".to_string(), + }) + } + } + _ => Ok(AgentOwnership::ProtectedConflict { + reason: "agent target is not a regular managed file".to_string(), + }), + } + } + } +} + +pub fn installed_request(ownership: &AgentOwnership) -> Vec { + match ownership { + AgentOwnership::ManagedExact(metadata) => vec![InstalledAgentRequest { + slug: metadata.slug.clone(), + version_id: Some(metadata.version_id.clone()), + content_sha256: Some(metadata.content_sha256.clone()), + scope: Some("global".to_string()), + targets: Vec::new(), + installed_via: Some(metadata.installed_via.clone()), + local_source: false, + }], + _ => Vec::new(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentLifecycleStatus { + Installed, + Updated, + UpToDate, + Removed, + AlreadyAbsent, + Conflict, +} + +#[derive(Debug, Clone)] +pub struct AgentLifecycleResult { + pub status: AgentLifecycleStatus, + pub slug: String, + pub path: PathBuf, + pub metadata: Option, + pub reason: Option, +} + +pub fn install_or_update( + config: &SkillsConfig, + client: &MarketplaceClient, + slug: &str, + version_id: Option, +) -> Result { + apply_install_or_update(config, client, slug, version_id, false) +} + +pub fn update( + config: &SkillsConfig, + client: &MarketplaceClient, + slug: &str, + version_id: Option, +) -> Result { + apply_install_or_update(config, client, slug, version_id, true) +} + +fn apply_install_or_update( + config: &SkillsConfig, + client: &MarketplaceClient, + slug: &str, + version_id: Option, + update_only: bool, +) -> Result { + let paths = agent_paths(config, slug)?; + let _lock = AgentLock::acquire(&paths.lock)?; + let ownership = classify(&paths, slug)?; + if update_only && matches!(&ownership, AgentOwnership::Absent) { + return Err(failure( + exit_codes::GENERAL, + "not_installed", + format!("agent `{slug}` is not installed; run `bl agents install {slug}`"), + )); + } + if let AgentOwnership::ProtectedConflict { reason } = ownership { + return Ok(conflict_result(slug, &paths, reason)); + } + let resolution = + client + .agents() + .resolve_install(slug, version_id, installed_request(&ownership))?; + let document = download_if_required(&ownership, &resolution.action, || { + download_agent_document(client, &resolution) + })?; + if document.is_none() { + let AgentOwnership::ManagedExact(metadata) = ownership else { + unreachable!() + }; + return Ok(up_to_date_result(slug, &paths, metadata, resolution.reason)); + } + let document = document.expect("agent download is required for a non-noop operation"); + let metadata = metadata_for(config, &resolution, slug, &document)?; + replace_pair(&paths, &document, &metadata)?; + Ok(AgentLifecycleResult { + status: if matches!( + ownership, + AgentOwnership::Absent | AgentOwnership::ManagedMissingFile(_) + ) { + AgentLifecycleStatus::Installed + } else { + AgentLifecycleStatus::Updated + }, + slug: slug.to_string(), + path: paths.target, + metadata: Some(metadata), + reason: Some(resolution.reason), + }) +} + +pub fn remove(config: &SkillsConfig, slug: &str) -> Result { + let paths = agent_paths(config, slug)?; + let _lock = AgentLock::acquire(&paths.lock)?; + remove_at_paths(&paths, slug) +} + +fn remove_at_paths(paths: &AgentPaths, slug: &str) -> Result { + match classify(paths, slug)? { + AgentOwnership::Absent => Ok(AgentLifecycleResult { + status: AgentLifecycleStatus::AlreadyAbsent, + slug: slug.to_string(), + path: paths.target.clone(), + metadata: None, + reason: None, + }), + AgentOwnership::ProtectedConflict { reason } => Ok(conflict_result(slug, paths, reason)), + AgentOwnership::ManagedExact(metadata) => { + remove_pair(paths, true)?; + Ok(removed_result(slug, paths.target.clone(), metadata)) + } + AgentOwnership::ManagedMissingFile(metadata) => { + remove_pair(paths, false)?; + Ok(removed_result(slug, paths.target.clone(), metadata)) + } + } +} + +fn conflict_result(slug: &str, paths: &AgentPaths, reason: String) -> AgentLifecycleResult { + AgentLifecycleResult { + status: AgentLifecycleStatus::Conflict, + slug: slug.to_string(), + path: paths.target.clone(), + metadata: None, + reason: Some(reason), + } +} + +fn removed_result( + slug: &str, + path: PathBuf, + metadata: InstalledAgentMetadata, +) -> AgentLifecycleResult { + AgentLifecycleResult { + status: AgentLifecycleStatus::Removed, + slug: slug.to_string(), + path, + metadata: Some(metadata), + reason: None, + } +} + +fn up_to_date_result( + slug: &str, + paths: &AgentPaths, + metadata: InstalledAgentMetadata, + reason: String, +) -> AgentLifecycleResult { + AgentLifecycleResult { + status: AgentLifecycleStatus::UpToDate, + slug: slug.to_string(), + path: paths.target.clone(), + metadata: Some(metadata), + reason: Some(reason), + } +} + +fn download_if_required( + ownership: &AgentOwnership, + action: &str, + download: F, +) -> Result>> +where + F: FnOnce() -> Result>, +{ + if action == "noop" && matches!(ownership, AgentOwnership::ManagedExact(_)) { + Ok(None) + } else { + download().map(Some) + } +} + +fn read_metadata(path: &Path) -> Result { + serde_json::from_slice(&fs::read(path).with_context(|| format!("read {}", path.display()))?) + .with_context(|| format!("parse {}", path.display())) +} + +fn download_agent_document( + client: &MarketplaceClient, + resolution: &AgentInstallResolution, +) -> Result> { + let artifact = resolution + .artifact + .as_ref() + .context("agent install operation did not include artifact metadata")?; + let download = client.download(&artifact.download_url)?; + verify_agent_artifact(&download, artifact)?; + let parent = std::env::temp_dir().join(format!( + "bl-agent-validate-{}-{}", + resolution.plan.slug, + unique_suffix() + )); + fs::create_dir_all(&parent).with_context(|| format!("create {}", parent.display()))?; + let result = (|| { + extract_zip_safely(&download.bytes, &parent)?; + read_agent_document(&parent) + })(); + let _ = fs::remove_dir_all(&parent); + result +} + +fn read_agent_document(root: &Path) -> Result> { + let mut candidates = Vec::new(); + collect_markdown(root, &mut candidates)?; + if candidates.len() != 1 { + anyhow::bail!( + "agent artifact must contain exactly one Agent Markdown document; found {}", + candidates.len() + ); + } + let bytes = + fs::read(&candidates[0]).with_context(|| format!("read {}", candidates[0].display()))?; + validate_agent_document(&bytes)?; + Ok(bytes) +} + +fn collect_markdown(root: &Path, candidates: &mut Vec) -> Result<()> { + for entry in fs::read_dir(root).with_context(|| format!("read {}", root.display()))? { + let entry = entry.with_context(|| format!("read entry in {}", root.display()))?; + let file_type = entry + .file_type() + .with_context(|| format!("stat {}", entry.path().display()))?; + if file_type.is_symlink() { + anyhow::bail!("agent artifact contains a symlink") + } + if file_type.is_dir() { + collect_markdown(&entry.path(), candidates)?; + } else if file_type.is_file() + && entry + .path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) + { + candidates.push(entry.path()); + } + } + Ok(()) +} + +fn validate_agent_document(bytes: &[u8]) -> Result<()> { + let text = std::str::from_utf8(bytes).context("agent document is not UTF-8")?; + let body = text + .strip_prefix("---\n") + .or_else(|| text.strip_prefix("---\r\n")) + .context("agent document must begin with YAML frontmatter")?; + let (frontmatter, persona) = if let Some(index) = body.find("\n---\n") { + (&body[..index], &body[index + 5..]) + } else if let Some(index) = body.find("\r\n---\r\n") { + (&body[..index], &body[index + 7..]) + } else { + anyhow::bail!("agent document frontmatter is not terminated") + }; + let yaml: YamlValue = + serde_yaml::from_str(frontmatter).context("parse agent YAML frontmatter")?; + let map = yaml + .as_mapping() + .context("agent frontmatter must be a mapping")?; + for field in ["name", "description"] { + let value = map + .get(YamlValue::String(field.to_string())) + .and_then(YamlValue::as_str) + .filter(|value| !value.trim().is_empty()); + if value.is_none() { + anyhow::bail!("agent frontmatter requires a nonblank string {field}") + } + } + if persona.trim().is_empty() { + anyhow::bail!("agent document requires a nonblank persona body") + } + Ok(()) +} + +fn metadata_for( + config: &SkillsConfig, + resolution: &AgentInstallResolution, + slug: &str, + document: &[u8], +) -> Result { + let artifact = resolution + .artifact + .as_ref() + .context("agent artifact disappeared during install")?; + Ok(InstalledAgentMetadata { + schema_version: RECORD_SCHEMA.to_string(), + kind: "agent".to_string(), + slug: slug.to_string(), + version_id: resolution.plan.version_id.clone(), + content_sha256: resolution.plan.content_sha256.clone(), + installed_file_sha256: sha256_hex(document), + artifact_id: artifact.id.clone(), + artifact_sha256: artifact.sha256.clone(), + artifact_size_bytes: artifact.size_bytes, + artifact_media_type: artifact.media_type.clone(), + source_id: resolution.version.source.source_id.clone(), + source_snapshot_id: resolution.version.source.snapshot_id.clone(), + source_revision: resolution.version.source.revision.clone(), + source_path: resolution.version.source.path.clone(), + server_url: kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path), + installed_at: iso8601_utc(unix_seconds()?), + installed_via: resolution.installed_via.clone(), + }) +} + +fn replace_pair( + paths: &AgentPaths, + document: &[u8], + metadata: &InstalledAgentMetadata, +) -> Result<()> { + replace_pair_with_rename(paths, document, metadata, |from, to| fs::rename(from, to)) +} + +fn replace_pair_with_rename( + paths: &AgentPaths, + document: &[u8], + metadata: &InstalledAgentMetadata, + mut rename: F, +) -> Result<()> +where + F: FnMut(&Path, &Path) -> std::io::Result<()>, +{ + let target_parent = paths + .target + .parent() + .context("agent target has no parent")?; + let state_parent = paths.state.parent().context("agent state has no parent")?; + fs::create_dir_all(target_parent) + .with_context(|| format!("create {}", target_parent.display()))?; + fs::create_dir_all(state_parent) + .with_context(|| format!("create {}", state_parent.display()))?; + let suffix = unique_suffix(); + let target_stage = target_parent.join(format!( + ".{}.stage-{suffix}", + paths.target.file_name().unwrap().to_string_lossy() + )); + let state_stage = state_parent.join(format!( + ".{}.stage-{suffix}", + paths.state.file_name().unwrap().to_string_lossy() + )); + write_new_file(&target_stage, document)?; + write_new_file( + &state_stage, + &serde_json::to_vec_pretty(metadata).context("serialize agent state")?, + )?; + let target_backup = backup_path(&paths.target, suffix); + let state_backup = backup_path(&paths.state, suffix); + let target_existed = paths.target.exists(); + let state_existed = paths.state.exists(); + let result = (|| -> Result<()> { + if target_existed { + rename(&paths.target, &target_backup) + .with_context(|| format!("backup {}", paths.target.display()))?; + } + if state_existed { + if let Err(error) = rename(&paths.state, &state_backup) { + let operation = + anyhow::Error::new(error).context(format!("backup {}", paths.state.display())); + return Err(with_recovery( + operation, + restore_pair( + &mut rename, + paths, + &target_backup, + &state_backup, + target_existed, + false, + false, + ), + )); + } + } + if let Err(error) = rename(&target_stage, &paths.target) { + let operation = + anyhow::Error::new(error).context(format!("install {}", paths.target.display())); + return Err(with_recovery( + operation, + restore_pair( + &mut rename, + paths, + &target_backup, + &state_backup, + target_existed, + state_existed, + false, + ), + )); + } + if let Err(error) = rename(&state_stage, &paths.state) { + let operation = + anyhow::Error::new(error).context(format!("install {}", paths.state.display())); + return Err(with_recovery( + operation, + restore_pair( + &mut rename, + paths, + &target_backup, + &state_backup, + target_existed, + state_existed, + true, + ), + )); + } + Ok(()) + })(); + match result { + Ok(()) => { + remove_backups(&target_backup, &state_backup)?; + Ok(()) + } + Err(operation) => Err(with_recovery( + operation, + remove_stages(&target_stage, &state_stage), + )), + } +} + +fn remove_pair(paths: &AgentPaths, has_target: bool) -> Result<()> { + let suffix = unique_suffix(); + let target_backup = backup_path(&paths.target, suffix); + let state_backup = backup_path(&paths.state, suffix); + if has_target { + fs::rename(&paths.target, &target_backup) + .with_context(|| format!("stage removal of {}", paths.target.display()))?; + } + if let Err(error) = fs::rename(&paths.state, &state_backup) { + let operation = anyhow::Error::new(error) + .context(format!("stage removal of {}", paths.state.display())); + return Err(with_recovery( + operation, + restore_pair( + &mut |from, to| fs::rename(from, to), + paths, + &target_backup, + &state_backup, + has_target, + false, + false, + ), + )); + } + if has_target { + fs::remove_file(&target_backup) + .with_context(|| format!("remove {}", paths.target.display()))?; + } + fs::remove_file(&state_backup).with_context(|| format!("remove {}", paths.state.display())) +} + +fn restore_pair( + rename: &mut F, + paths: &AgentPaths, + target_backup: &Path, + state_backup: &Path, + target_existed: bool, + state_existed: bool, + target_replaced: bool, +) -> Result<()> +where + F: FnMut(&Path, &Path) -> std::io::Result<()>, +{ + let mut failures = Vec::new(); + if target_replaced { + if let Err(error) = fs::remove_file(&paths.target) { + failures.push(format!( + "remove replacement {}: {error}", + paths.target.display() + )); + } + } + if state_existed { + if let Err(error) = rename(state_backup, &paths.state) { + failures.push(format!("restore {}: {error}", paths.state.display())); + } + } + if target_existed { + if let Err(error) = rename(target_backup, &paths.target) { + failures.push(format!("restore {}: {error}", paths.target.display())); + } + } + if failures.is_empty() { + Ok(()) + } else { + anyhow::bail!(failures.join("; ")) + } +} + +fn with_recovery(operation: anyhow::Error, recovery: Result<()>) -> anyhow::Error { + match recovery { + Ok(()) => operation, + Err(recovery_error) => anyhow::anyhow!( + "{operation:#}; recovery failed: {recovery_error:#}. Inspect the agent and state paths before retrying." + ), + } +} + +fn remove_stages(target_stage: &Path, state_stage: &Path) -> Result<()> { + for stage in [target_stage, state_stage] { + match fs::remove_file(stage) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("remove stage {}", stage.display())) + } + } + } + Ok(()) +} + +fn remove_backups(target_backup: &Path, state_backup: &Path) -> Result<()> { + for backup in [target_backup, state_backup] { + match fs::remove_file(backup) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("remove backup {}", backup.display())) + } + } + } + Ok(()) +} + +fn backup_path(path: &Path, suffix: u128) -> PathBuf { + path.parent().unwrap().join(format!( + ".{}.previous-{suffix}", + path.file_name().unwrap().to_string_lossy() + )) +} + +fn write_new_file(path: &Path, bytes: &[u8]) -> Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("create {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("write {}", path.display()))?; + file.sync_all() + .with_context(|| format!("sync {}", path.display())) +} + +fn unique_suffix() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() +} + +fn unix_seconds() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX epoch")? + .as_secs()) +} + +struct AgentLock { + path: PathBuf, +} + +impl AgentLock { + fn acquire(path: &Path) -> Result { + let parent = path.parent().context("agent lock has no parent")?; + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + for _ in 0..2 { + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + { + Ok(mut file) => { + let _ = writeln!(file, "{}", std::process::id()); + return Ok(Self { + path: path.to_path_buf(), + }); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let stale = fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age.as_secs() > LOCK_STALE_SECS); + if stale { + let _ = fs::remove_file(path); + continue; + } + return Err(failure( + exit_codes::FS_CONFLICT, + "agent_locked", + format!( + "another bl agents operation is running; remove {} if this is stale", + path.display() + ), + )); + } + Err(error) => { + return Err(error).with_context(|| format!("create {}", path.display())) + } + } + } + Err(failure( + exit_codes::FS_CONFLICT, + "agent_locked", + format!("could not acquire {}", path.display()), + )) + } +} + +impl Drop for AgentLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_paths(slug: &str) -> (tempfile::TempDir, AgentPaths) { + let root = tempfile::tempdir().unwrap(); + let paths = AgentPaths { + target: root.path().join("agents").join(format!("{slug}.md")), + state: root.path().join("state").join(format!("{slug}.json")), + lock: root.path().join("lock"), + }; + (root, paths) + } + + fn record(slug: &str, contents: &[u8]) -> InstalledAgentMetadata { + InstalledAgentMetadata { + schema_version: RECORD_SCHEMA.to_string(), + kind: "agent".to_string(), + slug: slug.to_string(), + version_id: "v1".to_string(), + content_sha256: "content".to_string(), + installed_file_sha256: sha256_hex(contents), + artifact_id: "artifact".to_string(), + artifact_sha256: "artifact-sha".to_string(), + artifact_size_bytes: 1, + artifact_media_type: "application/zip".to_string(), + source_id: "source".to_string(), + source_snapshot_id: "snapshot".to_string(), + source_revision: "revision".to_string(), + source_path: "agents/demo.md".to_string(), + server_url: "http://localhost".to_string(), + installed_at: "0Z".to_string(), + installed_via: "explicit".to_string(), + } + } + + #[test] + fn rejects_unsafe_slugs() { + for slug in ["", "../demo", "Demo", "a/b", "a.b"] { + assert!(validate_slug(slug).is_err()); + } + assert!(validate_slug("release-notes_2").is_ok()); + } + + #[test] + fn classifies_absent_exact_missing_and_changed_content() { + let (_root, paths) = temp_paths("demo"); + assert!(matches!( + classify(&paths, "demo").unwrap(), + AgentOwnership::Absent + )); + let contents = b"---\nname: Demo\ndescription: Test\n---\nPersona"; + fs::create_dir_all(paths.target.parent().unwrap()).unwrap(); + fs::create_dir_all(paths.state.parent().unwrap()).unwrap(); + fs::write(&paths.target, contents).unwrap(); + fs::write( + &paths.state, + serde_json::to_vec(&record("demo", contents)).unwrap(), + ) + .unwrap(); + assert!(matches!( + classify(&paths, "demo").unwrap(), + AgentOwnership::ManagedExact(_) + )); + fs::remove_file(&paths.target).unwrap(); + assert!(matches!( + classify(&paths, "demo").unwrap(), + AgentOwnership::ManagedMissingFile(_) + )); + fs::write(&paths.target, b"changed").unwrap(); + assert!(matches!( + classify(&paths, "demo").unwrap(), + AgentOwnership::ProtectedConflict { .. } + )); + } + + #[test] + fn validates_agent_document_contract() { + assert!( + validate_agent_document(b"---\nname: Demo\ndescription: Test\n---\nPersona").is_ok() + ); + assert!(validate_agent_document(b"---\nname: Demo\n---\nPersona").is_err()); + assert!(validate_agent_document(b"---\nname: Demo\ndescription: Test\n---\n ").is_err()); + } + + #[test] + fn accepts_crlf_frontmatter_with_a_short_persona() { + assert!( + validate_agent_document(b"---\r\nname: Demo\r\ndescription: Test\r\n---\r\nI").is_ok() + ); + } + + #[test] + fn restores_existing_pair_when_target_stage_rename_fails() { + let (_root, paths) = temp_paths("demo"); + let old_document = b"---\nname: Demo\ndescription: Old\n---\nOld"; + let old_state = serde_json::to_vec(&record("demo", old_document)).unwrap(); + fs::create_dir_all(paths.target.parent().unwrap()).unwrap(); + fs::create_dir_all(paths.state.parent().unwrap()).unwrap(); + fs::write(&paths.target, old_document).unwrap(); + fs::write(&paths.state, &old_state).unwrap(); + + let replacement = b"---\nname: Demo\ndescription: New\n---\nNew"; + let error = replace_pair_with_rename( + &paths, + replacement, + &record("demo", replacement), + |from, to| { + if from.parent() == paths.target.parent() + && from + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with(".demo.md.stage-")) + { + return Err(std::io::Error::other( + "injected target-stage rename failure", + )); + } + fs::rename(from, to) + }, + ) + .unwrap_err(); + + assert!(format!("{error:#}").contains("injected target-stage rename failure")); + assert_eq!(fs::read(&paths.target).unwrap(), old_document); + assert_eq!(fs::read(&paths.state).unwrap(), old_state); + } + + #[test] + fn reports_failed_pair_recovery_with_canonical_paths() { + let (_root, paths) = temp_paths("demo"); + fs::create_dir_all(paths.target.parent().unwrap()).unwrap(); + let suffix = unique_suffix(); + let recovery = restore_pair( + &mut |_, _| Err(std::io::Error::other("injected restore failure")), + &paths, + &backup_path(&paths.target, suffix), + &backup_path(&paths.state, suffix), + true, + true, + false, + ); + + let error = with_recovery(anyhow::anyhow!("install failed"), recovery); + + let message = format!("{error:#}"); + assert!(message.contains("recovery failed")); + assert!(message.contains(&paths.target.display().to_string())); + assert!(message.contains(&paths.state.display().to_string())); + } + + #[test] + fn removes_orphaned_managed_state_without_requiring_a_target_backup() { + let (_root, paths) = temp_paths("demo"); + fs::create_dir_all(paths.state.parent().unwrap()).unwrap(); + fs::write( + &paths.state, + serde_json::to_vec(&record("demo", b"old")).unwrap(), + ) + .unwrap(); + + remove_pair(&paths, false).unwrap(); + + assert!(!paths.target.exists()); + assert!(!paths.state.exists()); + } + + #[test] + fn protected_removal_preserves_changed_agent_and_state() { + let (_root, paths) = temp_paths("demo"); + let original = b"---\nname: Demo\ndescription: Managed\n---\nPersona"; + let changed = b"---\nname: Demo\ndescription: Local\n---\nPersona"; + let state = serde_json::to_vec(&record("demo", original)).unwrap(); + fs::create_dir_all(paths.target.parent().unwrap()).unwrap(); + fs::create_dir_all(paths.state.parent().unwrap()).unwrap(); + fs::write(&paths.target, changed).unwrap(); + fs::write(&paths.state, &state).unwrap(); + + let result = remove_at_paths(&paths, "demo").unwrap(); + + assert_eq!(result.status, AgentLifecycleStatus::Conflict); + assert_eq!(fs::read(&paths.target).unwrap(), changed); + assert_eq!(fs::read(&paths.state).unwrap(), state); + } + + #[test] + fn up_to_date_result_does_not_write_the_managed_pair() { + let (_root, paths) = temp_paths("demo"); + let document = b"---\nname: Demo\ndescription: Test\n---\nPersona"; + let state = serde_json::to_vec(&record("demo", document)).unwrap(); + fs::create_dir_all(paths.target.parent().unwrap()).unwrap(); + fs::create_dir_all(paths.state.parent().unwrap()).unwrap(); + fs::write(&paths.target, document).unwrap(); + fs::write(&paths.state, &state).unwrap(); + + let mut downloaded = false; + let downloaded_document = download_if_required( + &AgentOwnership::ManagedExact(record("demo", document)), + "noop", + || { + downloaded = true; + anyhow::bail!("a no-op must not download an artifact") + }, + ) + .unwrap(); + let result = up_to_date_result( + "demo", + &paths, + record("demo", document), + "current".to_string(), + ); + + assert!(downloaded_document.is_none()); + assert!(!downloaded); + assert_eq!(result.status, AgentLifecycleStatus::UpToDate); + assert_eq!(fs::read(&paths.target).unwrap(), document); + assert_eq!(fs::read(&paths.state).unwrap(), state); + } +} diff --git a/src/bl/agents_models.rs b/src/bl/agents_models.rs new file mode 100644 index 0000000..9dc9af6 --- /dev/null +++ b/src/bl/agents_models.rs @@ -0,0 +1,213 @@ +//! Marketplace DTOs used exclusively by `bl agents`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const AGENT_OPERATION_KIND: &str = "agent"; + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentCatalogPage { + pub items: Vec, + #[serde(default)] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentSummary { + pub slug: String, + pub name: String, + pub description: String, + pub status: String, + pub enabled: bool, + pub latest_version_id: String, + pub latest_content_sha256: String, + pub source_id: String, + pub source_revision: String, + pub source_path: String, + pub tags: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentDetail { + pub slug: String, + pub name: String, + pub description: String, + pub status: String, + pub enabled: bool, + pub latest_version_id: String, + pub latest_content_sha256: String, + pub source_id: String, + pub source_revision: String, + pub source_path: String, + pub tags: Vec, + pub latest_version: AgentVersionDetail, + pub versions: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentVersionSummary { + pub id: String, + pub status: String, + pub content_sha256: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentVersionDetail { + pub id: String, + pub slug: String, + pub name: String, + pub status: String, + pub content_sha256: String, + pub artifact: AgentReadArtifact, + pub source: AgentVersionSource, + pub created_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentVersion { + pub id: String, + pub slug: String, + pub name: String, + pub status: String, + pub content_sha256: String, + pub artifact: AgentReadArtifact, + pub source: AgentVersionSource, + pub created_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentVersionSource { + pub source_id: String, + pub snapshot_id: String, + pub revision: String, + pub path: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentReadArtifact { + pub id: String, + pub sha256: String, + pub size_bytes: u64, + pub media_type: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentInstallArtifact { + pub id: String, + pub download_url: String, + pub sha256: String, + pub size_bytes: u64, + pub media_type: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AgentInstallPlanRequest { + pub scope: String, + pub targets: Vec, + pub installed: Vec, + pub client: BTreeMap, + pub include_dependencies: bool, + pub allow_removals: bool, + pub dry_run: bool, +} + +impl AgentInstallPlanRequest { + pub fn for_agent( + slug: impl Into, + version_id: Option, + installed: Vec, + ) -> Self { + Self { + scope: "global".to_string(), + targets: vec![AgentInstallTarget { + target_type: AGENT_OPERATION_KIND.to_string(), + slug: slug.into(), + version_id, + }], + installed, + client: BTreeMap::new(), + include_dependencies: false, + allow_removals: false, + dry_run: false, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct AgentInstallTarget { + #[serde(rename = "type")] + pub target_type: String, + pub slug: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct InstalledAgentRequest { + pub slug: String, + pub version_id: Option, + pub content_sha256: Option, + pub scope: Option, + pub targets: Vec, + pub installed_via: Option, + pub local_source: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentInstallPlan { + pub operations: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentInstallOperation { + pub action: String, + pub reason: String, + pub kind: String, + pub skill: AgentPlanContent, + pub artifact: Option, + pub installed_via: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentPlanContent { + pub slug: String, + pub version_id: String, + pub content_sha256: String, +} + +#[derive(Debug, Clone)] +pub struct AgentInstallResolution { + pub action: String, + pub reason: String, + pub agent: AgentDetail, + pub version: AgentVersion, + pub plan: AgentPlanContent, + pub artifact: Option, + pub installed_via: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentOperationError { + Missing { slug: String }, + WrongKind { slug: String, actual: String }, +} + +impl std::fmt::Display for AgentOperationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Missing { slug } => write!( + formatter, + "install plan has no operation for requested agent `{slug}`" + ), + Self::WrongKind { slug, actual } => write!( + formatter, + "install plan operation for `{slug}` has kind `{actual}`; expected `{AGENT_OPERATION_KIND}`" + ), + } + } +} + +impl std::error::Error for AgentOperationError {} diff --git a/src/bl/apps.rs b/src/bl/apps.rs new file mode 100644 index 0000000..a4e8cd2 --- /dev/null +++ b/src/bl/apps.rs @@ -0,0 +1,4675 @@ +//! External BuilderLab Apps Platform control-plane commands. +//! +//! This module serves only the external pilot: first in `bl-block` staging, +//! then in the multi-tenant `bl-public` environment. It does not replace the +//! existing Cloudflare-backed internal Block App Kit CLI exposed through +//! `bl tools appkit`, and it does not migrate the separate internal Compose +//! workflow. Both internal paths remain unchanged. +//! +//! The CLI sends its stored blidentity session only to the allowlisted Compose +//! control-plane origins. Public ingress authorizes that session through kgoose +//! `ext_authz` and removes it before forwarding the request internally. Compose +//! never receives the session credential. + +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use builderlab_auth::auth_login::auth_url; +#[cfg(test)] +use builderlab_auth::auth_login::build_auth_http_client; +use builderlab_auth::auth_storage::StoredSessionCredential; +use clap::{Arg, ArgGroup, ArgMatches, Command}; +use reqwest::blocking::{multipart, Client, Request, RequestBuilder, Response}; +use reqwest::header::{HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; +use reqwest::redirect::Policy; +use reqwest::StatusCode; +use serde::Serialize; +use serde_json::{json, Map, Value}; + +use super::auth_login::verify_stored_session; +use super::auth_storage::default_session_storage; +use super::display::{print_json, terminal_safe_text, Style}; +use super::runner; +#[cfg(test)] +use super::skills_api::failure_info; +use super::skills_api::{exit_codes, failure, CliFailure}; +use super::skills_config::SkillsConfig; + +const APPS_BASE_URL_ENV_VAR: &str = "BL_APPS_CONTROL_PLANE_URL"; +const APPS_CLIENT_VERSION_ENV_VAR: &str = "BL_APPS_CLIENT_VERSION"; +#[cfg(test)] +const APPS_E2E_CONTROL_PLANE_URL_ENV_VAR: &str = "BL_APPS_E2E_CONTROL_PLANE_URL"; +#[cfg(test)] +const APPS_E2E_AUTH_URL_ENV_VAR: &str = "BL_APPS_E2E_AUTH_URL"; +#[cfg(test)] +const APPS_E2E_CREDENTIAL_ENV_VAR: &str = "BL_APPS_E2E_CREDENTIAL"; +const APPS_CONTRACT_PATH: &str = "/v1/agent/contract"; +const APPS_PLAN_PATH: &str = "/v1/agent/apps/plan"; +const MAX_DEBUG_TAIL_LINES: u16 = 1000; +const HOTPOD_AGENT_CLIENT_VERSION_HEADER: &str = "X-Hotpod-Agent-Client-Version"; +// Compose may synchronously wait up to two minutes for an initialize or +// deploy rollout. Leave enough headroom for the response to traverse ingress. +const CONTROL_PLANE_REQUEST_TIMEOUT: Duration = Duration::from_secs(3 * 60); +const CONTROL_PLANE_RESPONSE_MAX_BYTES: usize = 2 * 1024 * 1024; +const TRUSTED_CONTROL_PLANE_HOSTS: &[&str] = &[ + "compose-ctrl.test.blockstaging.build", + "compose-ctrl.app.builderlab.xyz", +]; + +pub fn command() -> Command { + Command::new("apps") + .about("Manage apps through Apps Platform") + .long_about( + "Manage apps through the BuilderLab Apps Platform control plane on Compose, first in \ + `bl-block` staging and then in multi-tenant `bl-public`. This does not replace the \ + Cloudflare-backed internal App Kit CLI (`bl tools appkit`) or migrate the separate internal \ + Compose workflow.", + ) + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand(control_plane_args( + Command::new("contract") + .about( + "Read the control-plane contract, runtime metadata, and supported operations", + ), + )) + .subcommand(control_plane_args( + Command::new("list") + .about("List apps the current caller can manage") + .long_about( + "List Apps Platform apps the current caller owns or is approved to publish. \ + Deleted apps remain hidden unless explicitly included.", + ) + .arg( + Arg::new("scope") + .long("scope") + .value_name("SCOPE") + .value_parser(["manageable", "owned", "publisher"]) + .help("Filter by relationship to the app (control-plane default: manageable)"), + ) + .arg( + Arg::new("include-deleted") + .long("include-deleted") + .action(clap::ArgAction::SetTrue) + .help("Include logically deleted apps"), + ), + )) + .subcommand(control_plane_args( + Command::new("get") + .about("Get one manageable app and its recorded versions") + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bl apps list` or `bl apps create`"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Optional Compose environment override"), + ), + )) + .subcommand(control_plane_args( + Command::new("versions") + .about("List active and rollback-candidate versions for an app") + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bl apps list` or `bl apps create`"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Optional Compose environment override"), + ), + )) + .subcommand(control_plane_args( + Command::new("create") + .about("Plan and reserve an app") + .long_about( + "Plan an app identity through Apps Platform, reserve that exact ID, then \ + initialize it only when the returned plan marks initialization as required \ + or recommended. Supply a descriptive DNS-safe app ID or a human-readable \ + name from which the control plane can derive one. If creation is interrupted \ + after reservation, repeat the same command to reconcile the caller-owned \ + idle reservation and continue initialization.", + ) + .group( + ArgGroup::new("app-identity") + .args(["app-id", "name"]) + .multiple(true) + .required(true), + ) + .arg( + Arg::new("app-id") + .long("app-id") + .value_name("APP_ID") + .value_parser(clap::builder::NonEmptyStringValueParser::new()) + .help("Descriptive DNS-safe app identifier to reserve exactly"), + ) + .arg( + Arg::new("name") + .long("name") + .value_name("NAME") + .value_parser(clap::builder::NonEmptyStringValueParser::new()) + .help("Human-readable app name"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Compose environment to plan and initialize"), + ) + .arg( + Arg::new("runtime-profile") + .long("runtime-profile") + .value_name("PROFILE") + .help("Artifact runtime profile advertised by the control-plane contract"), + ) + .arg( + Arg::new("persistence") + .long("persistence") + .value_name("MODE") + .value_parser(["none", "sqlite"]) + .help("Requested persistence mode"), + ), + )) + .subcommand(control_plane_args( + Command::new("deploy") + .about("Deploy a prebuilt app artifact") + .long_about( + "Upload a prebuilt Hot Pod artifact.tar.gz to Apps Platform. The response \ + includes the deployed URL and control-plane readiness and diagnostics endpoints.", + ) + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bl apps create`"), + ) + .arg( + Arg::new("artifact") + .value_name("ARTIFACT_TAR_GZ") + .required(true) + .value_parser(clap::value_parser!(PathBuf)) + .help("Path to the prebuilt artifact.tar.gz"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Optional Compose environment override"), + ) + .arg( + Arg::new("version-id") + .long("version-id") + .value_name("VERSION_ID") + .help("Optional idempotent version identifier"), + ) + .arg( + Arg::new("deployment-id") + .long("deployment-id") + .value_name("DEPLOYMENT_ID") + .help("Optional deployment identifier"), + ), + )) + .subcommand(control_plane_args( + Command::new("rollback") + .about("Roll back an app to a previous or selected version") + .long_about( + "Request one Apps Platform rollback. Omit --version-id to select the previous \ + active version, or pass an uploaded version explicitly. The response preserves \ + the control-plane rollback, readiness, and next-call fields without hidden polling.", + ) + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bl apps list` or `bl apps create`"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Optional Compose environment override"), + ) + .arg( + Arg::new("version-id") + .long("version-id") + .value_name("VERSION_ID") + .help("Uploaded version to activate; omit to select the previous version"), + ), + )) + .subcommand(control_plane_args( + Command::new("delete") + .about("Logically delete an app and retire its active route") + .long_about( + "Request one owner-only Apps Platform logical deletion. The active route is \ + retired while uploaded versions, artifacts, and stack resources are retained. \ + --confirm-app-id and --confirm-environment must exactly match APP_ID and \ + --environment.", + ) + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bl apps list` or `bl apps create`"), + ) + .arg( + Arg::new("confirm-app-id") + .long("confirm-app-id") + .value_name("APP_ID") + .required(true) + .help("Repeat the exact app identifier to confirm logical deletion"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .required(true) + .help("Exact Compose environment containing the app"), + ) + .arg( + Arg::new("confirm-environment") + .long("confirm-environment") + .value_name("ENVIRONMENT") + .required(true) + .help("Repeat the exact environment to confirm logical deletion"), + ), + )) + .subcommand(control_plane_args( + Command::new("ready") + .about("Check readiness for an exact deployed app version") + .long_about( + "Request one control-plane readiness snapshot for an exact deployed app version. \ + The response includes active-route, runner, readiness, and diagnostic fields; \ + callers can follow the returned guidance to poll again.", + ) + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bl apps create`"), + ) + .arg( + Arg::new("version-id") + .long("version-id") + .value_name("VERSION_ID") + .required(true) + .help("Exact version identifier returned by `bl apps deploy`"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Optional Compose environment override"), + ), + )) + .subcommand(control_plane_args( + Command::new("debug") + .about("Collect a bounded diagnostic snapshot for an app") + .long_about( + "Request one control-plane diagnostic snapshot, preserving partial results when \ + individual collectors fail. Optionally correlate the snapshot to a deployed \ + version and control the number of log lines collected per container.", + ) + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bl apps create`"), + ) + .arg( + Arg::new("version-id") + .long("version-id") + .value_name("VERSION_ID") + .help("Optional version identifier to correlate with the active route"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Optional Compose environment override"), + ) + .arg( + Arg::new("tail-lines") + .long("tail-lines") + .value_name("N") + .value_parser(clap::value_parser!(u16).range(1..=MAX_DEBUG_TAIL_LINES.into())) + .help("Log lines to collect per container (1-1000; control-plane default: 200)"), + ), + )) + .subcommand( + Command::new("access") + .about("Read or update an app's viewer access policy") + .long_about( + "Read or update an Apps Platform app's visibility and explicit viewer list. \ + Approved publishers may read access settings, while only the original owner \ + may update them.", + ) + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand(control_plane_args( + Command::new("get") + .about("Get an app's current visibility and viewer access") + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bl apps list` or `bl apps create`"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Optional Compose environment override"), + ), + )) + .subcommand(control_plane_args( + Command::new("set") + .about("Replace an app's visibility and explicit viewer list") + .long_about( + "Replace an app's complete access policy. For restricted visibility, \ + repeat --viewer for each explicit viewer, or pass --clear-viewers to \ + explicitly clear the list. The owner and approved publishers remain \ + effective viewers. Only the original owner may update access. Ask each \ + intended viewer to copy the exact caller value from `bl apps list --json`.", + ) + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bl apps list` or `bl apps create`"), + ) + .arg( + Arg::new("visibility") + .long("visibility") + .value_name("VISIBILITY") + .value_parser(["organization", "restricted"]) + .required(true) + .help("Who may view the app"), + ) + .arg( + Arg::new("viewer") + .long("viewer") + .value_name("IDENTITY") + .action(clap::ArgAction::Append) + .help( + "Exact case-sensitive Apps Platform user subject (for example, \ + auth0|...); ask the viewer to copy `caller` from `bl apps list \ + --json`; repeat for each viewer", + ), + ) + .arg( + Arg::new("clear-viewers") + .long("clear-viewers") + .action(clap::ArgAction::SetTrue) + .conflicts_with("viewer") + .help( + "Confirm replacing the explicit viewer list with an empty list", + ), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Optional Compose environment override"), + ), + )), + ) +} + +fn control_plane_args(command: Command) -> Command { + command + .arg( + Arg::new("apps-base-url") + .long("base-url") + .visible_alias("control-plane-url") + .value_name("URL") + .env(APPS_BASE_URL_ENV_VAR) + .required(true) + .help("Approved BuilderLab Compose control-plane ingress URL"), + ) + .arg( + Arg::new("apps-client-version") + .long("client-version") + .value_name("VERSION") + .env(APPS_CLIENT_VERSION_ENV_VAR) + .default_value(env!("CARGO_PKG_VERSION")) + .help("Agent client version sent to the Compose control plane"), + ) +} + +pub fn describe_commands() -> Value { + super::description::describe_command_tree(&command()) +} + +pub fn run(matches: &ArgMatches) -> Result<()> { + runner::run(matches, dispatch) +} + +fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + runner::ensure_org_configured(config)?; + match matches.subcommand() { + Some(("contract", contract_matches)) => run_contract(config, contract_matches), + Some(("list", list_matches)) => run_list(config, list_matches), + Some(("get", get_matches)) => run_get(config, get_matches), + Some(("versions", versions_matches)) => run_versions(config, versions_matches), + Some(("create", create_matches)) => run_create(config, create_matches), + Some(("deploy", deploy_matches)) => run_deploy(config, deploy_matches), + Some(("rollback", rollback_matches)) => run_rollback(config, rollback_matches), + Some(("delete", delete_matches)) => run_delete(config, delete_matches), + Some(("ready", ready_matches)) => run_ready(config, ready_matches), + Some(("debug", debug_matches)) => run_debug(config, debug_matches), + Some(("access", access_matches)) => run_access(config, access_matches), + _ => anyhow::bail!("expected an apps subcommand"), + } +} + +fn run_contract(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let base_url = matches + .get_one::("apps-base-url") + .context("expected Apps Platform control-plane URL")?; + let client_version = matches + .get_one::("apps-client-version") + .context("expected Apps Platform client version")?; + + let client = ControlPlaneClient::new(base_url, client_version, config.style)?; + let credential = ComposeSessionCredential::from_config(config)?; + let contract = client.contract(&credential)?; + print_json(&contract) +} + +fn run_list(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let scope = matches.get_one::("scope").map(String::as_str); + let include_deleted = matches.get_flag("include-deleted"); + let (client, credential) = control_plane_context(config, matches)?; + let response = client.list_apps(&credential, scope, include_deleted)?; + print_json(&response) +} + +fn run_get(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let environment = matches.get_one::("environment").map(String::as_str); + let (client, credential) = control_plane_context(config, matches)?; + let response = client.get_app(&credential, app_id, environment)?; + print_json(&response) +} + +fn run_versions(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let environment = matches.get_one::("environment").map(String::as_str); + let (client, credential) = control_plane_context(config, matches)?; + let response = client.versions(&credential, app_id, environment)?; + print_json(&response) +} + +fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let (client, credential) = control_plane_context(config, matches)?; + let requested_app_id = matches.get_one::("app-id").map(String::as_str); + let request = PlanRequest { + app_id: requested_app_id, + name: matches.get_one::("name").map(String::as_str), + environment: matches.get_one::("environment").map(String::as_str), + runtime_profile: matches + .get_one::("runtime-profile") + .map(String::as_str), + persistence: matches.get_one::("persistence").map(String::as_str), + client_version: client.client_version_text(), + }; + let plan = client.plan(&credential, &request)?; + let app_id = required_response_string(&plan, "app_id", "Apps Platform plan")?.to_string(); + if let Some(requested_app_id) = requested_app_id { + require_exact_app_id("plan", requested_app_id, &app_id)?; + } + let initialize_required = plan + .pointer("/initialize/required") + .and_then(Value::as_bool) + .context( + "Apps Platform plan response did not include boolean initialize.required; refusing to reserve the app", + )?; + let initialize_recommended = plan + .pointer("/initialize/recommended") + .and_then(Value::as_bool) + .context( + "Apps Platform plan response did not include boolean initialize.recommended; refusing to reserve the app", + )?; + let plan_requests_initialize = initialize_required || initialize_recommended; + let mutation_request = mutation_request_from_plan(&plan); + let (reservation, reservation_reconciled) = + reconcile_create_reservation(&client, &credential, &app_id, &plan, &mutation_request)?; + let initialize = if plan_requests_initialize { + let response = client + .initialize(&credential, &app_id, &mutation_request) + .with_context(|| { + format!( + "Apps Platform reserved app_id {app_id:?}, but initialization did not complete. Retry the same `bl apps create` command to reconcile the reservation and continue initialization." + ) + })?; + let initialized_app_id = + required_response_string(&response, "app_id", "Apps Platform initialize")?; + require_exact_app_id("initialize", &app_id, initialized_app_id)?; + Some(response) + } else { + None + }; + let effective_external_url = match initialize.as_ref() { + Some(response) => Value::String( + required_response_string(response, "external_url", "Apps Platform initialize")? + .to_string(), + ), + None => reservation + .get("external_url") + .cloned() + .unwrap_or(Value::Null), + }; + print_json(&json!({ + "ok": true, + "app_id": app_id, + "external_url": effective_external_url, + "reserved": true, + "reservation_reconciled": reservation_reconciled, + "initialized": initialize.is_some(), + "plan": plan, + "reservation": reservation, + "initialize": initialize, + })) +} + +fn reconcile_create_reservation( + client: &ControlPlaneClient, + credential: &ComposeSessionCredential, + app_id: &str, + plan: &Value, + mutation_request: &Value, +) -> Result<(Value, bool)> { + match client.reserve(credential, app_id, mutation_request) { + Ok(reservation) => { + let reserved_app_id = + required_response_string(&reservation, "app_id", "Apps Platform reserve")?; + require_exact_app_id("reserve", app_id, reserved_app_id)?; + Ok((reservation, false)) + } + Err(reserve_error) => { + let environment = mutation_request.get("environment").and_then(Value::as_str); + match client.get_app(credential, app_id, environment) { + Ok(existing) + if is_matching_incomplete_reservation(&existing, app_id, mutation_request) => + { + Ok(( + json!({ + "ok": true, + "app_id": app_id, + "external_url": plan.get("external_url").cloned().unwrap_or(Value::Null), + "reconciled": true, + "app": existing.get("app").cloned().unwrap_or(Value::Null), + }), + true, + )) + } + Ok(_) if reservation_outcome_is_unknown(&reserve_error) => { + let mismatch = anyhow::anyhow!( + "the inspected app was not the same caller-owned, incomplete reservation" + ); + Err(reservation_outcome_unknown( + app_id, + environment, + &reserve_error, + &mismatch, + )) + } + Ok(_) => Err(reserve_error), + Err(inspect_error) if reservation_outcome_is_unknown(&reserve_error) => { + Err(reservation_outcome_unknown( + app_id, + environment, + &reserve_error, + &inspect_error, + )) + } + Err(_) => Err(reserve_error), + } + } + } +} + +fn is_matching_incomplete_reservation( + response: &Value, + app_id: &str, + mutation_request: &Value, +) -> bool { + let Some(app) = response.get("app").and_then(Value::as_object) else { + return false; + }; + if response.get("ok").and_then(Value::as_bool) != Some(true) + || response + .get("versions") + .and_then(Value::as_array) + .is_none_or(|versions| !versions.is_empty()) + || app.get("app_id").and_then(Value::as_str) != Some(app_id) + || app.get("role").and_then(Value::as_str) != Some("owner") + || app.get("route_status").and_then(Value::as_str) != Some("idle") + || app.get("route_revision").and_then(Value::as_u64) != Some(1) + || !value_is_absent_or_empty_text(app.get("active_version_id")) + || !value_is_absent_or_empty_text(app.get("version_id")) + || !value_is_absent_or_empty_text(app.get("deleted_at")) + { + return false; + } + [ + ("environment", "environment"), + ("persistence", "persistence"), + ("runtime_class", "runtime_class"), + ("name", "name"), + ] + .into_iter() + .all(|(request_field, app_field)| { + mutation_request + .get(request_field) + .and_then(Value::as_str) + .is_none_or(|expected| app.get(app_field).and_then(Value::as_str) == Some(expected)) + }) +} + +fn value_is_absent_or_empty_text(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => true, + Some(Value::String(text)) => text.is_empty(), + Some(_) => false, + } +} + +fn reservation_outcome_is_unknown(error: &anyhow::Error) -> bool { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .is_none_or(|failure| failure.code == "network_error") +} + +fn reservation_outcome_unknown( + app_id: &str, + environment: Option<&str>, + reserve_error: &anyhow::Error, + inspect_error: &anyhow::Error, +) -> anyhow::Error { + let environment_argument = environment + .map(|value| format!(" --environment {value}")) + .unwrap_or_default(); + failure( + exit_codes::NETWORK, + "reservation_outcome_unknown", + format!( + "Apps Platform may have reserved app_id {app_id:?}, but the CLI did not receive a complete reservation result and could not verify the app.\n\ + reserve_error: {reserve_error:#}\n\ + inspection_error: {inspect_error:#}\n\ + next_action: Run `bl apps get {app_id}{environment_argument}`. If it reports a caller-owned app with route_status `idle`, retry the same `bl apps create` command; the retry will reconcile that reservation and continue initialization." + ), + ) +} + +fn require_exact_app_id(stage: &str, expected: &str, actual: &str) -> Result<()> { + if actual != expected { + anyhow::bail!( + "Apps Platform {stage} returned app_id {actual:?}; expected exact app_id {expected:?}. No replacement app was accepted." + ); + } + Ok(()) +} + +fn run_deploy(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let artifact = matches + .get_one::("artifact") + .context("expected artifact.tar.gz path")?; + validate_artifact_path(artifact)?; + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let options = DeployOptions { + environment: matches.get_one::("environment").cloned(), + version_id: matches.get_one::("version-id").cloned(), + deployment_id: matches.get_one::("deployment-id").cloned(), + }; + let (client, credential) = control_plane_context(config, matches)?; + let response = client.deploy(&credential, app_id, artifact, &options)?; + print_json(&response) +} + +fn run_rollback(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let request = RollbackRequest { + environment: matches.get_one::("environment").map(String::as_str), + version_id: matches.get_one::("version-id").map(String::as_str), + }; + let (client, credential) = control_plane_context(config, matches)?; + let response = client.rollback(&credential, app_id, &request)?; + print_json(&response) +} + +fn run_delete(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let confirm_app_id = matches + .get_one::("confirm-app-id") + .context("expected delete confirmation app id")?; + let environment = matches + .get_one::("environment") + .context("expected delete environment")?; + let confirm_environment = matches + .get_one::("confirm-environment") + .context("expected delete confirmation environment")?; + validate_delete_confirmation(app_id, environment, confirm_app_id, confirm_environment)?; + let request = DeleteAppRequest { environment }; + let (client, credential) = control_plane_context(config, matches)?; + let response = client.delete_app(&credential, app_id, &request)?; + print_json(&response) +} + +fn validate_delete_confirmation( + app_id: &str, + environment: &str, + confirm_app_id: &str, + confirm_environment: &str, +) -> Result<()> { + if confirm_app_id != app_id { + anyhow::bail!("delete requires --confirm-app-id to exactly match APP_ID ({app_id})"); + } + if confirm_environment != environment { + anyhow::bail!( + "delete requires --confirm-environment to exactly match --environment ({environment})" + ); + } + Ok(()) +} + +fn run_ready(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let version_id = matches + .get_one::("version-id") + .context("expected version id")?; + let environment = matches.get_one::("environment").map(String::as_str); + let (client, credential) = control_plane_context(config, matches)?; + let response = client.ready(&credential, app_id, version_id, environment)?; + print_json(&response) +} + +fn run_debug(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let environment = matches.get_one::("environment").map(String::as_str); + let version_id = matches.get_one::("version-id").map(String::as_str); + let tail_lines = matches.get_one::("tail-lines").copied(); + let (client, credential) = control_plane_context(config, matches)?; + let response = client.debug(&credential, app_id, environment, version_id, tail_lines)?; + print_json(&response) +} + +fn run_access(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + match matches.subcommand() { + Some(("get", get_matches)) => run_access_get(config, get_matches), + Some(("set", set_matches)) => run_access_set(config, set_matches), + _ => anyhow::bail!("expected an access subcommand"), + } +} + +fn run_access_get(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let environment = matches.get_one::("environment").map(String::as_str); + let (client, credential) = control_plane_context(config, matches)?; + let response = client.get_access(&credential, app_id, environment)?; + print_json(&response) +} + +fn run_access_set(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let visibility = matches + .get_one::("visibility") + .context("expected access visibility")?; + let viewers: Vec<&str> = matches + .get_many::("viewer") + .into_iter() + .flatten() + .map(String::as_str) + .collect(); + if visibility == "restricted" && viewers.is_empty() && !matches.get_flag("clear-viewers") { + anyhow::bail!( + "restricted visibility requires at least one --viewer or explicit --clear-viewers confirmation" + ); + } + let request = AccessRequest { + visibility, + viewers, + environment: matches.get_one::("environment").map(String::as_str), + }; + let (client, credential) = control_plane_context(config, matches)?; + let response = client.set_access(&credential, app_id, &request)?; + print_json(&response) +} + +fn control_plane_context( + config: &SkillsConfig, + matches: &ArgMatches, +) -> Result<(ControlPlaneClient, ComposeSessionCredential)> { + let base_url = matches + .get_one::("apps-base-url") + .context("expected Apps Platform control-plane URL")?; + let client_version = matches + .get_one::("apps-client-version") + .context("expected Apps Platform client version")?; + let client = ControlPlaneClient::new(base_url, client_version, config.style)?; + let credential = ComposeSessionCredential::from_config(config)?; + Ok((client, credential)) +} + +#[derive(Serialize)] +struct PlanRequest<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + app_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + environment: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + runtime_profile: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + persistence: Option<&'a str>, + client_version: &'a str, +} + +#[derive(Serialize)] +struct RollbackRequest<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + environment: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + version_id: Option<&'a str>, +} + +#[derive(Serialize)] +struct DeleteAppRequest<'a> { + environment: &'a str, +} + +#[derive(Serialize)] +struct AccessRequest<'a> { + visibility: &'a str, + viewers: Vec<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + environment: Option<&'a str>, +} + +#[derive(Default)] +struct DeployOptions { + environment: Option, + version_id: Option, + deployment_id: Option, +} + +fn mutation_request_from_plan(plan: &Value) -> Value { + let mut request = Map::new(); + for field in ["environment", "persistence", "runtime_class"] { + if let Some(value) = plan.get(field).and_then(Value::as_str) { + if !value.is_empty() { + request.insert(field.to_string(), Value::String(value.to_string())); + } + } + } + if let Some(display_name) = plan.get("display_name").and_then(Value::as_str) { + if !display_name.is_empty() { + request.insert("name".to_string(), Value::String(display_name.to_string())); + } + } + Value::Object(request) +} + +fn required_response_string<'a>( + value: &'a Value, + field: &str, + description: &str, +) -> Result<&'a str> { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .with_context(|| format!("{description} response did not include {field}")) +} + +fn validate_artifact_path(path: &Path) -> Result<()> { + let metadata = fs::metadata(path) + .with_context(|| format!("read Apps Platform artifact {}", path.display()))?; + if !metadata.is_file() { + anyhow::bail!( + "Apps Platform artifact must be a file containing a prebuilt artifact.tar.gz: {}", + path.display() + ); + } + Ok(()) +} + +struct ComposeSessionCredential { + authorization: HeaderValue, + secret: String, +} + +impl ComposeSessionCredential { + fn from_config(config: &SkillsConfig) -> Result { + let storage = default_session_storage(config)?; + let verified = + verify_stored_session(config, storage.as_ref())?.ok_or_else(auth_required_error)?; + Self::from_stored(verified.credential) + } + + fn from_stored(credential: StoredSessionCredential) -> Result { + let secret = credential + .session_credential_header_value() + .ok_or_else(auth_required_error)?; + Self::new(secret) + } + + fn new(secret: String) -> Result { + let authorization = HeaderValue::from_str(&format!("BLIdentity {secret}")) + .context("stored BuilderLab CLI auth session is invalid; run `bl auth login`")?; + Ok(Self { + authorization, + secret, + }) + } + + fn authorization_header(&self) -> HeaderValue { + self.authorization.clone() + } + + fn redact(&self, value: &str) -> String { + value.replace(&self.secret, "[REDACTED]") + } +} + +struct ControlPlaneClient { + client: Client, + #[cfg(test)] + test_transport: Option>, + base_url: String, + client_version: HeaderValue, + client_version_text: String, + style: Style, +} + +#[cfg(test)] +trait ControlPlaneTransport { + fn execute(&self, request: Request) -> reqwest::Result; +} + +#[cfg(test)] +struct LoopbackTestTransport { + client: Client, + base_url: url::Url, +} + +#[cfg(test)] +impl LoopbackTestTransport { + fn new(base_url: &str, timeout: Duration) -> Result { + Ok(Self { + client: build_auth_http_client(timeout)?, + base_url: validate_apps_e2e_loopback_url(base_url, APPS_E2E_CONTROL_PLANE_URL_ENV_VAR)?, + }) + } +} + +#[cfg(test)] +impl ControlPlaneTransport for LoopbackTestTransport { + fn execute(&self, mut request: Request) -> reqwest::Result { + assert!( + is_trusted_control_plane_url(request.url()), + "request must retain its approved production URL until transport execution: {}", + request.url() + ); + let query = request.url().query().map(str::to_string); + let mut loopback_url = self.base_url.clone(); + loopback_url.set_path(request.url().path()); + loopback_url.set_query(query.as_deref()); + *request.url_mut() = loopback_url; + self.client.execute(request) + } +} + +impl ControlPlaneClient { + fn new(base_url: &str, client_version: &str, style: Style) -> Result { + Self::new_with_timeout( + base_url, + client_version, + style, + CONTROL_PLANE_REQUEST_TIMEOUT, + ) + } + + fn new_with_timeout( + base_url: &str, + client_version: &str, + style: Style, + request_timeout: Duration, + ) -> Result { + validate_control_plane_base_url(base_url)?; + let client = build_control_plane_http_client(request_timeout)?; + let control_plane = Self::build(base_url, client_version, style, client)?; + #[cfg(test)] + let control_plane = { + let mut control_plane = control_plane; + if let Some(loopback_url) = std::env::var_os(APPS_E2E_CONTROL_PLANE_URL_ENV_VAR) { + let loopback_url = loopback_url.into_string().map_err(|_| { + anyhow::anyhow!("{APPS_E2E_CONTROL_PLANE_URL_ENV_VAR} must be UTF-8") + })?; + control_plane.test_transport = Some(Box::new(LoopbackTestTransport::new( + &loopback_url, + request_timeout, + )?)); + } + control_plane + }; + Ok(control_plane) + } + + fn build(base_url: &str, client_version: &str, style: Style, client: Client) -> Result { + let client_version_text = client_version.to_string(); + let client_version = HeaderValue::from_str(client_version) + .context("Apps Platform client version is not a valid HTTP header value")?; + Ok(Self { + client, + #[cfg(test)] + test_transport: None, + base_url: base_url.to_string(), + client_version, + client_version_text, + style, + }) + } + + #[cfg(test)] + fn new_for_test( + base_url: &str, + client_version: &str, + style: Style, + request_timeout: Duration, + transport: Box, + ) -> Result { + validate_control_plane_base_url(base_url)?; + let mut client = Self::build( + base_url, + client_version, + style, + build_auth_http_client(request_timeout)?, + )?; + client.test_transport = Some(transport); + Ok(client) + } + + fn client_version_text(&self) -> &str { + &self.client_version_text + } + + fn contract(&self, credential: &ComposeSessionCredential) -> Result { + let url = self.endpoint(APPS_CONTRACT_PATH)?; + self.authorized_json_request(credential, "GET", APPS_CONTRACT_PATH, |authorization| { + self.standard_request(self.client.get(url.clone()), authorization) + .build() + .context("build Apps Platform contract request") + }) + } + + fn plan( + &self, + credential: &ComposeSessionCredential, + request: &PlanRequest<'_>, + ) -> Result { + let url = self.endpoint(APPS_PLAN_PATH)?; + self.authorized_json_request(credential, "POST", APPS_PLAN_PATH, |authorization| { + self.standard_request(self.client.post(url.clone()), authorization) + .json(request) + .build() + .context("build Apps Platform plan request") + }) + } + + fn list_apps( + &self, + credential: &ComposeSessionCredential, + scope: Option<&str>, + include_deleted: bool, + ) -> Result { + let mut query = Vec::new(); + if let Some(scope) = scope { + query.push(("scope", scope.to_string())); + } + if include_deleted { + query.push(("include_deleted", "true".to_string())); + } + let url = self.apps_url(&query)?; + self.get_url(credential, url) + } + + fn get_app( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + environment: Option<&str>, + ) -> Result { + let query = environment + .map(|environment| vec![("environment", environment.to_string())]) + .unwrap_or_default(); + let url = self.app_url(app_id, &query)?; + self.get_url(credential, url) + } + + fn versions( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + environment: Option<&str>, + ) -> Result { + let query = environment + .map(|environment| vec![("environment", environment.to_string())]) + .unwrap_or_default(); + self.get_app_resource(credential, app_id, "versions", &query) + } + + fn initialize( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + request: &Value, + ) -> Result { + let url = self.app_action_url(app_id, "initialize")?; + let path = url.path().to_string(); + self.authorized_json_request(credential, "POST", &path, |authorization| { + self.standard_request(self.client.post(url.clone()), authorization) + .json(request) + .build() + .context("build Apps Platform initialize request") + }) + } + + fn reserve( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + request: &Value, + ) -> Result { + let url = self.app_action_url(app_id, "reserve")?; + let path = url.path().to_string(); + self.authorized_json_request(credential, "POST", &path, |authorization| { + self.standard_request(self.client.post(url.clone()), authorization) + .json(request) + .build() + .context("build Apps Platform reserve request") + }) + } + + fn deploy( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + artifact: &Path, + options: &DeployOptions, + ) -> Result { + let url = self.app_action_url(app_id, "deploy")?; + let path = url.path().to_string(); + self.authorized_json_request(credential, "POST", &path, |authorization| { + let form = deploy_form(artifact, options)?; + self.standard_request(self.client.post(url.clone()), authorization) + .multipart(form) + .build() + .context("build Apps Platform deploy request") + }) + } + + fn rollback( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + request: &RollbackRequest<'_>, + ) -> Result { + let url = self.app_action_url(app_id, "rollback")?; + let path = url.path().to_string(); + self.authorized_json_request(credential, "POST", &path, |authorization| { + self.standard_request(self.client.post(url.clone()), authorization) + .json(request) + .build() + .context("build Apps Platform rollback request") + }) + } + + fn delete_app( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + request: &DeleteAppRequest<'_>, + ) -> Result { + let url = self.app_url(app_id, &[])?; + let path = url.path().to_string(); + let authorization = credential.authorization_header(); + let http_request = self + .standard_request(self.client.delete(url), authorization) + .json(request) + .build() + .context("build Apps Platform delete request")?; + self.style.verbose(&format!("DELETE {path}")); + let response = self + .execute_request(http_request) + .map_err(|_| delete_outcome_unknown())?; + let status = response.status(); + let body = read_limited_response_body( + response, + CONTROL_PLANE_RESPONSE_MAX_BYTES, + "Apps Platform control-plane", + ) + .map_err(|_| delete_outcome_unknown())?; + self.style + .verbose(&format!("DELETE {path} -> {status} ({} bytes)", body.len())); + if !status.is_success() { + return Err(control_plane_http_failure( + "DELETE", &path, status, &body, credential, + )); + } + let mut value = serde_json::from_str(&body).map_err(|_| delete_outcome_unknown())?; + redact_json_value(&mut value, credential).map_err(|_| delete_outcome_unknown())?; + Ok(value) + } + + fn ready( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + version_id: &str, + environment: Option<&str>, + ) -> Result { + let mut query = Vec::new(); + if let Some(environment) = environment { + query.push(("environment", environment.to_string())); + } + query.push(("version_id", version_id.to_string())); + self.get_app_resource(credential, app_id, "ready", &query) + } + + fn debug( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + environment: Option<&str>, + version_id: Option<&str>, + tail_lines: Option, + ) -> Result { + let mut query = Vec::new(); + if let Some(environment) = environment { + query.push(("environment", environment.to_string())); + } + if let Some(version_id) = version_id { + query.push(("version_id", version_id.to_string())); + } + if let Some(tail_lines) = tail_lines { + query.push(("tail_lines", tail_lines.to_string())); + } + self.get_app_resource(credential, app_id, "debug", &query) + } + + fn get_access( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + environment: Option<&str>, + ) -> Result { + let query = environment + .map(|environment| vec![("environment", environment.to_string())]) + .unwrap_or_default(); + self.get_app_resource(credential, app_id, "access", &query) + } + + fn set_access( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + request: &AccessRequest<'_>, + ) -> Result { + let url = self.app_resource_url(app_id, "access", &[])?; + let path = url.path().to_string(); + self.authorized_json_request(credential, "PUT", &path, |authorization| { + self.standard_request(self.client.put(url.clone()), authorization) + .json(request) + .build() + .context("build Apps Platform access update request") + }) + } + + fn get_app_resource( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + resource: &str, + query: &[(&str, String)], + ) -> Result { + let url = self.app_resource_url(app_id, resource, query)?; + self.get_url(credential, url) + } + + fn get_url(&self, credential: &ComposeSessionCredential, url: url::Url) -> Result { + let path = request_path(&url); + self.authorized_json_request(credential, "GET", &path, |authorization| { + self.standard_request(self.client.get(url.clone()), authorization) + .build() + .with_context(|| format!("build Apps Platform GET {path} request")) + }) + } + + fn endpoint(&self, path: &str) -> Result { + auth_url(&self.base_url, path) + .with_context(|| format!("build Apps Platform control-plane {path} URL")) + } + + fn app_action_url(&self, app_id: &str, action: &str) -> Result { + self.app_resource_url(app_id, action, &[]) + } + + fn apps_url(&self, query: &[(&str, String)]) -> Result { + let mut url = self.endpoint("/v1/agent/apps")?; + if !query.is_empty() { + let mut pairs = url.query_pairs_mut(); + for (name, value) in query { + pairs.append_pair(name, value); + } + } + Ok(url) + } + + fn app_url(&self, app_id: &str, query: &[(&str, String)]) -> Result { + let mut url = self.apps_url(query)?; + url.path_segments_mut() + .map_err(|_| { + anyhow::anyhow!("Apps Platform control-plane URL cannot contain path segments") + })? + .push(app_id); + Ok(url) + } + + fn app_resource_url( + &self, + app_id: &str, + resource: &str, + query: &[(&str, String)], + ) -> Result { + let mut url = self.app_url(app_id, query)?; + url.path_segments_mut() + .map_err(|_| { + anyhow::anyhow!("Apps Platform control-plane URL cannot contain path segments") + })? + .push(resource); + Ok(url) + } + + fn standard_request( + &self, + request: RequestBuilder, + authorization: HeaderValue, + ) -> RequestBuilder { + request + .header(USER_AGENT, apps_user_agent()) + .header(ACCEPT, "application/json") + .header( + HOTPOD_AGENT_CLIENT_VERSION_HEADER, + self.client_version.clone(), + ) + .header(AUTHORIZATION, authorization) + } + + fn authorized_json_request( + &self, + credential: &ComposeSessionCredential, + method: &str, + path: &str, + send: F, + ) -> Result + where + F: Fn(HeaderValue) -> Result, + { + let authorization = credential.authorization_header(); + let (status, body) = self.request_response(method, path, &send, authorization)?; + if !status.is_success() { + return Err(control_plane_http_failure( + method, path, status, &body, credential, + )); + } + let mut value = serde_json::from_str(&body) + .with_context(|| format!("parse Apps Platform {method} {path} response"))?; + redact_json_value(&mut value, credential) + .with_context(|| format!("sanitize Apps Platform {method} {path} response"))?; + Ok(value) + } + + fn request_response( + &self, + method: &str, + path: &str, + send: &F, + authorization: HeaderValue, + ) -> Result<(StatusCode, String)> + where + F: Fn(HeaderValue) -> Result, + { + self.style.verbose(&format!("{method} {path}")); + let request = send(authorization)?; + let response = self + .execute_request(request) + .map_err(|error| network_failure(method, path, error))?; + let status = response.status(); + let body = read_limited_response_body( + response, + CONTROL_PLANE_RESPONSE_MAX_BYTES, + "Apps Platform control-plane", + )?; + self.style.verbose(&format!( + "{method} {path} -> {status} ({} bytes)", + body.len() + )); + Ok((status, body)) + } + + fn execute_request(&self, request: Request) -> reqwest::Result { + #[cfg(test)] + if let Some(transport) = self.test_transport.as_ref() { + return transport.execute(request); + } + self.client.execute(request) + } +} + +fn request_path(url: &url::Url) -> String { + match url.query() { + Some(query) => format!("{}?{query}", url.path()), + None => url.path().to_string(), + } +} + +fn build_control_plane_http_client(timeout: Duration) -> Result { + Client::builder() + .redirect(Policy::none()) + .timeout(timeout) + .build() + .context("build Apps Platform control-plane HTTP client") +} + +fn redact_json_value(value: &mut Value, credential: &ComposeSessionCredential) -> Result<()> { + match value { + Value::String(text) => *text = credential.redact(text), + Value::Array(items) => { + for item in items { + redact_json_value(item, credential)?; + } + } + Value::Object(object) => { + for (key, value) in object { + if credential.redact(key) != *key { + anyhow::bail!( + "Apps Platform response contained the session credential in an object key" + ); + } + redact_json_value(value, credential)?; + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } + Ok(()) +} + +#[cfg(test)] +fn validate_apps_e2e_loopback_url(value: &str, name: &str) -> Result { + let url = url::Url::parse(value).with_context(|| format!("parse {name}"))?; + let loopback_ip = match url.host() { + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + Some(url::Host::Domain(_)) | None => false, + }; + if url.scheme() != "http" + || !loopback_ip + || url.port().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + anyhow::bail!( + "{name} must be an HTTP loopback IP origin with an explicit port and no userinfo, path, query, or fragment" + ); + } + Ok(url) +} + +fn deploy_form(artifact: &Path, options: &DeployOptions) -> Result { + let artifact_part = multipart::Part::file(artifact) + .with_context(|| format!("open Apps Platform artifact {}", artifact.display()))? + .file_name("artifact.tar.gz") + .mime_str("application/gzip") + .context("set Apps Platform artifact content type")?; + let mut form = multipart::Form::new().part("artifact", artifact_part); + for (name, value) in [ + ("environment", options.environment.as_deref()), + ("version_id", options.version_id.as_deref()), + ("deployment_id", options.deployment_id.as_deref()), + ] { + if let Some(value) = value { + form = form.text(name.to_string(), value.to_string()); + } + } + Ok(form) +} + +fn is_trusted_control_plane_url(url: &url::Url) -> bool { + if url.scheme() != "https" + || url.port_or_known_default() != Some(443) + || !url.username().is_empty() + || url.password().is_some() + { + return false; + } + let Some(url::Host::Domain(host)) = url.host() else { + return false; + }; + TRUSTED_CONTROL_PLANE_HOSTS + .iter() + .any(|trusted| host.eq_ignore_ascii_case(trusted)) +} + +fn validate_control_plane_base_url(base_url: &str) -> Result<()> { + let contract_url = auth_url(base_url, APPS_CONTRACT_PATH) + .context("build Apps Platform control-plane contract URL")?; + if !is_trusted_control_plane_url(&contract_url) { + anyhow::bail!( + "Apps Platform control-plane URL must use HTTPS and target an approved BuilderLab ingress host" + ); + } + Ok(()) +} + +fn read_limited_response_body( + response: reqwest::blocking::Response, + max_bytes: usize, + description: &str, +) -> Result { + let mut bytes = Vec::new(); + response + .take((max_bytes + 1) as u64) + .read_to_end(&mut bytes) + .with_context(|| format!("read {description} response"))?; + if bytes.len() > max_bytes { + anyhow::bail!("{description} response exceeded {max_bytes} bytes"); + } + String::from_utf8(bytes).with_context(|| format!("decode {description} response as UTF-8")) +} + +fn apps_user_agent() -> String { + format!("bl-apps/{}", env!("CARGO_PKG_VERSION")) +} + +fn auth_required_error() -> anyhow::Error { + failure( + exit_codes::AUTH_REQUIRED, + "auth_required", + "BuilderLab CLI auth is required; run `bl auth login`", + ) +} + +fn network_failure(method: &str, path: &str, error: reqwest::Error) -> anyhow::Error { + failure( + exit_codes::NETWORK, + "network_error", + format!("{method} {path} failed before receiving a response: {error}"), + ) +} + +fn delete_outcome_unknown() -> anyhow::Error { + failure( + exit_codes::NETWORK, + "delete_outcome_unknown", + "The delete may have succeeded, but no complete JSON success response was received.\n\ + next_action: Before retrying, verify the same APP_ID and ENVIRONMENT with \ + `bl apps get --environment `; a successful delete reports \ + `app.status` as `deleted`.", + ) +} + +fn control_plane_http_failure( + method: &str, + path: &str, + status: StatusCode, + body: &str, + credential: &ComposeSessionCredential, +) -> anyhow::Error { + let parsed = serde_json::from_str::(body).ok(); + let code = parsed + .as_ref() + .and_then(|value| value.pointer("/error/code")) + .and_then(Value::as_str) + .unwrap_or("control_plane_request_failed"); + let code = credential.redact(&terminal_safe_text(code)); + let next_action = if status == StatusCode::UNAUTHORIZED { + Some("Run `bl auth logout`, then `bl auth login` to replace your session.".to_string()) + } else { + parsed + .as_ref() + .and_then(|value| { + value + .get("next_action") + .or_else(|| value.pointer("/error/next_action")) + }) + .and_then(Value::as_str) + .map(terminal_safe_text) + .map(|value| credential.redact(&value)) + }; + let mut message = format!("{method} {path} failed with {status}"); + if let Some(next_action) = next_action { + message.push_str("\nnext_action: "); + message.push_str(&next_action); + } + let exit_code = match status.as_u16() { + 401 => exit_codes::AUTH_REQUIRED, + 403 => exit_codes::FORBIDDEN, + value if value >= 500 => exit_codes::NETWORK, + _ => exit_codes::GENERAL, + }; + failure(exit_code, &code, message) +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, VecDeque}; + use std::process::Command as ProcessCommand; + use std::sync::{Arc, Mutex}; + use std::thread; + + use sha2::{Digest, Sha256}; + use tiny_http::{Header, Response, Server}; + + use super::*; + + const APPROVED_TEST_BASE_URL: &str = "https://compose-ctrl.test.blockstaging.build"; + const PROCESS_STDOUT_BEGIN: &str = "BL_APPS_E2E_STDOUT_BEGIN"; + const PROCESS_STDOUT_END: &str = "BL_APPS_E2E_STDOUT_END"; + + #[derive(Clone)] + struct ProcessResponse { + status: u16, + body: String, + } + + impl ProcessResponse { + fn json(body: Value) -> Self { + Self { + status: 200, + body: body.to_string(), + } + } + + fn json_status(status: u16, body: Value) -> Self { + Self { + status, + body: body.to_string(), + } + } + + fn raw(status: u16, body: impl Into) -> Self { + Self { + status, + body: body.into(), + } + } + } + + #[derive(Clone)] + struct ProcessRequest { + method: String, + path: String, + headers: BTreeMap, + body: Value, + body_bytes: Vec, + } + + struct ProcessServer { + base_url: String, + requests: Arc>>, + handle: Option>, + } + + impl ProcessServer { + fn start(responses: Vec) -> Self { + let server = Server::http("127.0.0.1:0").expect("bind Apps process test server"); + let base_url = format!("http://{}", server.server_addr()); + let requests = Arc::new(Mutex::new(Vec::new())); + let thread_requests = Arc::clone(&requests); + let handle = thread::spawn(move || { + let mut responses = VecDeque::from(responses); + while let Some(response) = responses.pop_front() { + let mut request = server + .recv_timeout(Duration::from_secs(10)) + .expect("receive Apps process request") + .expect("Apps process request before timeout"); + let headers = request + .headers() + .iter() + .map(|header| { + ( + header.field.as_str().to_string().to_ascii_lowercase(), + header.value.as_str().to_string(), + ) + }) + .collect::>(); + let mut body_bytes = Vec::new(); + request + .as_reader() + .read_to_end(&mut body_bytes) + .expect("read Apps process request"); + let body = if headers + .get("content-type") + .is_some_and(|value| value.starts_with("application/json")) + { + serde_json::from_slice(&body_bytes) + .expect("parse Apps process JSON request") + } else { + Value::Null + }; + thread_requests + .lock() + .expect("lock Apps process requests") + .push(ProcessRequest { + method: request.method().as_str().to_string(), + path: request.url().to_string(), + headers, + body, + body_bytes, + }); + request + .respond( + Response::from_string(response.body) + .with_status_code(response.status) + .with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build Apps process content type"), + ), + ) + .expect("respond to Apps process request"); + } + }); + Self { + base_url, + requests, + handle: Some(handle), + } + } + + fn finish(mut self) -> Vec { + self.handle + .take() + .expect("Apps process server handle") + .join() + .expect("join Apps process server"); + self.requests + .lock() + .expect("lock Apps process requests") + .clone() + } + } + + #[test] + fn bl_apps_e2e_process_helper() { + let Some(args) = std::env::var_os("BL_APPS_E2E_ARGS") else { + return; + }; + let args = serde_json::from_str::>( + args.to_str().expect("BL_APPS_E2E_ARGS must be UTF-8"), + ) + .expect("parse BL_APPS_E2E_ARGS"); + let auth_url = std::env::var(APPS_E2E_AUTH_URL_ENV_VAR) + .expect("Apps E2E helper requires an explicit auth URL"); + let auth_url = validate_apps_e2e_loopback_url(&auth_url, APPS_E2E_AUTH_URL_ENV_VAR) + .expect("validate Apps E2E auth URL"); + let credential = std::env::var(APPS_E2E_CREDENTIAL_ENV_VAR) + .expect("Apps E2E helper requires an explicit synthetic credential"); + assert!( + credential.starts_with("apps-e2e-only."), + "Apps E2E helper accepts only synthetic test credentials" + ); + let temp = tempfile::tempdir().expect("create isolated Apps E2E home"); + let bl_home = temp.path().join("bl-home"); + let storage_path = temp.path().join("auth-sessions.json"); + fs::create_dir_all(&bl_home).expect("create isolated Apps E2E bl home"); + fs::write(bl_home.join("config.yaml"), "org: test\n") + .expect("write isolated Apps E2E config"); + let service_url = format!("{}/api/goose", auth_url.as_str().trim_end_matches('/')); + let mut hasher = Sha256::new(); + hasher.update(b"default"); + hasher.update([0]); + hasher.update(service_url.as_bytes()); + let storage_key = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + fs::write( + &storage_path, + serde_json::to_vec_pretty(&json!({ + storage_key: { + "sessionCredential": credential, + "expiresAt": "2099-01-01T00:00:00Z" + } + })) + .expect("serialize isolated Apps E2E storage"), + ) + .expect("write isolated Apps E2E storage"); + std::env::set_var("BL_HOME", &bl_home); + std::env::set_var("BL_AUTH_STORAGE", "file"); + std::env::set_var("BL_AUTH_STORAGE_FILE", &storage_path); + std::env::set_var("KGOOSE_BASE_URL", auth_url.as_str()); + std::env::remove_var("BL_SKILLS_PROFILE"); + std::env::remove_var("KGOOSE_PLAYPEN"); + println!("{PROCESS_STDOUT_BEGIN}"); + crate::run_bl_with_argv(args).expect("run bl Apps process command"); + println!("{PROCESS_STDOUT_END}"); + } + + fn process_auth_response() -> ProcessResponse { + ProcessResponse::json(json!({ + "subject": "auth0|apps-user", + "email": "apps@example.com", + "name": "Apps User", + "expires_at": "2099-01-01T00:00:00Z", + "workspaces": {"active": [{"name": "Test Workspace"}]} + })) + } + + fn process_command( + auth_server: &ProcessServer, + control_plane: &ProcessServer, + args: &[&str], + credential: &str, + ) -> ProcessCommand { + assert!(credential.starts_with("apps-e2e-only.")); + let argv = std::iter::once("bl") + .chain(args.iter().copied()) + .map(str::to_string) + .collect::>(); + let mut command = ProcessCommand::new(std::env::current_exe().expect("current test exe")); + command + .args([ + "--exact", + "bl::apps::tests::bl_apps_e2e_process_helper", + "--nocapture", + ]) + .env("BL_APPS_E2E_ARGS", serde_json::to_string(&argv).unwrap()) + .env(APPS_E2E_CONTROL_PLANE_URL_ENV_VAR, &control_plane.base_url) + .env(APPS_E2E_AUTH_URL_ENV_VAR, &auth_server.base_url) + .env(APPS_E2E_CREDENTIAL_ENV_VAR, credential) + .env_remove("BL_HOME") + .env_remove("BL_AUTH_STORAGE") + .env_remove("BL_AUTH_STORAGE_FILE") + .env_remove("KGOOSE_BASE_URL") + .env_remove("BL_SKILLS_PROFILE") + .env_remove("KGOOSE_PLAYPEN"); + command + } + + fn process_stdout(output: &std::process::Output) -> String { + let stdout = String::from_utf8(output.stdout.clone()).expect("Apps process stdout UTF-8"); + let start = stdout + .find(PROCESS_STDOUT_BEGIN) + .expect("Apps process stdout begin marker") + + PROCESS_STDOUT_BEGIN.len(); + let end = stdout[start..] + .find(PROCESS_STDOUT_END) + .map(|offset| start + offset) + .expect("Apps process stdout end marker"); + stdout[start..end].trim().to_string() + } + + fn assert_process_auth(request: &ProcessRequest, credential: &str) { + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/api/goose/v1/auth/me"); + assert_eq!( + request + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some(credential) + ); + } + + fn assert_process_control_plane( + request: &ProcessRequest, + method: &str, + path: &str, + credential: &str, + ) { + assert_eq!(request.method, method); + assert_eq!(request.path, path); + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some(format!("BLIdentity {credential}").as_str()) + ); + assert_eq!( + request + .headers + .get("x-hotpod-agent-client-version") + .map(String::as_str), + Some("0.2.0") + ); + for forbidden in [ + "cookie", + "x-bb-session-credential", + "x-forwarded-user", + "x-forwarded-workspace-id", + ] { + assert!(!request.headers.contains_key(forbidden)); + } + } + + #[test] + fn apps_e2e_destinations_require_explicit_http_loopback_ip_origins() { + for valid in ["http://127.0.0.1:1234", "http://[::1]:4321"] { + assert!(validate_apps_e2e_loopback_url(valid, "test URL").is_ok()); + } + for invalid in [ + "http://192.0.2.1:1234", + "https://127.0.0.1:1234", + "http://localhost:1234", + "http://user@127.0.0.1:1234", + "http://127.0.0.1:1234/path", + "http://127.0.0.1:1234/?query=yes", + "http://127.0.0.1:1234/#fragment", + "http://127.0.0.1", + ] { + let error = validate_apps_e2e_loopback_url(invalid, "test URL") + .expect_err("reject unsafe Apps E2E destination"); + assert!(error.to_string().contains("HTTP loopback IP origin")); + } + } + + #[test] + fn bl_apps_contract_process_covers_auth_dispatch_output_and_redaction() { + let credential = "apps-e2e-only.contract.session+credential"; + let contract = json!({ + "ok": true, + "contract_version": "2026-06-30", + "reflected": credential, + "nested": {"message": format!("prefix {credential} suffix")} + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(contract)]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "contract", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps contract process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = process_stdout(&output); + assert!(!stdout.contains(credential)); + let value = serde_json::from_str::(&stdout).expect("parse contract process output"); + assert_eq!(value["contract_version"], "2026-06-30"); + assert_eq!(value["reflected"], "[REDACTED]"); + assert_eq!(value["nested"]["message"], "prefix [REDACTED] suffix"); + let auth_requests = auth_server.finish(); + let control_requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_process_control_plane(&control_requests[0], "GET", APPS_CONTRACT_PATH, credential); + } + + #[test] + fn bl_apps_list_process_sends_filters_and_preserves_inventory() { + let credential = "apps-e2e-only.list.session+credential"; + let inventory = json!({ + "ok": true, + "caller": "apps-user", + "scope": "publisher", + "captured_at": "2026-09-01T12:00:00Z", + "count": 1, + "apps": [{ + "app_id": "merchant-lookup", + "role": "publisher", + "status": "deleted", + "ready": false, + "active_version_id": "ver-123", + "last_published_by": "apps-user" + }] + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(inventory.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "list", + "--scope", + "publisher", + "--include-deleted", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps list process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse list process output"), + inventory + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_process_control_plane( + &requests[0], + "GET", + "/v1/agent/apps?scope=publisher&include_deleted=true", + credential, + ); + assert_eq!(requests[0].body, Value::Null); + } + + #[test] + fn bl_apps_get_process_encodes_app_id_and_preserves_versions() { + let credential = "apps-e2e-only.get.session+credential"; + let app = json!({ + "ok": true, + "app": { + "app_id": "merchant/lookup app", + "environment": "staging", + "role": "owner", + "ready": true, + "route_revision": 9 + }, + "versions": [{ + "version_id": "ver-123", + "deployment_id": "dpl-123", + "active": true + }] + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(app.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "get", + "merchant/lookup app", + "--environment", + "staging", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps get process command"); + assert!(output.status.success()); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse get process output"), + app + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_process_control_plane( + &requests[0], + "GET", + "/v1/agent/apps/merchant%2Flookup%20app?environment=staging", + credential, + ); + } + + #[test] + fn bl_apps_access_process_gets_and_replaces_the_complete_policy() { + let credential = "apps-e2e-only.access.session+credential"; + let current = json!({ + "ok": true, + "app_id": "merchant/lookup app", + "environment": "staging/west", + "owner": "auth0|owner", + "visibility": "restricted", + "viewers": ["auth0|alice"], + "effective_viewers": ["auth0|owner", "auth0|publisher", "auth0|alice"] + }); + let updated = json!({ + "ok": true, + "app_id": "merchant/lookup app", + "environment": "staging/west", + "owner": "auth0|owner", + "visibility": "restricted", + "viewers": ["auth0|bob", "auth0|carol"], + "effective_viewers": ["auth0|owner", "auth0|publisher", "auth0|bob", "auth0|carol"] + }); + let auth_server = + ProcessServer::start(vec![process_auth_response(), process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(current.clone()), + ProcessResponse::json(updated.clone()), + ]); + + let mut get_command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "access", + "get", + "merchant/lookup app", + "--environment", + "staging/west", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + let get_output = get_command + .output() + .expect("run Apps access get process command"); + assert!( + get_output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&get_output.stderr) + ); + assert_eq!( + serde_json::from_str::(&process_stdout(&get_output)) + .expect("parse access get process output"), + current + ); + + let mut set_command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "access", + "set", + "merchant/lookup app", + "--visibility", + "restricted", + "--viewer", + "auth0|bob", + "--viewer", + "auth0|carol", + "--environment", + "staging/west", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + let set_output = set_command + .output() + .expect("run Apps access set process command"); + assert!( + set_output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&set_output.stderr) + ); + assert_eq!( + serde_json::from_str::(&process_stdout(&set_output)) + .expect("parse access set process output"), + updated + ); + + let auth_requests = auth_server.finish(); + assert_eq!(auth_requests.len(), 2); + for request in &auth_requests { + assert_process_auth(request, credential); + } + let requests = control_plane.finish(); + assert_eq!(requests.len(), 2); + assert_process_control_plane( + &requests[0], + "GET", + "/v1/agent/apps/merchant%2Flookup%20app/access?environment=staging%2Fwest", + credential, + ); + assert_eq!(requests[0].body, Value::Null); + assert_process_control_plane( + &requests[1], + "PUT", + "/v1/agent/apps/merchant%2Flookup%20app/access", + credential, + ); + assert_eq!( + requests[1].body, + json!({ + "visibility": "restricted", + "viewers": ["auth0|bob", "auth0|carol"], + "environment": "staging/west" + }) + ); + } + + #[test] + fn bl_apps_access_process_explicitly_clears_restricted_viewers() { + let credential = "apps-e2e-only.access.clear.session+credential"; + let updated = json!({ + "ok": true, + "app_id": "merchant-lookup", + "environment": "production", + "owner": "auth0|owner", + "visibility": "restricted", + "viewers": [], + "effective_viewers": ["auth0|owner"] + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(updated.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "access", + "set", + "merchant-lookup", + "--visibility", + "restricted", + "--clear-viewers", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command + .output() + .expect("run Apps access explicit viewer clearing command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse access clearing output"), + updated + ); + + let auth_requests = auth_server.finish(); + assert_eq!(auth_requests.len(), 1); + assert_process_auth(&auth_requests[0], credential); + let requests = control_plane.finish(); + assert_eq!(requests.len(), 1); + assert_process_control_plane( + &requests[0], + "PUT", + "/v1/agent/apps/merchant-lookup/access", + credential, + ); + assert_eq!( + requests[0].body, + json!({"visibility": "restricted", "viewers": []}) + ); + } + + #[test] + fn bl_apps_versions_process_preserves_rollback_candidates() { + let credential = "apps-e2e-only.versions.session+credential"; + let versions = json!({ + "ok": true, + "app_id": "merchant-lookup", + "environment": "staging", + "active_version_id": "ver-123", + "count": 2, + "versions": [ + {"version_id": "ver-123", "route_revision": 9, "active": true}, + {"version_id": "ver-122", "route_revision": 8, "active": false} + ] + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(versions.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "versions", + "merchant-lookup", + "--environment", + "staging", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps versions process command"); + assert!(output.status.success()); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse versions process output"), + versions + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_process_control_plane( + &requests[0], + "GET", + "/v1/agent/apps/merchant-lookup/versions?environment=staging", + credential, + ); + } + + #[test] + fn bl_apps_create_process_runs_plan_reserve_and_initialize() { + let credential = "apps-e2e-only.create.session+credential"; + let plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "sqlite", + "runtime_class": "default", + "initialize": {"required": true, "recommended": false} + }); + let initialized = json!({ + "app_id": "merchant-lookup", + "external_url": "https://merchant-lookup--bpsites.example/" + }); + let reservation = json!({ + "app_id": "merchant-lookup", + "external_url": "https://merchant-lookup--bpsites.example/" + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(plan.clone()), + ProcessResponse::json(reservation.clone()), + ProcessResponse::json(initialized.clone()), + ]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--name", + "Merchant Lookup", + "--environment", + "staging", + "--runtime-profile", + "fetch-js", + "--persistence", + "sqlite", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value = serde_json::from_str::(&process_stdout(&output)) + .expect("parse create process output"); + assert_eq!(value["app_id"], "merchant-lookup"); + assert_eq!(value["reserved"], true); + assert_eq!(value["initialized"], true); + assert_eq!(value["plan"], plan); + assert_eq!(value["reservation"], reservation); + assert_eq!(value["initialize"], initialized); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 3); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + assert_eq!( + requests[0].body, + json!({ + "app_id": "merchant-lookup", + "name": "Merchant Lookup", + "environment": "staging", + "runtime_profile": "fetch-js", + "persistence": "sqlite", + "client_version": "0.2.0" + }) + ); + assert_process_control_plane( + &requests[1], + "POST", + "/v1/agent/apps/merchant-lookup/reserve", + credential, + ); + assert_eq!( + requests[1].body, + json!({ + "environment": "staging", + "persistence": "sqlite", + "runtime_class": "default", + "name": "Merchant Lookup" + }) + ); + assert_process_control_plane( + &requests[2], + "POST", + "/v1/agent/apps/merchant-lookup/initialize", + credential, + ); + assert_eq!(requests[2].body, requests[1].body); + } + + #[test] + fn bl_apps_create_requires_complete_boolean_initialize_decision_before_reserve() { + let credential = "apps-e2e-only.initialize-decision.session+credential"; + let valid_plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "initialize": {"required": false, "recommended": false} + }); + + for field in ["required", "recommended"] { + for (shape, replacement) in [ + ("missing", None), + ("null", Some(Value::Null)), + ("non-boolean", Some(Value::String("false".to_string()))), + ] { + let mut plan = valid_plan.clone(); + let initialize = plan["initialize"] + .as_object_mut() + .expect("initialize object"); + match replacement { + Some(value) => { + initialize.insert(field.to_string(), value); + } + None => { + initialize.remove(field); + } + } + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(plan)]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--name", + "Merchant Lookup", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command + .output() + .unwrap_or_else(|error| panic!("run create with {field} {shape}: {error}")); + assert!( + !output.status.success(), + "accepted initialize.{field} as {shape}" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!("boolean initialize.{field}")) + && stderr.contains("refusing to reserve the app"), + "stderr for initialize.{field} as {shape} was: {stderr}" + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!( + requests.len(), + 1, + "initialize.{field} as {shape} sent a mutation" + ); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + } + } + } + + #[test] + fn bl_apps_create_requires_descriptive_identity() { + let error = command() + .try_get_matches_from(["apps", "create", "--base-url", APPROVED_TEST_BASE_URL]) + .expect_err("create without app identity must fail"); + + assert!(error + .to_string() + .contains("--app-id |--name ")); + } + + #[test] + fn bl_apps_create_rejects_empty_identity() { + for argument in ["--app-id", "--name"] { + command() + .try_get_matches_from([ + "apps", + "create", + argument, + "", + "--base-url", + APPROVED_TEST_BASE_URL, + ]) + .expect_err("empty app identity must fail"); + } + } + + #[test] + fn bl_apps_create_rejects_plan_substitute_before_initialize() { + let credential = "apps-e2e-only.plan-substitute.session+credential"; + let plan = json!({ + "app_id": "merchant-lookup-2", + "initialize": {"required": true, "recommended": true} + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(plan)]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains( + "expected exact app_id \\\"merchant-lookup\\\". No replacement app was accepted" + )); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + } + + #[test] + fn bl_apps_create_rejects_reservation_substitute_before_initialize() { + let credential = "apps-e2e-only.reserve-substitute.session+credential"; + let plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "initialize": {"required": true, "recommended": true} + }); + let reservation = json!({ + "app_id": "merchant-lookup-2", + "external_url": "https://merchant-lookup-2--bpsites.example/" + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(plan), + ProcessResponse::json(reservation), + ]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains( + "Apps Platform reserve returned app_id \\\"merchant-lookup-2\\\"; expected exact app_id \\\"merchant-lookup\\\". No replacement app was accepted" + ), + "stderr was: {stderr}" + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 2); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + assert_process_control_plane( + &requests[1], + "POST", + "/v1/agent/apps/merchant-lookup/reserve", + credential, + ); + } + + #[test] + fn bl_apps_create_stops_on_reservation_collision() { + let credential = "apps-e2e-only.reserve-collision.session+credential"; + let plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "initialize": {"required": true, "recommended": true} + }); + let collision = ProcessResponse::json_status( + 409, + json!({ + "ok": false, + "error": { + "code": "app_id_collision", + "message": "app_id is already reserved" + } + }), + ); + let not_owned = ProcessResponse::json_status( + 403, + json!({ + "ok": false, + "error": {"code": "owner_required", "message": "caller does not own app"} + }), + ); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = + ProcessServer::start(vec![ProcessResponse::json(plan), collision, not_owned]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("app_id_collision")); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 3); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + assert_process_control_plane( + &requests[1], + "POST", + "/v1/agent/apps/merchant-lookup/reserve", + credential, + ); + assert_process_control_plane( + &requests[2], + "GET", + "/v1/agent/apps/merchant-lookup?environment=staging", + credential, + ); + } + + #[test] + fn bl_apps_create_retries_matching_reservation_after_initialize_failure() { + let credential = "apps-e2e-only.resume-after-initialize.session+credential"; + let initial_plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "runtime_profile": "process", + "external_url": "https://merchant-lookup--bpsites.example/", + "initialize": {"required": true, "recommended": true} + }); + let retry_plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "runtime_profile": "process", + "external_url": "https://merchant-lookup--bpsites.example/", + "initialize": { + "required": true, + "recommended": true, + "reason": "the app ID is reserved, but its dynamic stack has not been initialized" + } + }); + let reservation = json!({ + "app_id": "merchant-lookup", + "external_url": "https://merchant-lookup--bpsites.example/" + }); + let initialize_failure = ProcessResponse::json_status( + 502, + json!({ + "ok": false, + "error": {"code": "kubernetes_apply_failed", "message": "runner rollout failed"} + }), + ); + let collision = ProcessResponse::json_status( + 409, + json!({ + "ok": false, + "error": {"code": "app_id_collision", "message": "app_id is already reserved"} + }), + ); + let existing_reservation = json!({ + "ok": true, + "app": { + "app_id": "merchant-lookup", + "name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "role": "owner", + "status": "idle", + "route_status": "idle", + "route_revision": 1 + }, + "versions": [] + }); + let initialized = json!({ + "app_id": "merchant-lookup", + "external_url": "https://merchant-lookup--bpsites.example/" + }); + let auth_server = + ProcessServer::start(vec![process_auth_response(), process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(initial_plan), + ProcessResponse::json(reservation), + initialize_failure, + ProcessResponse::json(retry_plan), + collision, + ProcessResponse::json(existing_reservation), + ProcessResponse::json(initialized.clone()), + ]); + let args = [ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--name", + "Merchant Lookup", + "--runtime-profile", + "process", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ]; + + let first = process_command(&auth_server, &control_plane, &args, credential) + .output() + .expect("run initial Apps create process command"); + assert!(!first.status.success()); + assert!( + String::from_utf8_lossy(&first.stderr) + .contains("Retry the same `bl apps create` command"), + "stderr was: {}", + String::from_utf8_lossy(&first.stderr) + ); + + let retry = process_command(&auth_server, &control_plane, &args, credential) + .output() + .expect("retry Apps create process command"); + assert!( + retry.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&retry.stderr) + ); + let value = serde_json::from_str::(&process_stdout(&retry)) + .expect("parse resumed create output"); + assert_eq!(value["app_id"], "merchant-lookup"); + assert_eq!(value["reservation_reconciled"], true); + assert_eq!(value["initialized"], true); + assert_eq!(value["initialize"], initialized); + + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_eq!(auth_requests.len(), 2); + assert_eq!(requests.len(), 7); + assert_process_control_plane( + &requests[4], + "POST", + "/v1/agent/apps/merchant-lookup/reserve", + credential, + ); + assert_process_control_plane( + &requests[5], + "GET", + "/v1/agent/apps/merchant-lookup?environment=staging", + credential, + ); + assert_process_control_plane( + &requests[6], + "POST", + "/v1/agent/apps/merchant-lookup/initialize", + credential, + ); + } + + #[test] + fn bl_apps_create_reconciles_committed_reservation_after_unreadable_response() { + let credential = "apps-e2e-only.reserve-response-lost.session+credential"; + let plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "runtime_profile": "process", + "external_url": "https://merchant-lookup--bpsites.example/", + "initialize": {"required": true, "recommended": true} + }); + let existing_reservation = json!({ + "ok": true, + "app": { + "app_id": "merchant-lookup", + "name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "role": "owner", + "status": "idle", + "route_status": "idle", + "route_revision": 1 + }, + "versions": [] + }); + let initialized = json!({ + "app_id": "merchant-lookup", + "external_url": "https://merchant-lookup--bpsites.example/" + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(plan), + ProcessResponse::raw(201, "{"), + ProcessResponse::json(existing_reservation), + ProcessResponse::json(initialized), + ]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--name", + "Merchant Lookup", + "--runtime-profile", + "process", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value = serde_json::from_str::(&process_stdout(&output)) + .expect("parse reconciled create output"); + assert_eq!(value["reservation_reconciled"], true); + assert_eq!(value["initialized"], true); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 4); + assert_process_control_plane( + &requests[2], + "GET", + "/v1/agent/apps/merchant-lookup?environment=staging", + credential, + ); + assert_process_control_plane( + &requests[3], + "POST", + "/v1/agent/apps/merchant-lookup/initialize", + credential, + ); + } + + #[test] + fn bl_apps_create_reconciles_reserve_only_plan_without_initialize() { + let credential = "apps-e2e-only.reserve-only-reconcile.session+credential"; + let plan = json!({ + "app_id": "artifact-only-app", + "display_name": "Artifact Only App", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "runtime_profile": "fetch-js", + "external_url": "https://artifact-only-app--bpsites.example/", + "initialize": {"required": false, "recommended": false} + }); + let existing_reservation = json!({ + "ok": true, + "app": { + "app_id": "artifact-only-app", + "name": "Artifact Only App", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "role": "owner", + "status": "idle", + "route_status": "idle", + "route_revision": 1 + }, + "versions": [] + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(plan), + ProcessResponse::raw(201, "{"), + ProcessResponse::json(existing_reservation), + ]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "artifact-only-app", + "--name", + "Artifact Only App", + "--runtime-profile", + "fetch-js", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command + .output() + .expect("run reserve-only create process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value = serde_json::from_str::(&process_stdout(&output)) + .expect("parse reconciled reserve-only output"); + assert_eq!(value["reservation_reconciled"], true); + assert_eq!(value["initialized"], false); + assert_eq!(value["initialize"], Value::Null); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 3); + assert_process_control_plane( + &requests[2], + "GET", + "/v1/agent/apps/artifact-only-app?environment=staging", + credential, + ); + } + + #[test] + fn matching_incomplete_reservation_requires_authoritative_empty_state() { + let mutation_request = json!({ + "name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default" + }); + let response = json!({ + "ok": true, + "app": { + "app_id": "merchant-lookup", + "name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "role": "owner", + "route_status": "idle", + "route_revision": 1 + }, + "versions": [] + }); + assert!(is_matching_incomplete_reservation( + &response, + "merchant-lookup", + &mutation_request + )); + + for invalid_versions in [Value::Null, json!({}), json!([{"version_id": "ver-1"}])] { + let mut invalid = response.clone(); + invalid["versions"] = invalid_versions; + assert!(!is_matching_incomplete_reservation( + &invalid, + "merchant-lookup", + &mutation_request + )); + } + let mut missing_versions = response.clone(); + missing_versions + .as_object_mut() + .expect("response object") + .remove("versions"); + assert!(!is_matching_incomplete_reservation( + &missing_versions, + "merchant-lookup", + &mutation_request + )); + + for field in ["active_version_id", "version_id", "deleted_at"] { + let mut malformed = response.clone(); + malformed["app"][field] = json!({}); + assert!(!is_matching_incomplete_reservation( + &malformed, + "merchant-lookup", + &mutation_request + )); + } + } + + #[test] + fn bl_apps_create_reports_unknown_reservation_outcome_when_inspection_fails() { + let credential = "apps-e2e-only.reserve-outcome-unknown.session+credential"; + let plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "runtime_profile": "process", + "initialize": {"required": true, "recommended": true} + }); + let inspection_failure = ProcessResponse::json_status( + 503, + json!({ + "ok": false, + "error": {"code": "active_route_read_failed", "message": "store unavailable"} + }), + ); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(plan), + ProcessResponse::raw(201, "{"), + inspection_failure, + ]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "merchant-lookup", + "--name", + "Merchant Lookup", + "--runtime-profile", + "process", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("reservation_outcome_unknown"), + "stderr was: {stderr}" + ); + assert!( + stderr.contains("bl apps get merchant-lookup --environment staging"), + "stderr was: {stderr}" + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 3); + assert_process_control_plane( + &requests[2], + "GET", + "/v1/agent/apps/merchant-lookup?environment=staging", + credential, + ); + } + + #[test] + fn bl_apps_create_rejects_initialize_substitute() { + let credential = "apps-e2e-only.initialize-substitute.session+credential"; + let plan = json!({ + "app_id": "merchant-lookup", + "display_name": "Merchant Lookup", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "initialize": {"required": true, "recommended": true} + }); + let initialized = json!({ + "app_id": "merchant-lookup-2", + "external_url": "https://merchant-lookup-2--bpsites.example/" + }); + let reservation = json!({ + "app_id": "merchant-lookup", + "external_url": "https://merchant-lookup--bpsites.example/" + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(plan), + ProcessResponse::json(reservation), + ProcessResponse::json(initialized), + ]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--name", + "Merchant Lookup", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains( + "expected exact app_id \\\"merchant-lookup\\\". No replacement app was accepted" + )); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 3); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + assert_process_control_plane( + &requests[1], + "POST", + "/v1/agent/apps/merchant-lookup/reserve", + credential, + ); + assert_process_control_plane( + &requests[2], + "POST", + "/v1/agent/apps/merchant-lookup/initialize", + credential, + ); + } + + #[test] + fn bl_apps_create_static_process_reserves_without_initialize() { + let credential = "apps-e2e-only.static.session+credential"; + let plan = json!({ + "app_id": "static-app", + "display_name": "Static App", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "runtime_profile": "static", + "initialize": {"required": false, "recommended": false} + }); + let reservation = json!({ + "app_id": "static-app", + "external_url": "https://static-app--bpsites.example/" + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(plan.clone()), + ProcessResponse::json(reservation.clone()), + ]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "static-app", + "--runtime-profile", + "static", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!(output.status.success()); + let value = serde_json::from_str::(&process_stdout(&output)) + .expect("parse create process output"); + assert_eq!(value["app_id"], "static-app"); + assert_eq!(value["reserved"], true); + assert_eq!(value["initialized"], false); + assert_eq!(value["reservation"], reservation); + assert_eq!(value["initialize"], Value::Null); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 2); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + assert_process_control_plane( + &requests[1], + "POST", + "/v1/agent/apps/static-app/reserve", + credential, + ); + } + + #[test] + fn bl_apps_create_does_not_initialize_when_plan_does_not_request_it() { + let credential = "apps-e2e-only.no-initialize.session+credential"; + let plan = json!({ + "app_id": "artifact-only-app", + "display_name": "Artifact Only App", + "environment": "staging", + "persistence": "none", + "runtime_class": "default", + "runtime_profile": "fetch-js", + "initialize": {"required": false, "recommended": false} + }); + let reservation = json!({ + "app_id": "artifact-only-app", + "external_url": "https://artifact-only-app--bpsites.example/" + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::json(plan), + ProcessResponse::json(reservation), + ]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "create", + "--app-id", + "artifact-only-app", + "--runtime-profile", + "fetch-js", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps create process command"); + assert!(output.status.success()); + let value = serde_json::from_str::(&process_stdout(&output)) + .expect("parse create process output"); + assert_eq!(value["reserved"], true); + assert_eq!(value["initialized"], false); + assert_eq!(value["initialize"], Value::Null); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 2); + assert_process_control_plane(&requests[0], "POST", APPS_PLAN_PATH, credential); + assert_process_control_plane( + &requests[1], + "POST", + "/v1/agent/apps/artifact-only-app/reserve", + credential, + ); + } + + #[test] + fn bl_apps_deploy_process_uploads_multipart_artifact() { + let credential = "apps-e2e-only.deploy.session+credential"; + let deployed = json!({ + "ok": true, + "app_id": "merchant-lookup", + "version_id": "ver-123", + "deployment_id": "dpl-123" + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(deployed.clone())]); + let temp = tempfile::tempdir().expect("create deploy process temp directory"); + let artifact = temp.path().join("prepared-app.tar.gz"); + fs::write(&artifact, "test-hotpod-artifact-marker").expect("write deploy artifact"); + let artifact_text = artifact.to_str().expect("artifact path UTF-8"); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "deploy", + "merchant-lookup", + artifact_text, + "--environment", + "production", + "--version-id", + "ver-123", + "--deployment-id", + "dpl-123", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps deploy process command"); + assert!(output.status.success()); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse deploy process output"), + deployed + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_process_control_plane( + &requests[0], + "POST", + "/v1/agent/apps/merchant-lookup/deploy", + credential, + ); + let body = String::from_utf8_lossy(&requests[0].body_bytes); + for expected in [ + "test-hotpod-artifact-marker", + "name=\"environment\"\r\n\r\nproduction", + "name=\"version_id\"\r\n\r\nver-123", + "name=\"deployment_id\"\r\n\r\ndpl-123", + ] { + assert!(body.contains(expected), "multipart omitted {expected:?}"); + } + } + + #[test] + fn bl_apps_rollback_process_sends_target_and_preserves_readiness() { + let credential = "apps-e2e-only.rollback.session+credential"; + let rollback = json!({ + "ok": true, + "app_id": "merchant/lookup app", + "environment": "staging/west", + "version_id": "ver/122?stable=true", + "previous_version_id": "ver-123", + "deployment_id": "dpl-122", + "route_revision": 10, + "external_url": "https://merchant-lookup--bpsites.example/", + "readiness": { + "control_plane_url": "/v1/agent/apps/merchant-lookup/ready?environment=staging&version_id=ver-122", + "diagnostics_url": "/v1/agent/apps/merchant-lookup/debug?environment=staging&version_id=ver-122" + }, + "next_api_calls": [{ + "method": "GET", + "path": "/v1/agent/apps/merchant-lookup/ready?environment=staging&version_id=ver-122", + "when": "poll until ready is true" + }] + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(rollback.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "rollback", + "merchant/lookup app", + "--environment", + "staging/west", + "--version-id", + "ver/122?stable=true", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps rollback process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse rollback process output"), + rollback + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_process_control_plane( + &requests[0], + "POST", + "/v1/agent/apps/merchant%2Flookup%20app/rollback", + credential, + ); + assert_eq!( + requests[0].body, + json!({ + "environment": "staging/west", + "version_id": "ver/122?stable=true" + }) + ); + } + + #[test] + fn bl_apps_delete_process_sends_confirmed_target_and_preserves_retention_details() { + let credential = "apps-e2e-only.delete.session+credential"; + let deleted = json!({ + "ok": true, + "app_id": "merchant/lookup app", + "environment": "staging/west", + "owner": "apps-user", + "deleted_by": "apps-user", + "deleted_at": "2026-09-02T20:00:00Z", + "active_route_ref": "s3://apps/merchant-lookup/staging/active.json", + "route_revision": 11, + "status": "idle", + "artifacts_retained": true, + "stack_retained": true, + "versions_retained": 3 + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(deleted.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "delete", + "merchant/lookup app", + "--confirm-app-id", + "merchant/lookup app", + "--environment", + "staging/west", + "--confirm-environment", + "staging/west", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps delete process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse delete process output"), + deleted + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_process_control_plane( + &requests[0], + "DELETE", + "/v1/agent/apps/merchant%2Flookup%20app", + credential, + ); + assert_eq!(requests[0].body, json!({"environment": "staging/west"})); + } + + #[test] + fn bl_apps_delete_rejects_mismatched_confirmation_before_auth_or_network() { + let credential = "apps-e2e-only.delete-mismatch.session+credential"; + let auth_server = ProcessServer::start(vec![]); + let control_plane = ProcessServer::start(vec![]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "delete", + "merchant-lookup", + "--confirm-app-id", + "different-app", + "--environment", + "production", + "--confirm-environment", + "production", + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + credential, + ); + + let output = command + .output() + .expect("run Apps delete with mismatched confirmation"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--confirm-app-id to exactly match APP_ID (merchant-lookup)")); + assert!(!stderr.contains(credential)); + assert!(auth_server.finish().is_empty()); + assert!(control_plane.finish().is_empty()); + } + + #[test] + fn bl_apps_delete_rejects_mismatched_environment_before_auth_or_network() { + let credential = "apps-e2e-only.delete-environment-mismatch.session+credential"; + let auth_server = ProcessServer::start(vec![]); + let control_plane = ProcessServer::start(vec![]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "delete", + "merchant-lookup", + "--confirm-app-id", + "merchant-lookup", + "--environment", + "staging", + "--confirm-environment", + "production", + "--base-url", + APPROVED_TEST_BASE_URL, + "--json", + ], + credential, + ); + + let output = command + .output() + .expect("run Apps delete with mismatched environment confirmation"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--confirm-environment to exactly match --environment (staging)")); + assert!(!stderr.contains(credential)); + assert!(auth_server.finish().is_empty()); + assert!(control_plane.finish().is_empty()); + } + + #[test] + fn bl_apps_ready_process_requests_exact_version_and_preserves_response() { + let credential = "apps-e2e-only.ready.session+credential"; + let ready = json!({ + "ok": true, + "app_id": "merchant-lookup", + "version_id": "ver/123?route=active", + "ready": false, + "status": "runner_unavailable", + "active_version_id": "ver/123?route=active", + "route_revision": 8, + "readiness": { + "control_plane_url": "/v1/agent/apps/merchant-lookup/ready?version_id=ver-123", + "diagnostics_url": "/v1/agent/apps/merchant-lookup/debug?version_id=ver-123" + }, + "runner_readiness": { + "http_status": 503, + "error": {"code": "runner_readiness_unreachable"} + }, + "next_action": "Call the diagnostics endpoint." + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(ready.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "ready", + "merchant/lookup app", + "--version-id", + "ver/123?route=active", + "--environment", + "staging/west?cell=1", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps ready process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse ready process output"), + ready + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_process_control_plane( + &requests[0], + "GET", + "/v1/agent/apps/merchant%2Flookup%20app/ready?environment=staging%2Fwest%3Fcell%3D1&version_id=ver%2F123%3Froute%3Dactive", + credential, + ); + assert_eq!(requests[0].body, Value::Null); + } + + #[test] + fn bl_apps_debug_process_preserves_partial_diagnostics() { + let credential = "apps-e2e-only.debug.session+credential"; + let debug = json!({ + "ok": true, + "complete": false, + "status": "incomplete", + "app_id": "merchant-lookup", + "version_id": "ver-123", + "route": {"active_version_id": "ver-122", "version_matches": false}, + "runner_readiness": {"http_status": 503}, + "pods": [{ + "name": "hotpod-runner-abc", + "logs": [{"container": "hotpod-runner", "current": "useful log line"}] + }], + "events": [{"reason": "FailedScheduling", "message": "insufficient cpu"}], + "issues": [{"code": "route_version_mismatch", "severity": "warning"}], + "collection_errors": [{"source": "deployment", "message": "temporarily unavailable"}], + "next_actions": ["Retry the debug request."] + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(debug.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "debug", + "merchant-lookup", + "--environment", + "staging", + "--version-id", + "ver-123", + "--tail-lines", + "75", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps debug process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse debug process output"), + debug + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_process_control_plane( + &requests[0], + "GET", + "/v1/agent/apps/merchant-lookup/debug?environment=staging&version_id=ver-123&tail_lines=75", + credential, + ); + assert_eq!(requests[0].body, Value::Null); + } + + fn test_control_plane_client(base_url: &str, timeout: Duration) -> ControlPlaneClient { + ControlPlaneClient::new_for_test( + APPROVED_TEST_BASE_URL, + "1.0.0", + Style::new(true, false, false), + timeout, + Box::new( + LoopbackTestTransport::new(base_url, timeout) + .expect("build loopback test transport"), + ), + ) + .expect("build test control-plane client") + } + + fn test_credential(secret: &str) -> ComposeSessionCredential { + ComposeSessionCredential::new(secret.to_string()).expect("build test credential") + } + + #[test] + fn compose_session_header_matches_kgoose_contract() { + let secret = "opaque.session+credential/with=punctuation"; + let credential = test_credential(secret); + + assert_eq!( + credential + .authorization_header() + .to_str() + .expect("authorization text"), + format!("BLIdentity {secret}") + ); + for invalid in ["credential\r\nInjected: header", "credential\nheader"] { + let error = ComposeSessionCredential::new(invalid.to_string()) + .err() + .expect("reject invalid session credential"); + assert!(!error.to_string().contains(invalid)); + } + } + + #[test] + fn compose_session_uses_the_exact_credential_returned_by_login() { + let secret = "session_stored_after_browser_login_12345"; + let credential = ComposeSessionCredential::from_stored(StoredSessionCredential { + session_credential: secret.to_string(), + expires_at: Some("2099-01-01T00:00:00Z".to_string()), + }) + .expect("use returned login credential"); + + assert_eq!( + credential + .authorization_header() + .to_str() + .expect("authorization text"), + format!("BLIdentity {secret}") + ); + } + + #[test] + fn control_plane_allowlist_is_exact_and_https_only() { + let style = Style::new(true, false, false); + + for trusted in [ + "https://compose-ctrl.test.blockstaging.build", + "https://compose-ctrl.app.builderlab.xyz", + "https://compose-ctrl.test.blockstaging.build:443", + ] { + assert!( + ControlPlaneClient::new(trusted, "1.0.0", style).is_ok(), + "allowlisted control-plane origin should be accepted" + ); + } + + for untrusted in [ + "http://compose-ctrl.test.blockstaging.build", + "https://attacker.example", + "https://test.blockstaging.build", + "https://app.builderlab.xyz", + "https://compose-ctrl.test.blockstaging.build.attacker.example", + "https://compose-ctrl.test.blockstaging.build:444", + "https://compose-ctrl.app.builderlab.xyz.attacker.example", + "https://compose-ctrl.app.builderlab.xyz:444", + "https://user@compose-ctrl.test.blockstaging.build", + "http://localhost:8080", + "https://localhost:8080", + "http://127.0.0.1:8080", + "https://127.0.0.1:8443", + "http://[::1]:8080", + "https://[::1]:8443", + ] { + let error = ControlPlaneClient::new(untrusted, "1.0.0", style) + .err() + .expect("reject untrusted control-plane origin"); + assert!(error.to_string().contains("approved BuilderLab ingress")); + } + } + + #[test] + fn control_plane_uses_blidentity_authorization_without_identity_headers() { + let secret = "opaque_session_credential_1234567890"; + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn(move || { + let request = server.recv().expect("receive contract request"); + assert_eq!(request.method().as_str(), "GET"); + assert_eq!(request.url(), APPS_CONTRACT_PATH); + assert_eq!( + request + .headers() + .iter() + .find(|header| header.field.equiv("Authorization")) + .map(|header| header.value.as_str()), + Some("BLIdentity opaque_session_credential_1234567890") + ); + for forbidden in [ + "Cookie", + "X-BB-Session-Credential", + "X-Forwarded-User", + "X-Forwarded-Workspace-Id", + ] { + assert!(!request + .headers() + .iter() + .any(|header| header.field.equiv(forbidden))); + } + request + .respond( + Response::from_string(r#"{"contract_version":"test"}"#).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to contract request"); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + + let contract = client + .contract(&test_credential(secret)) + .expect("read contract"); + + assert_eq!(contract["contract_version"], "test"); + server_thread.join().expect("join request server"); + } + + #[test] + fn control_plane_does_not_follow_redirects_or_forward_the_session() { + let target = Server::http("127.0.0.1:0").expect("bind redirect target"); + let target_url = format!("http://{}/stolen", target.server_addr()); + let redirector = Server::http("127.0.0.1:0").expect("bind redirector"); + let base_url = format!("http://{}", redirector.server_addr()); + let redirect_thread = thread::spawn(move || { + let request = redirector.recv().expect("receive original request"); + request + .respond(Response::empty(302).with_header( + Header::from_bytes("Location", target_url).expect("build redirect header"), + )) + .expect("send redirect"); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let secret = "redirect_session_credential_123456"; + + let error = client + .contract(&test_credential(secret)) + .expect_err("reject redirect response"); + + assert!(error.to_string().contains("302")); + assert!(!error.to_string().contains(secret)); + assert!(target + .recv_timeout(Duration::from_millis(250)) + .expect("wait for redirect target") + .is_none()); + redirect_thread.join().expect("join redirect server"); + } + + #[test] + fn expired_session_is_not_retried_and_returns_login_guidance() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn(move || { + let request = server.recv().expect("receive expired session request"); + request + .respond(Response::from_string("expired").with_status_code(401)) + .expect("reject expired session"); + assert!(server + .recv_timeout(Duration::from_millis(250)) + .expect("wait for unexpected retry") + .is_none()); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let secret = "expired_session_credential_1234567"; + + let error = client + .contract(&test_credential(secret)) + .expect_err("reject expired session"); + let message = error.to_string(); + + assert!(message.contains("401")); + assert!(message.contains("bl auth logout")); + assert!(message.contains("bl auth login")); + assert!(!message.contains(secret)); + server_thread.join().expect("join request server"); + } + + #[test] + fn control_plane_errors_redact_the_session_credential() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let secret = "reflected_session_credential_123456"; + let response_body = json!({ + "error": {"code": secret}, + "next_action": format!("remove {secret} from the request") + }) + .to_string(); + let server_thread = thread::spawn(move || { + let request = server.recv().expect("receive request"); + request + .respond(Response::from_string(response_body).with_status_code(400)) + .expect("send reflected error"); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + + let error = client + .contract(&test_credential(secret)) + .expect_err("reject failed request"); + let message = format!("{error:#}"); + + assert!(!message.contains(secret)); + assert!(message.contains("[REDACTED]")); + server_thread.join().expect("join request server"); + } + + #[test] + fn successful_response_rejects_secret_bearing_keys_without_collisions() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let secret = "reflected_key_session_credential_123456"; + let mut nested = Map::new(); + nested.insert(secret.to_string(), json!("secret-key value")); + nested.insert("[REDACTED]".to_string(), json!("existing value")); + let response_body = json!({"nested": Value::Object(nested)}).to_string(); + let server_thread = thread::spawn(move || { + let request = server.recv().expect("receive request"); + request + .respond(Response::from_string(response_body)) + .expect("send reflected key response"); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + + let error = client + .contract(&test_credential(secret)) + .expect_err("reject a successful response with the session in an object key"); + let message = format!("{error:#}"); + + assert!(message.contains("object key")); + assert!(!message.contains(secret)); + server_thread.join().expect("join request server"); + } + + #[test] + fn initialize_and_deploy_allow_delayed_rollout_responses() { + assert!(CONTROL_PLANE_REQUEST_TIMEOUT > Duration::from_secs(2 * 60)); + + let temporary_directory = tempfile::tempdir().expect("create temporary directory"); + let artifact_path = temporary_directory.path().join("artifact.tar.gz"); + fs::write(&artifact_path, b"delayed-rollout-artifact").expect("write artifact"); + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn(move || { + let initialize = server.recv().expect("receive initialize request"); + assert_eq!(initialize.url(), "/v1/agent/apps/delayed-app/initialize"); + thread::sleep(Duration::from_millis(75)); + initialize + .respond( + Response::from_string( + r#"{"ok":true,"app_id":"delayed-app","external_url":"https://delayed-app.example"}"#, + ) + .with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to initialize request"); + + let mut deploy = server.recv().expect("receive deploy request"); + assert_eq!(deploy.url(), "/v1/agent/apps/delayed-app/deploy"); + let mut body = Vec::new(); + deploy + .as_reader() + .read_to_end(&mut body) + .expect("read deploy body"); + assert!(body + .windows(b"delayed-rollout-artifact".len()) + .any(|window| window == b"delayed-rollout-artifact")); + thread::sleep(Duration::from_millis(75)); + deploy + .respond( + Response::from_string(r#"{"ok":true,"version_id":"ver-delayed"}"#).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to deploy request"); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(1)); + + let initialized = client + .initialize( + &test_credential("delayed_session_credential_123456"), + "delayed-app", + &json!({"environment": "staging"}), + ) + .expect("wait for delayed initialize response"); + let deployed = client + .deploy( + &test_credential("delayed_session_credential_123456"), + "delayed-app", + &artifact_path, + &DeployOptions::default(), + ) + .expect("wait for delayed deploy response"); + + assert_eq!(initialized["app_id"], "delayed-app"); + assert_eq!(deployed["version_id"], "ver-delayed"); + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn control_plane_bounds_plan_responses() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn(move || { + let request = server.recv().expect("receive plan request"); + request + .respond(Response::from_data(vec![ + b'x'; + CONTROL_PLANE_RESPONSE_MAX_BYTES + + 1 + ])) + .expect("respond with oversized plan response"); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let request = PlanRequest { + app_id: Some("bounded-app"), + name: None, + environment: None, + runtime_profile: None, + persistence: None, + client_version: "1.0.0", + }; + + let error = client + .plan( + &test_credential("bounded_session_credential_123456"), + &request, + ) + .expect_err("reject oversized plan response"); + + assert!(error.to_string().contains("exceeded 2097152 bytes")); + assert!(!error + .to_string() + .contains("bounded_session_credential_123456")); + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn rollback_supports_previous_and_explicit_version_requests() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn(move || { + for (expected_path, expected_body) in [ + ("/v1/agent/apps/app%2Fwith%20space/rollback", json!({})), + ( + "/v1/agent/apps/app%2Fwith%20space/rollback", + json!({ + "environment": "staging/west?cell=1", + "version_id": "ver/123?stable=true" + }), + ), + ] { + let mut request = server.recv().expect("receive rollback request"); + assert_eq!(request.method().as_str(), "POST"); + assert_eq!(request.url(), expected_path); + let mut body = String::new(); + request + .as_reader() + .read_to_string(&mut body) + .expect("read rollback request body"); + assert_eq!( + serde_json::from_str::(&body).expect("parse rollback request body"), + expected_body + ); + request + .respond( + Response::from_string(r#"{"ok":true}"#).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to rollback request"); + } + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let credential = test_credential("rollback_session_credential_123456"); + + for request in [ + RollbackRequest { + environment: None, + version_id: None, + }, + RollbackRequest { + environment: Some("staging/west?cell=1"), + version_id: Some("ver/123?stable=true"), + }, + ] { + client + .rollback(&credential, "app/with space", &request) + .expect("request rollback response"); + } + + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn delete_sends_the_explicit_environment() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn(move || { + let mut request = server.recv().expect("receive delete request"); + assert_eq!(request.method().as_str(), "DELETE"); + assert_eq!(request.url(), "/v1/agent/apps/app%2Fwith%20space"); + let mut body = String::new(); + request + .as_reader() + .read_to_string(&mut body) + .expect("read delete request body"); + assert_eq!( + serde_json::from_str::(&body).expect("parse delete request body"), + json!({"environment": "staging/west?cell=1"}) + ); + request + .respond( + Response::from_string(r#"{"ok":true}"#).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to delete request"); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let credential = test_credential("delete_session_credential_123456"); + + client + .delete_app( + &credential, + "app/with space", + &DeleteAppRequest { + environment: "staging/west?cell=1", + }, + ) + .expect("request delete response"); + + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn delete_confirmation_requires_exact_app_and_environment_matches() { + validate_delete_confirmation("merchant-lookup", "staging", "merchant-lookup", "staging") + .expect("accept exact delete target confirmation"); + for confirmation in ["different-app", "Merchant-Lookup", "merchant-lookup "] { + let error = + validate_delete_confirmation("merchant-lookup", "staging", confirmation, "staging") + .expect_err("reject mismatched app id confirmation"); + assert!(error.to_string().contains("exactly match APP_ID")); + assert!(!error.to_string().contains(confirmation)); + } + for confirmation in ["production", "Staging", "staging "] { + let error = validate_delete_confirmation( + "merchant-lookup", + "staging", + "merchant-lookup", + confirmation, + ) + .expect_err("reject mismatched environment confirmation"); + assert!(error.to_string().contains("exactly match --environment")); + assert!(!error.to_string().contains(confirmation)); + } + } + + #[test] + fn delete_reports_unknown_outcome_for_an_unreadable_success_response() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn(move || { + let request = server.recv().expect("receive delete request"); + assert_eq!(request.method().as_str(), "DELETE"); + request + .respond(Response::from_string("not-json")) + .expect("respond with unreadable success body"); + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let credential_value = "delete_unknown_session_credential_123456"; + let credential = test_credential(credential_value); + + let error = client + .delete_app( + &credential, + "merchant-lookup", + &DeleteAppRequest { + environment: "staging", + }, + ) + .expect_err("reject unreadable delete success response"); + let (exit_code, payload) = failure_info(&error); + assert_eq!(exit_code, exit_codes::NETWORK); + assert_eq!(payload["error"]["code"], "delete_outcome_unknown"); + let message = payload["error"]["message"] + .as_str() + .expect("outcome error message"); + assert!(message.contains("may have succeeded")); + assert!(message.contains("bl apps get --environment ")); + assert!(message.contains("app.status")); + assert!(!message.contains(credential_value)); + + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn ready_and_debug_build_each_supported_environment_query_shape() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let expected_paths = [ + "/v1/agent/apps/app/ready?version_id=ver-123", + "/v1/agent/apps/app/ready?environment=staging%2Fwest%3Fcell%3D1&version_id=ver%2F123%3Factive", + "/v1/agent/apps/app/debug", + "/v1/agent/apps/app/debug?environment=staging%2Fwest%3Fcell%3D1", + "/v1/agent/apps/app/debug?version_id=ver%2F123%3Factive", + "/v1/agent/apps/app/debug?tail_lines=25", + "/v1/agent/apps/app/debug?environment=staging&version_id=ver-123", + "/v1/agent/apps/app/debug?environment=staging&tail_lines=50", + "/v1/agent/apps/app/debug?version_id=ver-123&tail_lines=75", + "/v1/agent/apps/app/debug?environment=staging&version_id=ver-123&tail_lines=100", + ]; + let server_thread = thread::spawn(move || { + for (index, expected_path) in expected_paths.into_iter().enumerate() { + let request = server.recv().expect("receive debug request"); + assert_eq!(request.method().as_str(), "GET"); + assert_eq!(request.url(), expected_path); + request + .respond( + Response::from_string(format!(r#"{{"request":{index}}}"#)).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to debug request"); + } + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let credential = test_credential("debug_query_session_credential_123456"); + + assert_eq!( + client + .ready(&credential, "app", "ver-123", None) + .expect("request default-environment ready response")["request"], + 0 + ); + assert_eq!( + client + .ready( + &credential, + "app", + "ver/123?active", + Some("staging/west?cell=1"), + ) + .expect("request explicit-environment ready response")["request"], + 1 + ); + + for (index, (environment, version_id, tail_lines)) in [ + (None, None, None), + (Some("staging/west?cell=1"), None, None), + (None, Some("ver/123?active"), None), + (None, None, Some(25)), + (Some("staging"), Some("ver-123"), None), + (Some("staging"), None, Some(50)), + (None, Some("ver-123"), Some(75)), + (Some("staging"), Some("ver-123"), Some(100)), + ] + .into_iter() + .enumerate() + { + let response = client + .debug(&credential, "app", environment, version_id, tail_lines) + .expect("request debug response"); + assert_eq!(response["request"], index + 2); + } + + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn list_builds_each_supported_query_shape() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let expected_paths = [ + "/v1/agent/apps", + "/v1/agent/apps?scope=owned", + "/v1/agent/apps?include_deleted=true", + "/v1/agent/apps?scope=publisher&include_deleted=true", + ]; + let server_thread = thread::spawn(move || { + for (index, expected_path) in expected_paths.into_iter().enumerate() { + let request = server.recv().expect("receive list request"); + assert_eq!(request.method().as_str(), "GET"); + assert_eq!(request.url(), expected_path); + request + .respond( + Response::from_string(format!(r#"{{"request":{index}}}"#)).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to list request"); + } + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let credential = test_credential("list_query_session_credential_123456"); + + for (index, (scope, include_deleted)) in [ + (None, false), + (Some("owned"), false), + (None, true), + (Some("publisher"), true), + ] + .into_iter() + .enumerate() + { + let response = client + .list_apps(&credential, scope, include_deleted) + .expect("request list response"); + assert_eq!(response["request"], index); + } + + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn get_and_versions_support_default_and_explicit_environments() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let expected_paths = [ + "/v1/agent/apps/app", + "/v1/agent/apps/app?environment=staging%2Fwest%3Fcell%3D1", + "/v1/agent/apps/app/versions", + "/v1/agent/apps/app/versions?environment=staging%2Fwest%3Fcell%3D1", + ]; + let server_thread = thread::spawn(move || { + for (index, expected_path) in expected_paths.into_iter().enumerate() { + let request = server.recv().expect("receive inspection request"); + assert_eq!(request.method().as_str(), "GET"); + assert_eq!(request.url(), expected_path); + request + .respond( + Response::from_string(format!(r#"{{"request":{index}}}"#)).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to inspection request"); + } + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let credential = test_credential("inspection_environment_session_credential_123456"); + + let responses = [ + client.get_app(&credential, "app", None), + client.get_app(&credential, "app", Some("staging/west?cell=1")), + client.versions(&credential, "app", None), + client.versions(&credential, "app", Some("staging/west?cell=1")), + ]; + for (index, response) in responses.into_iter().enumerate() { + assert_eq!( + response.expect("request inspection response")["request"], + index + ); + } + + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn access_get_and_set_support_each_environment_and_viewer_shape() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let server_thread = thread::spawn(move || { + for (index, (method, expected_path, expected_body)) in [ + ("GET", "/v1/agent/apps/app/access", None), + ( + "GET", + "/v1/agent/apps/app/access?environment=staging%2Fwest%3Fcell%3D1", + None, + ), + ( + "PUT", + "/v1/agent/apps/app/access", + Some(json!({"visibility": "organization", "viewers": []})), + ), + ( + "PUT", + "/v1/agent/apps/app/access", + Some(json!({ + "visibility": "restricted", + "viewers": ["auth0|alice", "auth0|bob"], + "environment": "staging/west?cell=1" + })), + ), + ] + .into_iter() + .enumerate() + { + let mut request = server.recv().expect("receive access request"); + assert_eq!(request.method().as_str(), method); + assert_eq!(request.url(), expected_path); + let mut body = String::new(); + request + .as_reader() + .read_to_string(&mut body) + .expect("read access request body"); + match expected_body { + Some(expected_body) => assert_eq!( + serde_json::from_str::(&body).expect("parse access request body"), + expected_body + ), + None => assert!(body.is_empty(), "GET access body was: {body}"), + } + request + .respond( + Response::from_string(format!(r#"{{"request":{index}}}"#)).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to access request"); + } + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let credential = test_credential("access_environment_session_credential_123456"); + + let organization = AccessRequest { + visibility: "organization", + viewers: vec![], + environment: None, + }; + let restricted = AccessRequest { + visibility: "restricted", + viewers: vec!["auth0|alice", "auth0|bob"], + environment: Some("staging/west?cell=1"), + }; + let responses = [ + client.get_access(&credential, "app", None), + client.get_access(&credential, "app", Some("staging/west?cell=1")), + client.set_access(&credential, "app", &organization), + client.set_access(&credential, "app", &restricted), + ]; + for (index, response) in responses.into_iter().enumerate() { + assert_eq!(response.expect("request access response")["request"], index); + } + + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn access_set_rejects_unknown_visibility_before_auth_or_network() { + let error = command() + .try_get_matches_from([ + "apps", + "access", + "set", + "app", + "--visibility", + "public", + "--base-url", + APPROVED_TEST_BASE_URL, + ]) + .expect_err("reject unknown access visibility"); + assert_eq!(error.kind(), clap::error::ErrorKind::InvalidValue); + } + + #[test] + fn debug_tail_lines_match_control_plane_bounds() { + for invalid in ["0", "1001"] { + let error = command() + .try_get_matches_from([ + "apps", + "debug", + "app", + "--tail-lines", + invalid, + "--base-url", + APPROVED_TEST_BASE_URL, + ]) + .expect_err("reject out-of-range tail lines"); + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + } + + for valid in ["1", "1000"] { + command() + .try_get_matches_from([ + "apps", + "debug", + "app", + "--tail-lines", + valid, + "--base-url", + APPROVED_TEST_BASE_URL, + ]) + .expect("accept bounded tail lines"); + } + } + + #[test] + fn app_resource_urls_encode_path_segments_and_query_values() { + let client = test_control_plane_client("http://127.0.0.1:9", Duration::from_secs(2)); + + let url = client + .app_action_url("app/../../identity", "deploy") + .expect("build app deploy URL"); + + assert_eq!(url.path(), "/v1/agent/apps/app%2F..%2F..%2Fidentity/deploy"); + + let app = client + .app_url("app/with space", &[]) + .expect("build app detail URL"); + assert_eq!(app.path(), "/v1/agent/apps/app%2Fwith%20space"); + + let ready = client + .app_resource_url( + "app/with space", + "ready", + &[("version_id", "version/?&= value".to_string())], + ) + .expect("build app ready URL"); + assert_eq!( + request_path(&ready), + "/v1/agent/apps/app%2Fwith%20space/ready?version_id=version%2F%3F%26%3D+value" + ); + } +} diff --git a/src/bl/auth.rs b/src/bl/auth.rs new file mode 100644 index 0000000..2240a3d --- /dev/null +++ b/src/bl/auth.rs @@ -0,0 +1 @@ +pub use builderlab_auth::auth::SESSION_CREDENTIAL_HEADER; diff --git a/src/bl/auth_callback.html b/src/bl/auth_callback.html new file mode 100644 index 0000000..1d4e464 --- /dev/null +++ b/src/bl/auth_callback.html @@ -0,0 +1,188 @@ + + + + + + + __PAGE_TITLE__ + + + +
+
Berd
+ +

__HEADING__

+

__MESSAGE__

+
__TERMINAL_MESSAGE__
+
+ + diff --git a/src/bl/auth_login.rs b/src/bl/auth_login.rs new file mode 100644 index 0000000..a6f8c49 --- /dev/null +++ b/src/bl/auth_login.rs @@ -0,0 +1,531 @@ +//! Browser login for BuilderLab auth. + +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use builderlab_auth::auth_login::{ + build_auth_http_client, exchange_login_code_and_verify, login_url, logout_session_credential, + verify_session_credential, AuthMeResponse, VerifiedLoginSession, +}; +use builderlab_auth::auth_storage::StoredSessionCredential; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tiny_http::{Header, Response, Server, StatusCode}; +use url::Url; + +use super::auth_storage::{session_storage_key_from_config, SessionCredentialStorage}; +use super::skills_config::{kgoose_service_url, SkillsConfig}; + +const CALLBACK_PATH: &str = "/callback"; + +#[derive(Clone, Copy)] +enum AuthCallbackPage { + Success, + Failure, +} + +const AUTH_CALLBACK_PAGE_TEMPLATE: &str = include_str!("auth_callback.html"); + +#[derive(Debug, Serialize)] +pub struct BrowserLoginSummary { + pub kgoose_base_url: String, + pub kgoose_service_path: String, + pub storage: String, + pub source: BrowserLoginCredentialSource, + pub workspace_name: String, + pub expires_at: Option, + pub credential_prefix: Option, + pub credential_sha256_prefix: Option, +} + +struct BrowserLoginOutcome { + summary: BrowserLoginSummary, +} + +pub struct VerifiedStoredSession { + pub credential: StoredSessionCredential, + pub me: AuthMeResponse, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserLoginCredentialSource { + Stored, + BrowserLogin, +} + +pub fn run_browser_login( + config: &SkillsConfig, + storage: &dyn SessionCredentialStorage, +) -> Result { + run_browser_login_inner(config, storage).map(|outcome| outcome.summary) +} + +fn run_browser_login_inner( + config: &SkillsConfig, + storage: &dyn SessionCredentialStorage, +) -> Result { + let service_url = kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path); + let client = build_auth_http_client(Duration::from_secs(30))?; + let storage_key = session_storage_key_from_config(config); + + match storage.get(&storage_key)? { + Some(stored) => { + if let Some(me) = verify_session_credential( + &client, + config.playpen.as_deref(), + &service_url, + &stored, + )? { + auth_info( + config, + &format!( + "Found valid BuilderLab CLI auth session in {} storage", + storage.kind() + ), + ); + let workspace_name = me.active_workspace_name()?.to_string(); + return Ok(BrowserLoginOutcome { + summary: BrowserLoginSummary { + kgoose_base_url: config.kgoose_base_url.clone(), + kgoose_service_path: config.kgoose_service_path.clone(), + storage: storage.kind().to_string(), + source: BrowserLoginCredentialSource::Stored, + workspace_name, + expires_at: me.expires_at.or_else(|| stored.expires_at.clone()), + credential_prefix: None, + credential_sha256_prefix: None, + }, + }); + } + auth_info( + config, + &format!( + "Stored BuilderLab CLI auth session in {} storage is invalid", + storage.kind() + ), + ); + } + None => { + auth_info( + config, + &format!( + "No BuilderLab CLI auth session found in {} storage", + storage.kind() + ), + ); + } + } + + let server = Server::http("127.0.0.1:0") + .map_err(|error| anyhow!("listen on loopback callback port: {error}"))?; + let callback_url = format!("http://{}{}", server.server_addr(), CALLBACK_PATH); + let login_url = login_url(&service_url, &callback_url)?; + + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let result = receive_exchange_code(server); + let _ = tx.send(result); + }); + + if !config.json { + println!("Opening BuilderLab auth login in your browser:"); + println!("{login_url}"); + } + if let Err(error) = webbrowser::open(login_url.as_str()) { + if config.json { + return Err(anyhow!( + "failed to open browser for BuilderLab auth login: {error}" + )); + } + println!("Could not open a browser automatically. Open the URL above manually."); + } + + let code = rx + .recv() + .context("loopback auth server stopped before login completed")??; + let verified = + exchange_login_code_and_verify(&client, config.playpen.as_deref(), &service_url, &code)?; + let (stored, me, workspace_name) = + validate_and_store_login_credential(storage, &storage_key, verified)?; + auth_info( + config, + &format!( + "Stored BuilderLab CLI auth session in {} storage", + storage.kind() + ), + ); + + Ok(BrowserLoginOutcome { + summary: BrowserLoginSummary { + kgoose_base_url: config.kgoose_base_url.clone(), + kgoose_service_path: config.kgoose_service_path.clone(), + storage: storage.kind().to_string(), + source: BrowserLoginCredentialSource::BrowserLogin, + workspace_name, + expires_at: me.expires_at.or_else(|| stored.expires_at.clone()), + credential_prefix: Some(safe_prefix(&stored.session_credential)), + credential_sha256_prefix: Some(sha256_prefix(&stored.session_credential)), + }, + }) +} + +pub fn verify_stored_session( + config: &SkillsConfig, + storage: &dyn SessionCredentialStorage, +) -> Result> { + let service_url = kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path); + let client = build_auth_http_client(Duration::from_secs(30))?; + let storage_key = session_storage_key_from_config(config); + verify_stored_session_with(storage, &storage_key, |stored| { + verify_session_credential(&client, config.playpen.as_deref(), &service_url, stored) + }) +} + +fn verify_stored_session_with( + storage: &dyn SessionCredentialStorage, + storage_key: &super::auth_storage::SessionStorageKey, + verify: F, +) -> Result> +where + F: FnOnce(&StoredSessionCredential) -> Result>, +{ + let Some(stored) = storage.get(storage_key)? else { + return Ok(None); + }; + Ok(verify(&stored)?.map(|me| VerifiedStoredSession { + credential: stored, + me, + })) +} + +fn store_login_credential( + storage: &dyn SessionCredentialStorage, + storage_key: &super::auth_storage::SessionStorageKey, + credential: StoredSessionCredential, +) -> Result { + storage.set(storage_key, &credential)?; + Ok(credential) +} + +fn validate_and_store_login_credential( + storage: &dyn SessionCredentialStorage, + storage_key: &super::auth_storage::SessionStorageKey, + verified: VerifiedLoginSession, +) -> Result<(StoredSessionCredential, AuthMeResponse, String)> { + let workspace_name = verified.me.active_workspace_name()?.to_string(); + let stored = store_login_credential(storage, storage_key, verified.credential)?; + Ok((stored, verified.me, workspace_name)) +} + +pub fn logout_stored_session( + config: &SkillsConfig, + storage: &dyn SessionCredentialStorage, +) -> Result { + let service_url = kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path); + let client = build_auth_http_client(Duration::from_secs(30))?; + let storage_key = session_storage_key_from_config(config); + let Some(stored) = storage.get(&storage_key)? else { + return Ok(false); + }; + + logout_session_credential(&client, config.playpen.as_deref(), &service_url, &stored) +} + +fn receive_exchange_code(server: Server) -> Result { + for request in server.incoming_requests() { + let url = request.url().to_string(); + let parsed = + Url::parse(&format!("http://127.0.0.1{url}")).context("parse loopback callback URL")?; + if parsed.path() != CALLBACK_PATH { + respond_text( + request, + StatusCode(404), + "BuilderLab CLI auth is waiting for the callback.", + )?; + continue; + } + + let error = parsed + .query_pairs() + .find(|(key, _)| key == "error") + .map(|(_, value)| value.into_owned()); + if let Some(error) = error { + respond_auth_page(request, StatusCode(400), AuthCallbackPage::Failure)?; + return Err(anyhow!("auth callback returned error: {error}")); + } + + let code = parsed + .query_pairs() + .find(|(key, _)| key == "code") + .map(|(_, value)| value.into_owned()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("auth callback did not include an exchange code"))?; + respond_auth_page(request, StatusCode(200), AuthCallbackPage::Success)?; + return Ok(code); + } + + Err(anyhow!( + "loopback auth server stopped without receiving a callback" + )) +} + +fn respond_text(request: tiny_http::Request, status: StatusCode, body: &str) -> Result<()> { + request + .respond( + Response::from_string(body) + .with_status_code(status) + .with_header( + Header::from_bytes("content-type", "text/plain; charset=utf-8").unwrap(), + ), + ) + .map_err(|error| anyhow!("write loopback callback response: {error}")) +} + +fn respond_auth_page( + request: tiny_http::Request, + status: StatusCode, + page: AuthCallbackPage, +) -> Result<()> { + request + .respond( + Response::from_string(auth_callback_page(page)) + .with_status_code(status) + .with_header( + Header::from_bytes("content-type", "text/html; charset=utf-8").unwrap(), + ) + .with_header(Header::from_bytes("cache-control", "no-store").unwrap()) + .with_header( + Header::from_bytes( + "content-security-policy", + "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'", + ) + .unwrap(), + ) + .with_header(Header::from_bytes("x-content-type-options", "nosniff").unwrap()), + ) + .map_err(|error| anyhow!("write loopback callback response: {error}")) +} + +fn auth_callback_page(page: AuthCallbackPage) -> String { + let (title, class_name, icon, heading, message, terminal_message) = match page { + AuthCallbackPage::Success => ( + "Signed in · BuilderLab CLI", + "success", + r#""#, + "You’re signed in", + "BuilderLab CLI authentication is complete. You can safely close this tab.", + "Return to your terminal", + ), + AuthCallbackPage::Failure => ( + "Sign-in failed · BuilderLab CLI", + "failure", + r#""#, + "Sign-in didn’t finish", + "BuilderLab CLI couldn’t complete authentication. Return to your terminal for details.", + "Check your terminal", + ), + }; + + AUTH_CALLBACK_PAGE_TEMPLATE + .replace("__PAGE_TITLE__", title) + .replace("__PAGE_CLASS__", class_name) + .replace("__STATUS_ICON__", icon) + .replace("__HEADING__", heading) + .replace("__MESSAGE__", message) + .replace("__TERMINAL_MESSAGE__", terminal_message) +} + +fn safe_prefix(value: &str) -> String { + value.chars().take(8).collect() +} + +fn sha256_prefix(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + digest + .iter() + .take(6) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn auth_info(config: &SkillsConfig, message: &str) { + if config.json { + eprintln!("info: {message}"); + } else { + config.style.info(message); + } +} + +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + use std::collections::VecDeque; + + use anyhow::Result; + use builderlab_auth::auth_login::{ + AuthMeResponse, AuthMeWorkspace, AuthMeWorkspaces, VerifiedLoginSession, + }; + use builderlab_auth::auth_storage::{ + SessionCredentialStorage, SessionStorageKey, StoredSessionCredential, + }; + + use super::{ + auth_callback_page, store_login_credential, validate_and_store_login_credential, + verify_stored_session_with, AuthCallbackPage, + }; + + struct SwappingStorage { + reads: RefCell>, + read_count: Cell, + writes: RefCell>, + } + + impl SwappingStorage { + fn new(reads: impl IntoIterator) -> Self { + Self { + reads: RefCell::new(reads.into_iter().collect()), + read_count: Cell::new(0), + writes: RefCell::new(Vec::new()), + } + } + } + + impl SessionCredentialStorage for SwappingStorage { + fn kind(&self) -> &'static str { + "swapping" + } + + fn get(&self, _key: &SessionStorageKey) -> Result> { + self.read_count.set(self.read_count.get() + 1); + Ok(self.reads.borrow_mut().pop_front()) + } + + fn set( + &self, + _key: &SessionStorageKey, + credential: &StoredSessionCredential, + ) -> Result<()> { + self.writes.borrow_mut().push(credential.clone()); + Ok(()) + } + + fn delete(&self, _key: &SessionStorageKey) -> Result { + Ok(false) + } + } + + fn stored(value: &str) -> StoredSessionCredential { + StoredSessionCredential { + session_credential: value.to_string(), + expires_at: None, + } + } + + fn auth_me() -> AuthMeResponse { + AuthMeResponse { + subject: None, + email: None, + name: None, + expires_at: None, + workspaces: AuthMeWorkspaces { + active: vec![AuthMeWorkspace { + name: "Test Workspace".to_string(), + }], + }, + } + } + + #[test] + fn verification_returns_the_exact_credential_that_was_checked() { + let storage = SwappingStorage::new([stored("verified-a"), stored("substituted-b")]); + let key = SessionStorageKey::new("default", "https://kgoose.example"); + + let verified = verify_stored_session_with(&storage, &key, |credential| { + assert_eq!(credential.session_credential, "verified-a"); + Ok(Some(auth_me())) + }) + .expect("verify stored session") + .expect("verified session"); + + assert_eq!(verified.credential.session_credential, "verified-a"); + assert_eq!(storage.read_count.get(), 1); + assert_eq!( + storage + .get(&key) + .expect("read substituted credential") + .expect("substituted credential") + .session_credential, + "substituted-b" + ); + } + + #[test] + fn interactive_login_completion_returns_issued_credential_without_rereading_storage() { + let storage = SwappingStorage::new([stored("substituted-b")]); + let key = SessionStorageKey::new("default", "https://kgoose.example"); + + let returned = store_login_credential(&storage, &key, stored("issued-a")) + .expect("store completed login"); + + assert_eq!(returned.session_credential, "issued-a"); + assert_eq!(storage.read_count.get(), 0); + assert_eq!(storage.writes.borrow()[0].session_credential, "issued-a"); + } + + #[test] + fn browser_login_does_not_store_a_session_without_an_active_workspace() { + let storage = SwappingStorage::new([]); + let key = SessionStorageKey::new("default", "https://kgoose.example"); + let verified = VerifiedLoginSession { + credential: stored("issued-without-workspace"), + me: AuthMeResponse { + subject: None, + email: None, + name: None, + expires_at: None, + workspaces: AuthMeWorkspaces { active: vec![] }, + }, + }; + + let error = validate_and_store_login_credential(&storage, &key, verified) + .expect_err("reject login without an active workspace"); + + assert!(error.to_string().contains("no active workspaces")); + assert!(storage.writes.borrow().is_empty()); + assert!(storage.get(&key).expect("read storage").is_none()); + } + + #[test] + fn callback_pages_are_self_contained_and_themed() { + for page in [AuthCallbackPage::Success, AuthCallbackPage::Failure] { + let html = auth_callback_page(page); + + assert!(html.starts_with("")); + assert!(html.contains("prefers-color-scheme: dark")); + assert!(html.contains("prefers-reduced-motion: no-preference")); + assert!(html.contains(">Berd")); + assert!(!html.contains("src=")); + assert!(!html.contains("href=")); + assert!(!html.contains("@import")); + assert!(!html.contains("__PAGE_")); + assert!(!html.contains("__STATUS_")); + assert!(!html.contains("__HEADING__")); + assert!(!html.contains("__MESSAGE__")); + assert!(!html.contains("__TERMINAL_")); + } + } + + #[test] + fn callback_pages_have_distinct_outcomes() { + let success = auth_callback_page(AuthCallbackPage::Success); + let failure = auth_callback_page(AuthCallbackPage::Failure); + + assert!(success.contains("You’re signed in")); + assert!(success.contains(r#"class="success""#)); + assert!(failure.contains("Sign-in didn’t finish")); + assert!(failure.contains(r#"class="failure""#)); + } +} diff --git a/src/bl/auth_storage.rs b/src/bl/auth_storage.rs new file mode 100644 index 0000000..6c1aa54 --- /dev/null +++ b/src/bl/auth_storage.rs @@ -0,0 +1,21 @@ +use anyhow::Result; + +pub use builderlab_auth::auth_storage::{ + default_session_storage_for_bl_home, stored_session_credential_header_value, + stored_session_credential_header_value_for_kgoose_base_url, SessionCredentialStorage, + SessionStorageKey, +}; + +use super::skills_config::SkillsConfig; + +pub fn default_session_storage(config: &SkillsConfig) -> Result> { + default_session_storage_for_bl_home(config.bl_home.clone()) +} + +pub fn session_storage_key_from_config(config: &SkillsConfig) -> SessionStorageKey { + SessionStorageKey::from_profile_and_kgoose_base_url( + config.profile.clone(), + &config.kgoose_base_url, + &config.kgoose_service_path, + ) +} diff --git a/src/bl/description.rs b/src/bl/description.rs new file mode 100644 index 0000000..628f16c --- /dev/null +++ b/src/bl/description.rs @@ -0,0 +1,21 @@ +use clap::Command; +use serde_json::{json, Value}; + +pub fn describe_command_tree(command: &Command) -> Value { + let commands = command + .get_subcommands() + .filter(|subcommand| !subcommand.is_hide_set()) + .map(describe_command_tree) + .collect::>(); + let mut value = json!({ + "name": command.get_name(), + "summary": command + .get_about() + .map(|about| about.to_string()) + .unwrap_or_default(), + }); + if !commands.is_empty() { + value["commands"] = json!(commands); + } + value +} diff --git a/src/bl/display.rs b/src/bl/display.rs new file mode 100644 index 0000000..ea97fa2 --- /dev/null +++ b/src/bl/display.rs @@ -0,0 +1,172 @@ +//! Small display layer for `bl skills` output. +//! +//! Centralizes color and message vocabulary so output sites stay consistent. +//! Color is suppressed by `--no-color`, `--json`, the `NO_COLOR` environment +//! variable, or when stdout is not a terminal. + +use std::fmt::Write as _; +use std::io::IsTerminal; + +use anyhow::{Context, Result}; +use serde::Serialize; + +const RESET: &str = "\x1b[0m"; +const BOLD: &str = "\x1b[1m"; +const DIM: &str = "\x1b[2m"; +const RED: &str = "\x1b[31m"; +const GREEN: &str = "\x1b[32m"; +const YELLOW: &str = "\x1b[33m"; +const CYAN: &str = "\x1b[36m"; +const BOLD_CYAN: &str = "\x1b[1;36m"; + +#[derive(Debug, Clone, Copy)] +pub struct Style { + color: bool, + verbose: bool, +} + +impl Style { + pub fn new(no_color: bool, json: bool, verbose: bool) -> Self { + let env_no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()); + let color = !no_color && !json && !env_no_color && std::io::stdout().is_terminal(); + Self { color, verbose } + } + + fn paint(&self, code: &str, text: &str) -> String { + if self.color { + format!("{code}{text}{RESET}") + } else { + text.to_string() + } + } + + pub fn bold(&self, text: &str) -> String { + self.paint(BOLD, text) + } + + pub fn dim(&self, text: &str) -> String { + self.paint(DIM, text) + } + + pub fn green(&self, text: &str) -> String { + self.paint(GREEN, text) + } + + pub fn yellow(&self, text: &str) -> String { + self.paint(YELLOW, text) + } + + pub fn red(&self, text: &str) -> String { + self.paint(RED, text) + } + + pub fn cyan(&self, text: &str) -> String { + self.paint(CYAN, text) + } + + /// Emphasized identifier styling (skill slugs, command names). + pub fn slug(&self, text: &str) -> String { + self.paint(BOLD_CYAN, text) + } + + /// Render a field label like `tags:` so key/value output reads at a + /// glance; the value stays in the default color. + pub fn label(&self, text: &str) -> String { + self.cyan(text) + } + + /// Print a success line: `✓ message`. + pub fn success(&self, message: &str) { + println!("{} {message}", self.green("✓")); + } + + /// Print an informational line: `• message`. + pub fn info(&self, message: &str) { + println!("{} {message}", self.cyan("•")); + } + + /// Print a warning line to stderr: `! message`. + pub fn warn(&self, message: &str) { + eprintln!("{} {message}", self.yellow("!")); + } + + /// Log a verbose diagnostic line to stderr when `--verbose` is set. + pub fn verbose(&self, message: &str) { + if self.verbose { + eprintln!("{}", self.dim(&format!("[verbose] {message}"))); + } + } +} + +/// True when stdin is attached to an interactive terminal, meaning the CLI +/// may prompt for confirmation instead of requiring `--yes`. +pub fn stdin_is_tty() -> bool { + std::io::stdin().is_terminal() +} + +pub fn print_json(value: &T) -> Result<()> { + let json = serde_json::to_string_pretty(value).context("serialize JSON output")?; + println!("{}", json_with_escaped_bidi_controls(&json)); + Ok(()) +} + +pub fn terminal_safe_text(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if character.is_control() || is_bidi_control(character) { + escaped.extend(character.escape_default()); + } else { + escaped.push(character); + } + } + escaped +} + +fn json_with_escaped_bidi_controls(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if is_bidi_control(character) { + write!(escaped, "\\u{:04x}", character as u32) + .expect("writing to a String cannot fail"); + } else { + escaped.push(character); + } + } + escaped +} + +fn is_bidi_control(character: char) -> bool { + matches!( + character, + '\u{061c}' + | '\u{200e}' + | '\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + ) +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::*; + + #[test] + fn terminal_safe_text_escapes_controls_and_preserves_unicode() { + assert_eq!( + terminal_safe_text("Workspace\u{1b}]52;c;secret\u{7}\n\u{202e}fake\u{2066}日本語"), + "Workspace\\u{1b}]52;c;secret\\u{7}\\n\\u{202e}fake\\u{2066}日本語" + ); + } + + #[test] + fn json_with_escaped_bidi_controls_remains_valid_json() { + let input = format!(r#"{{"name":"safe{}fake{}日本語"}}"#, '\u{202e}', '\u{2066}'); + let escaped = json_with_escaped_bidi_controls(&input); + + assert_eq!(escaped, r#"{"name":"safe\u202efake\u2066日本語"}"#); + let parsed: Value = serde_json::from_str(&escaped).expect("valid escaped JSON"); + assert_eq!(parsed["name"], "safe\u{202e}fake\u{2066}日本語"); + } +} diff --git a/src/bl/mod.rs b/src/bl/mod.rs new file mode 100644 index 0000000..ff1732e --- /dev/null +++ b/src/bl/mod.rs @@ -0,0 +1,21 @@ +pub mod agents; +pub mod agents_install; +pub mod agents_models; +pub mod apps; +pub mod auth; +pub mod auth_login; +pub mod auth_storage; +pub mod description; +pub mod display; +pub mod org_routing; +pub mod runner; +pub mod skills; +pub mod skills_api; +pub mod skills_archive; +pub mod skills_config; +pub mod skills_doctor; +pub mod skills_install; +pub mod skills_models; +pub mod skills_slug; +pub mod skills_targets; +pub mod workspace; diff --git a/src/bl/org_routing.rs b/src/bl/org_routing.rs new file mode 100644 index 0000000..f40d3f0 --- /dev/null +++ b/src/bl/org_routing.rs @@ -0,0 +1 @@ +pub use builderlab_auth::org_routing::{normalize_org, resolve_org_kgoose_base_url}; diff --git a/src/bl/runner.rs b/src/bl/runner.rs new file mode 100644 index 0000000..457b485 --- /dev/null +++ b/src/bl/runner.rs @@ -0,0 +1,52 @@ +use anyhow::Result; +use clap::ArgMatches; + +use super::skills_api::{exit_codes, failure, failure_info, SilentJsonExit}; +use super::skills_config::SkillsConfig; + +pub fn ensure_org_configured(config: &SkillsConfig) -> Result<()> { + if config.local_dev || config.org.is_some() { + return Ok(()); + } + Err(missing_org_error()) +} + +pub fn missing_org_error() -> anyhow::Error { + failure( + exit_codes::AUTH_REQUIRED, + "org_required", + "bl org is not configured; run `bl auth login` or `bl config set org `", + ) +} + +pub fn run( + matches: &ArgMatches, + dispatch: fn(&SkillsConfig, &ArgMatches) -> Result<()>, +) -> Result<()> { + let config = SkillsConfig::resolve(matches)?; + run_resolved(&config, matches, dispatch) +} + +pub fn run_for_config( + matches: &ArgMatches, + dispatch: fn(&SkillsConfig, &ArgMatches) -> Result<()>, +) -> Result<()> { + let config = SkillsConfig::resolve_for_config(matches)?; + run_resolved(&config, matches, dispatch) +} + +fn run_resolved( + config: &SkillsConfig, + matches: &ArgMatches, + dispatch: fn(&SkillsConfig, &ArgMatches) -> Result<()>, +) -> Result<()> { + match dispatch(config, matches) { + Ok(()) => Ok(()), + Err(error) if config.json => { + let (exit_code, payload) = failure_info(&error); + eprintln!("{payload}"); + Err(anyhow::Error::new(SilentJsonExit(exit_code))) + } + Err(error) => Err(error), + } +} diff --git a/src/bl/skills.rs b/src/bl/skills.rs new file mode 100644 index 0000000..3abfc53 --- /dev/null +++ b/src/bl/skills.rs @@ -0,0 +1,2126 @@ +//! `bl skills` command tree and dispatch. +//! +//! Catalog and install-plan resolution stay on the server; this module wires +//! the marketplace client, local package state, and target linking together. + +use std::collections::BTreeMap; +use std::io::Write as IoWrite; + +use anyhow::{Context, Result}; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use serde_json::{json, Value}; + +use super::auth_login::{ + logout_stored_session, run_browser_login, verify_stored_session, BrowserLoginCredentialSource, +}; +use super::auth_storage::default_session_storage; +use super::description::describe_command_tree; +use super::display::{print_json, stdin_is_tty, terminal_safe_text, Style}; +use super::org_routing::{normalize_org, resolve_org_kgoose_base_url}; +use super::runner::{self, ensure_org_configured, missing_org_error}; +use super::skills_api::{exit_codes, failure, MarketplaceClient}; +use super::skills_archive::validate_preview_path; +use super::skills_config::SkillsConfig; +use super::skills_doctor::{run_doctor, CheckStatus}; +use super::skills_install::{ + canonical_dir, confirm_or_bail, ensure_base_dirs, execute_plan, install_local_path, + installed_request_payload, read_installed, read_metadata, remove_skill, ExecuteOptions, + InstallLock, PlanExecution, SetupSummary, +}; +use super::skills_models::{ + BundleSummary, InstallPlanRequest, InstallPlanResponse, InstalledSkillMetadata, + RequestedTarget, SkillDetail, SkillSummary, SkillVersionDetail, PREFERENCE_KEYS, +}; +use super::skills_targets::{inspect_link, LinkState, ResolvedTarget, Scope, TargetRegistry}; + +pub(crate) const EXIT_CODES_HELP: &str = "EXIT CODES:\n \ +0 success\n \ +1 general failure\n \ +2 invalid CLI usage\n \ +3 authentication required or expired\n \ +4 authorization denied\n \ +5 network or server unavailable\n \ +6 install plan blocked by policy or validation\n \ +7 local filesystem conflict\n \ +8 checksum or artifact verification failed\n \ +9 user canceled or confirmation required"; + +pub fn skills_command() -> Command { + Command::new("skills") + .about("Manage BuilderLab skills") + .long_about( + "Manage BuilderLab skills: discover them in the marketplace, install them \ + into your agents' skill directories, keep them updated, and diagnose the \ + local installation.\n\n\ + Skills are installed canonically under `~/.agents/skills/` and \ + linked (symlink with copy fallback) into each other target agent's real \ + skills directory, e.g. `~/.claude/skills`. `~/.bl` holds only bl state \ + (downloads, cache, locks) and configuration, never the skills themselves. \ + The server's target registry defines which targets exist.", + ) + .after_help(EXIT_CODES_HELP) + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand( + Command::new("search") + .about("Search marketplace skills") + .long_about( + "Search marketplace skills by free-text query. Matches slug, name, \ + description, and tags server-side. Use --json for machine-readable \ + output.", + ) + .arg(Arg::new("query").required(true).help("Free-text search query")), + ) + .subcommand( + Command::new("list") + .about("List marketplace skills") + .long_about( + "List marketplace skills. Follows pagination so large catalogs are \ + fully listed. Installed skills are marked and sorted to the top. \ + Filter with --installed, --source, or --status.", + ) + .arg( + Arg::new("installed") + .long("installed") + .help("Only show skills that are installed locally") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("source") + .long("source") + .value_name("SOURCE_ID") + .help("Only show skills from this source"), + ) + .arg( + Arg::new("status") + .long("status") + .value_name("STATUS") + .help("Only show skills with this status (e.g. stable)"), + ), + ) + .subcommand( + Command::new("show") + .about("Show one marketplace skill") + .long_about( + "Show one marketplace skill. Use --version to inspect a specific \ + version and --file to print a file from the skill package before \ + installing it (e.g. --file SKILL.md).", + ) + .arg(Arg::new("slug").required(true)) + .arg( + Arg::new("version") + .long("version") + .value_name("VERSION_ID") + .help("Show a specific version instead of the latest"), + ) + .arg( + Arg::new("file") + .long("file") + .value_name("PATH") + .help("Print one file from the skill package (validated, relative path)"), + ), + ) + .subcommand( + Command::new("files") + .about("List the files inside a marketplace skill") + .long_about( + "List the files inside a marketplace skill version without \ + installing it. Defaults to the latest version; use --version for \ + a specific one. Pair with `show --file ` to read a \ + file's contents.", + ) + .arg(Arg::new("slug").required(true)) + .arg( + Arg::new("version") + .long("version") + .value_name("VERSION_ID") + .help("Inspect a specific version instead of the latest"), + ), + ) + .subcommand( + Command::new("bundles") + .about("List marketplace bundles") + .long_about( + "List marketplace bundles (curated sets of skills). Install one \ + with `bl skills install --bundle `.", + ) + .arg(Arg::new("query").help("Optional free-text filter")), + ) + .subcommand(install_command()) + .subcommand(update_command()) + .subcommand(remove_command()) + .subcommand( + Command::new("installed") + .about("List locally installed skills") + .long_about( + "List locally installed skills with their versions and targets. \ + When the marketplace is reachable, each skill is checked against \ + the latest catalog version and stale skills are marked \ + 'update available'. Works offline (the remote check degrades to \ + a warning).", + ) + .arg(project_flag()), + ) + .subcommand( + Command::new("which") + .about("Show where a skill is installed and where it is linked") + .long_about( + "Show where a skill is installed (canonical package directory), \ + where it came from, and the state of every target link (ok, \ + missing, broken, or unmanaged).", + ) + .arg(Arg::new("slug").required(true)) + .arg(project_flag()), + ) + .subcommand( + Command::new("doctor") + .about("Diagnose the local skills installation") + .long_about( + "Run independent diagnostic probes: config parse, profile, \ + stored auth session, server reachability (distinguishing auth \ + failures from the server being down), capabilities, package \ + metadata, target links, and leftover staging directories. Each \ + probe reports pass/warn/fail; an unreachable server never hides \ + the local checks.\n\n\ + `--fix` repairs what is safe to repair: creates missing base \ + directories, removes orphaned staging/backup directories, and \ + re-links broken target links. It never deletes unmanaged files.", + ) + .arg( + Arg::new("fix") + .long("fix") + .help("Repair safe-to-repair problems (missing dirs, stale staging dirs, broken links)") + .action(ArgAction::SetTrue), + ), + ) + .subcommand(config_command().hide(true)) +} + +pub fn config_command() -> Command { + // The key list and help lines come from PREFERENCE_KEYS so the help can + // never drift from the keys `config get`/`config set` accept. + let keys = PREFERENCE_KEYS + .iter() + .map(|spec| format!(" {:<17} {}", spec.key, spec.help)) + .collect::>() + .join("\n"); + Command::new("config") + .about("Get and set bl preferences") + .long_about(format!( + "Get and set local preferences stored in `~/.bl/config.yaml`.\n\nKeys:\n{keys}" + )) + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand( + Command::new("get") + .about("Print one preference value") + .arg(Arg::new("key").required(true)), + ) + .subcommand( + Command::new("set") + .about("Set one preference value") + .arg(Arg::new("key").required(true)) + .arg(Arg::new("value").required(true)), + ) + .subcommand(Command::new("path").about("Print the preferences file path")) +} + +pub fn auth_command() -> Command { + Command::new("auth") + .about("Manage BuilderLab marketplace authentication") + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand( + Command::new("status") + .about("Print auth status") + .long_about( + "Print auth status. With a stored CLI auth session, this verifies \ + the session with /v1/auth/me and prints the profile details.", + ), + ) + .subcommand( + Command::new("login") + .about("Log in with browser-based CLI auth") + .long_about( + "Log in with the browser-based CLI auth flow. This first checks \ + stored CLI session credentials with /v1/auth/me. If none are present \ + or valid, it starts a loopback callback server, opens the backend \ + Auth0 login flow with type=cli, exchanges the returned one-time \ + code, and stores the session credential in OS keyring storage.", + ), + ) + .subcommand( + Command::new("logout") + .about("Remove browser-based CLI auth for the selected profile") + .long_about( + "Remove the browser-based CLI auth session credential for the selected \ + profile and server URL from the configured browser auth storage.", + ), + ) +} + +fn install_command() -> Command { + Command::new("install") + .about("Install a marketplace skill, bundle, or local skill directory") + .long_about( + "Install a skill from the marketplace, a bundle of skills, or a local \ + skill directory.\n\n\ + Remote installs ask the server for an install plan (which resolves \ + dependencies), download and verify each artifact (sha256 + size), \ + extract it safely into `~/.agents/skills/`, and link it \ + into every requested target's skills directory.\n\n\ + Local installs (`bl skills install ./my-skill`) copy the directory \ + instead, mark it `local_source`, and protect it from remote updates \ + unless --force is passed — the skill-author dev loop.\n\n\ + Without --yes, a TTY prompt confirms the plan; non-interactive shells \ + and --json require --yes.", + ) + .arg( + Arg::new("skill") + .value_name("SLUG_OR_PATH") + .help("Marketplace skill slug, or a local path (./my-skill) to install from disk") + .required_unless_present("bundle"), + ) + .arg( + Arg::new("bundle") + .long("bundle") + .value_name("BUNDLE_NAME") + .conflicts_with("skill") + .help("Install every skill in a marketplace bundle"), + ) + .arg(target_flag()) + .arg(project_flag()) + .arg( + Arg::new("version") + .long("version") + .value_name("VERSION_ID") + .help("Pin to a specific version (recorded as pinned; skipped by `update`)"), + ) + .arg( + Arg::new("name") + .long("name") + .value_name("SLUG") + .help("Override the slug for a local path install"), + ) + .arg(dry_run_flag()) + .arg( + Arg::new("force") + .long("force") + .help("Reinstall even if up to date; required to overwrite local-source installs") + .action(ArgAction::SetTrue), + ) + .arg(yes_flag()) +} + +fn update_command() -> Command { + Command::new("update") + .about("Update installed skills to the latest marketplace versions") + .long_about( + "Update one skill, or every installed skill when no slug is given.\n\n\ + Sends the locally installed versions to the server's install-plan \ + endpoint and applies only the operations that changed; up-to-date \ + skills are reported and skipped. Pinned skills (installed with \ + --version) and local-source skills are skipped unless named \ + explicitly with --force. Respects the `no_auto_updates` preference \ + in non-interactive shells.", + ) + .arg(Arg::new("skill").value_name("SLUG").help("Update only this skill")) + .arg(target_flag()) + .arg(project_flag()) + .arg(dry_run_flag()) + .arg( + Arg::new("force") + .long("force") + .help("Reinstall even when content is up to date; overrides pins and local-source protection") + .action(ArgAction::SetTrue), + ) + .arg(yes_flag()) +} + +fn remove_command() -> Command { + Command::new("remove") + .about("Remove an installed skill and its target links") + .long_about( + "Remove an installed skill: target links/copies first, then the \ + canonical package directory. With --target, only those target links \ + are removed and the package stays installed for the rest.\n\n\ + Directories that are not managed by bl skills are never deleted \ + unless both --include-unmanaged and --force are passed.", + ) + .visible_alias("rm") + .arg(Arg::new("slug").required(true)) + .arg(target_flag()) + .arg(project_flag()) + .arg( + Arg::new("include-unmanaged") + .long("include-unmanaged") + .help("Also remove unmanaged directories at the skill's paths (requires --force)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("force") + .long("force") + .requires("include-unmanaged") + .help("Confirm removal of unmanaged directories") + .action(ArgAction::SetTrue), + ) + .arg(yes_flag()) +} + +fn target_flag() -> Arg { + Arg::new("target") + .long("target") + .value_name("TARGET") + .action(ArgAction::Append) + .help("Agent target(s) to link into (default: `targets` preference, else `agents`)") +} + +fn project_flag() -> Arg { + Arg::new("project") + .long("project") + .help("Operate on project-local skill directories (./.agents/skills, ...) instead of global ones") + .action(ArgAction::SetTrue) +} + +fn dry_run_flag() -> Arg { + Arg::new("dry-run") + .long("dry-run") + .help("Print the install plan without changing anything") + .action(ArgAction::SetTrue) +} + +fn yes_flag() -> Arg { + Arg::new("yes") + .long("yes") + .help( + "Apply changes without prompting (required in non-interactive shells and with --json)", + ) + .action(ArgAction::SetTrue) +} + +pub fn skills_global_args(command: Command) -> Command { + command + .arg( + Arg::new("skills-config") + .long("config") + .value_name("PATH") + .global(true) + .help("BuilderLab skills config file"), + ) + .arg( + Arg::new("skills-profile") + .long("profile") + .value_name("NAME") + .global(true) + .help("BuilderLab skills config profile"), + ) + .arg( + Arg::new("json") + .long("json") + .global(true) + .help("Print JSON output (suppresses color and prompts)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("no-color") + .long("no-color") + .global(true) + .help("Disable colored output") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("verbose") + .long("verbose") + .global(true) + .help("Log HTTP requests and other diagnostics to stderr") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("local-dev") + .long("local-dev") + .global(true) + .hide(true) + .help("Use the checked-in BuilderLab local development config") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("kgoose-service-path") + .long("kgoose-service-path") + .global(true) + .hide(true) + .env(super::skills_config::KGOOSE_SERVICE_PATH_ENV_VAR) + .value_name("PATH") + .help("Path prefix for kgoose endpoints. [default: /cash-app/goose]"), + ) +} + +pub fn run(matches: &ArgMatches) -> Result<()> { + runner::run(matches, dispatch) +} + +/// Entry point for the top-level `bl auth` command. +pub fn run_auth(matches: &ArgMatches) -> Result<()> { + runner::run(matches, dispatch_auth) +} + +/// Entry point for the top-level `bl config` command. +pub fn run_config(matches: &ArgMatches) -> Result<()> { + runner::run_for_config(matches, preferences) +} + +fn dispatch_auth(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + match matches.subcommand() { + Some(("status", _)) => { + ensure_org_configured(config)?; + auth_status(config) + } + Some(("login", _)) => auth_login_browser(config), + Some(("logout", _)) => { + ensure_org_configured(config)?; + auth_logout_browser(config) + } + _ => anyhow::bail!("expected an auth subcommand"), + } +} + +fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + match matches.subcommand() { + Some(("search", search_matches)) => { + ensure_org_configured(config)?; + let query = search_matches + .get_one::("query") + .context("expected search query")?; + list_skills(config, Some(query), search_matches) + } + Some(("list", list_matches)) => { + ensure_org_configured(config)?; + list_skills(config, None, list_matches) + } + Some(("show", show_matches)) => { + ensure_org_configured(config)?; + show_skill(config, show_matches) + } + Some(("files", files_matches)) => { + ensure_org_configured(config)?; + list_files(config, files_matches) + } + Some(("bundles", bundles_matches)) => { + ensure_org_configured(config)?; + list_bundles(config, bundles_matches) + } + Some(("install", install_matches)) => { + ensure_org_configured(config)?; + install(config, install_matches) + } + Some(("update", update_matches)) => { + ensure_org_configured(config)?; + update(config, update_matches) + } + Some(("remove", remove_matches)) => { + ensure_org_configured(config)?; + remove(config, remove_matches) + } + Some(("installed", installed_matches)) => { + ensure_org_configured(config)?; + installed(config, installed_matches) + } + Some(("which", which_matches)) => { + ensure_org_configured(config)?; + which(config, which_matches) + } + Some(("doctor", doctor_matches)) => { + ensure_org_configured(config)?; + doctor(config, doctor_matches) + } + Some(("config", config_matches)) => preferences(config, config_matches), + _ => anyhow::bail!("expected a skills subcommand"), + } +} + +fn config_with_login_org(config: &SkillsConfig) -> Result { + if config.local_dev || config.org.is_some() { + return Ok(config.clone()); + } + if config.json || !stdin_is_tty() { + return Err(missing_org_error()); + } + + eprint!("Enter your org: "); + std::io::stderr().flush().context("flush org prompt")?; + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .context("read org")?; + let org = normalize_org(&answer)?; + let mut preferences = config.read_preferences()?; + preferences.org = Some(org.clone()); + config.write_preferences(&preferences)?; + + let mut resolved = config.clone(); + resolved.org = Some(org); + resolved.kgoose_base_url = resolve_org_kgoose_base_url( + &config.kgoose_base_url, + resolved.org.as_deref(), + config.local_dev, + &config.kgoose_service_path, + )?; + Ok(resolved) +} + +// --------------------------------------------------------------------------- +// auth + +fn auth_status(config: &SkillsConfig) -> Result<()> { + let storage = default_session_storage(config)?; + let Some(verified) = verify_stored_session(config, storage.as_ref())? else { + if !config.json { + println!("BuilderLab CLI auth"); + println!(" profile: {}", config.profile); + println!(" kgoose base: {}", config.kgoose_base_url); + println!(" kgoose service path: {}", config.kgoose_service_path); + println!(" authenticated: no"); + return Ok(()); + } + return print_json(&json!({ + "authenticated": false, + "kgoose_base_url": config.kgoose_base_url, + "kgoose_service_path": config.kgoose_service_path, + "profile": config.profile, + })); + }; + let me = verified.me; + + if !config.json { + let workspace_name = me.active_workspace_name()?; + let workspace_name = terminal_safe_text(workspace_name); + println!("BuilderLab CLI auth"); + println!(" profile: {}", config.profile); + println!(" kgoose base: {}", config.kgoose_base_url); + println!(" kgoose service path: {}", config.kgoose_service_path); + println!(" authenticated: yes"); + println!(" workspace: {workspace_name}"); + if let Some(expires_at) = &me.expires_at { + println!(" expires at: {expires_at}"); + } + return Ok(()); + } + print_json(&json!({ + "authenticated": true, + "kgoose_base_url": config.kgoose_base_url, + "kgoose_service_path": config.kgoose_service_path, + "profile": config.profile, + "workspace_name": me.active_workspace_name()?, + "expires_at": me.expires_at, + })) +} + +fn auth_login_browser(config: &SkillsConfig) -> Result<()> { + let config = config_with_login_org(config)?; + let storage = default_session_storage(&config)?; + let summary = run_browser_login(&config, storage.as_ref())?; + if config.json { + return print_json(&summary); + } + + match summary.source { + BrowserLoginCredentialSource::Stored => config + .style + .success("BuilderLab CLI auth session is already valid"), + BrowserLoginCredentialSource::BrowserLogin => config + .style + .success("BuilderLab CLI auth browser login succeeded"), + } + println!(" kgoose base: {}", summary.kgoose_base_url); + println!(" kgoose service path: {}", summary.kgoose_service_path); + println!(" storage: {}", summary.storage); + println!( + " workspace: {}", + terminal_safe_text(&summary.workspace_name) + ); + if let Some(expires_at) = &summary.expires_at { + println!(" expires at: {expires_at}"); + } + if let Some(prefix) = &summary.credential_prefix { + println!(" credential prefix: {prefix}..."); + } + if let Some(prefix) = &summary.credential_sha256_prefix { + println!(" credential sha256 prefix: {prefix}"); + } + println!(" stored: yes"); + Ok(()) +} + +fn auth_logout_browser(config: &SkillsConfig) -> Result<()> { + let storage = default_session_storage(config)?; + let storage_key = super::auth_storage::session_storage_key_from_config(config); + let mut warnings = Vec::new(); + let server_revoked = match logout_stored_session(config, storage.as_ref()) { + Ok(server_revoked) => server_revoked, + Err(err) => { + warnings.push(format!("failed to destroy server auth session: {err}")); + false + } + }; + let removed = match storage.delete(&storage_key) { + Ok(removed) => removed, + Err(err) => { + warnings.push(format!("failed to remove local auth session: {err}")); + false + } + }; + let purpose_token_removed = match storage.delete_legacy_purpose_token_cache(&storage_key) { + Ok(removed) => removed, + Err(err) => { + warnings.push(format!( + "failed to remove legacy cached Compose credential: {err}" + )); + false + } + }; + if config.json { + return print_json(&json!({ + "profile": config.profile, + "kgoose_base_url": config.kgoose_base_url, + "kgoose_service_path": config.kgoose_service_path, + "storage": storage.kind(), + "server_revoked": server_revoked, + "removed": removed, + "purpose_token_removed": purpose_token_removed, + "warnings": warnings, + })); + } + + if server_revoked { + config + .style + .success("Destroyed BuilderLab CLI auth session on the server"); + } + if removed { + config.style.success("Removed BuilderLab CLI auth session"); + } else { + println!("No BuilderLab CLI auth session was stored"); + } + for warning in warnings { + config.style.warn(&warning); + } + println!(" profile: {}", config.profile); + println!(" kgoose base: {}", config.kgoose_base_url); + println!(" kgoose service path: {}", config.kgoose_service_path); + println!(" storage: {}", storage.kind()); + Ok(()) +} + +// --------------------------------------------------------------------------- +// discovery + +fn list_skills(config: &SkillsConfig, query: Option<&str>, matches: &ArgMatches) -> Result<()> { + let client = MarketplaceClient::new(config)?; + let mut filters: Vec<(&str, &str)> = Vec::new(); + if let Some(query) = query { + filters.push(("query", query)); + } + let source = matches.try_get_one::("source").ok().flatten(); + if let Some(source) = source { + filters.push(("source_id", source)); + } + let status = matches.try_get_one::("status").ok().flatten(); + if let Some(status) = status { + filters.push(("status", status)); + } + let mut items = client.list_skills_all(&filters)?; + // Bundle membership is best-effort flavor text: an unreachable bundles + // endpoint should never break listing skills. + let bundles_by_skill = bundle_membership(&client); + + let installed_map = installed_by_slug(config); + let installed_only = matches + .try_get_one::("installed") + .ok() + .flatten() + .copied() + .unwrap_or(false); + if installed_only { + items.retain(|item| installed_map.contains_key(&item.slug)); + } + // Installed skills sort to the top so the installed and available + // sections can be cross-referenced at a glance. + items.sort_by(|left, right| { + let left_installed = installed_map.contains_key(&left.slug); + let right_installed = installed_map.contains_key(&right.slug); + right_installed + .cmp(&left_installed) + .then_with(|| left.slug.cmp(&right.slug)) + }); + + if config.json { + let items = items + .iter() + .map(|item| annotate_summary(item, &installed_map, &bundles_by_skill)) + .collect::>(); + return print_json(&json!({ "items": items, "next_cursor": Value::Null })); + } + display_marketplace_skills(config.style, &items, &installed_map, &bundles_by_skill); + Ok(()) +} + +/// Maps each skill slug to the bundle slugs that include it. Empty when the +/// bundles endpoint is unavailable. +fn bundle_membership(client: &MarketplaceClient) -> BTreeMap> { + let mut membership: BTreeMap> = BTreeMap::new(); + for bundle in client.list_bundles_all(None).unwrap_or_default() { + for skill in &bundle.skills { + membership + .entry(skill.clone()) + .or_default() + .push(bundle.slug.clone()); + } + } + membership +} + +fn annotate_summary( + item: &SkillSummary, + installed_map: &BTreeMap, + bundles_by_skill: &BTreeMap>, +) -> Value { + let mut value = serde_json::to_value(item).unwrap_or_else(|_| json!({})); + let installed_meta = installed_map.get(&item.slug); + value["installed"] = json!(installed_meta.is_some()); + value["update_available"] = match installed_meta { + Some(meta) => json!(item + .latest_content_sha256 + .as_deref() + .is_some_and(|latest| latest != meta.content_sha256)), + None => Value::Null, + }; + value["bundles"] = json!(bundles_by_skill + .get(&item.slug) + .cloned() + .unwrap_or_default()); + value +} + +fn installed_by_slug(config: &SkillsConfig) -> BTreeMap { + read_installed(config, Scope::Global) + .unwrap_or_default() + .into_iter() + .map(|meta| (meta.slug.clone(), meta)) + .collect() +} + +fn show_skill(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let slug = matches + .get_one::("slug") + .context("expected skill slug")?; + let version = matches.get_one::("version"); + let file = matches.get_one::("file"); + let client = MarketplaceClient::new(config)?; + + if let Some(file) = file { + // Validate locally before asking the server, so `--file ../../x` + // never leaves the package root even if the server also validates. + validate_preview_path(file)?; + let version_id = match version { + Some(version) => version.clone(), + None => { + client + .get_json::(&format!("/v1/marketplace/skills/{slug}"))? + .latest_version_id + } + }; + let bytes = client.get_bytes(&format!( + "/v1/marketplace/skills/{slug}/versions/{version_id}/files?path={file}" + ))?; + std::io::stdout() + .write_all(&bytes) + .context("write file contents")?; + return Ok(()); + } + + if let Some(version) = version { + let detail = client.get_json::(&format!( + "/v1/marketplace/skills/{slug}/versions/{version}" + ))?; + if config.json { + return print_json(&detail); + } + println!("{} @ {}", detail.slug, detail.id); + println!(" status: {}", detail.status); + println!(" content sha: {}", detail.content_sha256); + if let Some(created_at) = &detail.created_at { + println!(" created: {created_at}"); + } + if !detail.files.is_empty() { + println!(" files ({}):", detail.files.len()); + for file in &detail.files { + println!(" {} ({} bytes)", file.path, file.size_bytes); + } + } + return Ok(()); + } + + let detail = client.get_json::(&format!("/v1/marketplace/skills/{slug}"))?; + if config.json { + return print_json(&detail); + } + display_skill_detail(config.style, &detail); + Ok(()) +} + +fn list_files(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let slug = matches + .get_one::("slug") + .context("expected skill slug")?; + let version = matches.get_one::("version"); + let client = MarketplaceClient::new(config)?; + + let (version_id, files) = match version { + Some(version) => { + let detail = client.get_json::(&format!( + "/v1/marketplace/skills/{slug}/versions/{version}" + ))?; + (detail.id, detail.files) + } + None => { + let detail = + client.get_json::(&format!("/v1/marketplace/skills/{slug}"))?; + let files = detail + .latest_version + .as_ref() + .and_then(|version| version.get("files")) + .cloned() + .map(serde_json::from_value) + .transpose() + .context("parse latest version files")? + .unwrap_or_default(); + (detail.latest_version_id, files) + } + }; + + if config.json { + return print_json(&json!({ + "slug": slug, + "version_id": version_id, + "files": files, + })); + } + println!("{slug} @ {version_id} ({} files):", files.len()); + for file in &files { + println!(" {} ({} bytes)", file.path, file.size_bytes); + } + println!(); + println!("Read one with: bl skills show {slug} --file "); + Ok(()) +} + +fn list_bundles(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let query = matches.get_one::("query"); + let client = MarketplaceClient::new(config)?; + let items = client.list_bundles_all(query.map(String::as_str))?; + if config.json { + return print_json(&json!({ "items": items })); + } + display_bundles(config.style, &items); + Ok(()) +} + +// --------------------------------------------------------------------------- +// install / update / remove + +struct PlanContext { + client: MarketplaceClient, + targets: Vec, + target_names: Vec, + scope: Scope, +} + +fn plan_context( + config: &SkillsConfig, + matches: &ArgMatches, + explicit_target_default: Option>, +) -> Result { + let preferences = config.read_preferences()?; + let scope = if matches.get_flag("project") { + Scope::Project + } else { + Scope::Global + }; + let target_names = matches + .get_many::("target") + .map(|values| values.cloned().collect::>()) + .or(explicit_target_default) + .or_else(|| (!preferences.targets.is_empty()).then(|| preferences.targets.clone())) + .unwrap_or_else(|| vec!["agents".to_string()]); + + let client = MarketplaceClient::new(config)?; + let registry = TargetRegistry::load(config, &client)?; + let targets = registry.resolve(&target_names, scope)?; + Ok(PlanContext { + client, + targets, + target_names, + scope, + }) +} + +fn is_path_like(input: &str) -> bool { + input.starts_with("./") + || input.starts_with("../") + || input.starts_with('/') + || input.starts_with("~/") + || input.contains(std::path::MAIN_SEPARATOR) +} + +fn install(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + ensure_base_dirs(config)?; + let slug_or_path = matches.get_one::("skill"); + let bundle = matches.get_one::("bundle"); + let version = matches.get_one::("version"); + let dry_run = matches.get_flag("dry-run"); + let force = matches.get_flag("force"); + let yes = matches.get_flag("yes"); + let context = plan_context(config, matches, None)?; + + // Local path install: short-circuits all remote resolution. + if let Some(input) = slug_or_path { + if is_path_like(input) { + if version.is_some() { + anyhow::bail!("--version does not apply to local path installs"); + } + let source = super::skills_targets::expand_path(input); + confirm_or_bail( + config, + yes || dry_run, + &format!("Install local skill from {}.", source.display()), + )?; + if dry_run { + config + .style + .info(&format!("dry run: would install {}", source.display())); + if config.json { + return print_json(&json!({"dry_run": true, "source": source})); + } + return Ok(()); + } + let _lock = InstallLock::acquire(config)?; + let execution = install_local_path( + config, + &source, + matches.get_one::("name").map(String::as_str), + &context.targets, + context.scope, + force, + )?; + return report_execution(config, execution); + } + } + + let lock = if dry_run { + None + } else { + Some(InstallLock::acquire(config)?) + }; + let installed = read_installed(config, context.scope)?; + + // Never silently overwrite local-source skills with marketplace content. + if let Some(slug) = slug_or_path { + if !force + && installed + .iter() + .any(|meta| &meta.slug == slug && meta.local_source) + { + return Err(failure( + exit_codes::FS_CONFLICT, + "local_source_installed", + format!("skill `{slug}` is installed from a local source; pass --force to overwrite it with marketplace content"), + )); + } + } + + let force_slugs = match (force, slug_or_path) { + (true, Some(slug)) => vec![slug.clone()], + _ => Vec::new(), + }; + let requested = match (bundle, slug_or_path) { + (Some(bundle), _) => RequestedTarget { + target_type: "bundle".to_string(), + slug: bundle.clone(), + version_id: None, + }, + (None, Some(slug)) => RequestedTarget { + target_type: "skill".to_string(), + slug: slug.clone(), + version_id: version.cloned(), + }, + (None, None) => anyhow::bail!("expected a skill slug, path, or --bundle"), + }; + + let request = InstallPlanRequest { + scope: context.scope.as_str().to_string(), + targets: vec![requested], + installed: installed_request_payload(&installed, &force_slugs), + client: BTreeMap::from([("install_targets".to_string(), json!(context.target_names))]), + include_dependencies: true, + allow_removals: false, + dry_run, + }; + let plan = context + .client + .post_json::("/v1/marketplace/install-plan", &request)?; + + // The server resolves to the latest visible version; surface a clear + // error instead of silently installing something else when a pin was + // requested. + if let (Some(version), Some(slug)) = (version, slug_or_path) { + if let Some(operation) = plan + .operations + .iter() + .find(|operation| &operation.skill.slug == slug) + { + if &operation.skill.version_id != version { + return Err(failure( + exit_codes::PLAN_BLOCKED, + "version_pin_unresolved", + format!( + "requested version `{version}` but the server resolved `{}`; the marketplace currently serves only the latest stable version", + operation.skill.version_id + ), + )); + } + } + } + + if dry_run { + return report_plan(config, &plan, true); + } + + let pending = plan + .operations + .iter() + .filter(|operation| operation.action != "noop") + .count(); + if pending > 0 && !yes { + display_plan(config.style, &plan); + } + if pending > 0 { + confirm_or_bail(config, yes, &format!("{pending} change(s) planned."))?; + } + + let pinned_slugs = match (version.is_some(), slug_or_path) { + (true, Some(slug)) => vec![slug.clone()], + _ => Vec::new(), + }; + let options = ExecuteOptions { + targets: &context.targets, + scope: context.scope, + allow_removals: false, + pinned_slugs: &pinned_slugs, + }; + let execution = execute_plan(config, &context.client, plan, &options)?; + drop(lock); + report_execution(config, execution) +} + +fn update(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + ensure_base_dirs(config)?; + let slug = matches.get_one::("skill"); + let dry_run = matches.get_flag("dry-run"); + let force = matches.get_flag("force"); + let yes = matches.get_flag("yes"); + + let preferences = config.read_preferences()?; + if preferences.no_auto_updates.unwrap_or(false) + && !force + && !dry_run + && !super::display::stdin_is_tty() + { + return Err(failure( + exit_codes::CANCELED, + "no_auto_updates", + "the `no_auto_updates` preference is set and this shell is non-interactive; pass --force to update anyway", + )); + } + + let scope = if matches.get_flag("project") { + Scope::Project + } else { + Scope::Global + }; + let installed = read_installed(config, scope)?; + if installed.is_empty() { + if config.json { + return print_json(&json!({"updated": [], "up_to_date": [], "skipped": []})); + } + println!("No local skills installed."); + return Ok(()); + } + + let mut update_slugs = Vec::new(); + let mut skipped: Vec<(String, String)> = Vec::new(); + match slug { + Some(slug) => { + let Some(meta) = installed.iter().find(|meta| &meta.slug == slug) else { + return Err(failure( + exit_codes::GENERAL, + "not_installed", + format!("skill `{slug}` is not installed; run `bl skills install {slug}`"), + )); + }; + if meta.local_source && !force { + return Err(failure( + exit_codes::FS_CONFLICT, + "local_source_installed", + format!("skill `{slug}` is installed from a local source; pass --force to overwrite it"), + )); + } + if meta.pinned && !force { + return Err(failure( + exit_codes::PLAN_BLOCKED, + "pinned", + format!( + "skill `{slug}` is pinned to {}; pass --force to update anyway", + meta.version_id + ), + )); + } + update_slugs.push(slug.clone()); + } + None => { + for meta in &installed { + if meta.local_source { + skipped.push((meta.slug.clone(), "local source".to_string())); + } else if meta.pinned && !force { + skipped.push((meta.slug.clone(), format!("pinned to {}", meta.version_id))); + } else { + update_slugs.push(meta.slug.clone()); + } + } + } + } + + if update_slugs.is_empty() { + if config.json { + return print_json(&json!({ + "updated": [], + "up_to_date": [], + "skipped": skipped + .iter() + .map(|(slug, reason)| json!({"slug": slug, "reason": reason})) + .collect::>(), + })); + } + println!("Nothing to update."); + for (slug, reason) in &skipped { + println!(" skipped {slug}: {reason}"); + } + return Ok(()); + } + + // Default the link targets to everything the updating skills were + // installed into, so updates preserve existing placements. + let default_targets = { + let mut names: Vec = installed + .iter() + .filter(|meta| update_slugs.contains(&meta.slug)) + .flat_map(|meta| meta.targets.clone()) + .collect(); + names.sort(); + names.dedup(); + (!names.is_empty()).then_some(names) + }; + let context = plan_context(config, matches, default_targets)?; + + let lock = if dry_run { + None + } else { + Some(InstallLock::acquire(config)?) + }; + let force_slugs = if force { + update_slugs.clone() + } else { + Vec::new() + }; + let request = InstallPlanRequest { + scope: context.scope.as_str().to_string(), + targets: update_slugs + .iter() + .map(|slug| RequestedTarget { + target_type: "skill".to_string(), + slug: slug.clone(), + version_id: None, + }) + .collect(), + installed: installed_request_payload(&installed, &force_slugs), + client: BTreeMap::from([("install_targets".to_string(), json!(context.target_names))]), + include_dependencies: true, + allow_removals: false, + dry_run, + }; + let plan = context + .client + .post_json::("/v1/marketplace/install-plan", &request)?; + + if dry_run { + return report_plan(config, &plan, true); + } + + let pending = plan + .operations + .iter() + .filter(|operation| operation.action != "noop") + .count(); + if pending > 0 && !yes { + display_plan(config.style, &plan); + } + if pending > 0 { + confirm_or_bail(config, yes, &format!("{pending} change(s) planned."))?; + } + + let options = ExecuteOptions { + targets: &context.targets, + scope: context.scope, + allow_removals: false, + pinned_slugs: &[], + }; + let mut execution = execute_plan(config, &context.client, plan, &options)?; + execution.skipped.extend(skipped); + drop(lock); + report_execution(config, execution) +} + +fn remove(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let slug = matches + .get_one::("slug") + .context("expected skill slug")?; + let include_unmanaged = matches.get_flag("include-unmanaged"); + let force = matches.get_flag("force"); + let yes = matches.get_flag("yes"); + let scope = if matches.get_flag("project") { + Scope::Project + } else { + Scope::Global + }; + + let only_targets = match matches.get_many::("target") { + Some(values) => { + let names = values.cloned().collect::>(); + let registry = TargetRegistry::load_offline(config); + Some(registry.resolve(&names, scope)?) + } + None => None, + }; + + let what = match &only_targets { + Some(targets) => format!( + "Remove `{slug}` from target(s) {}.", + targets + .iter() + .map(|target| target.name.as_str()) + .collect::>() + .join(", ") + ), + None => format!("Remove skill `{slug}` and all of its target links."), + }; + confirm_or_bail(config, yes, &what)?; + + let _lock = InstallLock::acquire(config)?; + let report = remove_skill( + config, + slug, + only_targets.as_deref(), + scope, + include_unmanaged, + force, + )?; + + if config.json { + return print_json(&report.to_json()); + } + if report.removed_package { + config.style.success(&format!("Removed skill `{slug}`")); + } else { + config + .style + .success(&format!("Removed `{slug}` target links")); + } + for link in &report.removed_links { + println!(" removed {}", link.display()); + } + for (path, reason) in &report.skipped_paths { + config + .style + .warn(&format!("skipped {}: {reason}", path.display())); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// installed / which / doctor / config + +fn installed(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let scope = if matches.get_flag("project") { + Scope::Project + } else { + Scope::Global + }; + let installed = read_installed(config, scope)?; + + // Best-effort remote comparison; offline degrades to "unknown". + let latest_by_slug: Option>> = MarketplaceClient::new(config) + .ok() + .and_then(|client| client.list_skills_all(&[]).ok()) + .map(|items| { + items + .into_iter() + .map(|item| (item.slug, item.latest_content_sha256)) + .collect() + }); + if latest_by_slug.is_none() && !installed.is_empty() && !config.json { + config + .style + .warn("marketplace unreachable; update checks skipped"); + } + + let update_available = |meta: &InstalledSkillMetadata| -> Option { + let latest = latest_by_slug.as_ref()?.get(&meta.slug)?; + latest + .as_deref() + .map(|latest| latest != meta.content_sha256) + }; + + if config.json { + let items = installed + .iter() + .map(|meta| { + let mut value = serde_json::to_value(meta).unwrap_or_else(|_| json!({})); + value["update_available"] = match update_available(meta) { + Some(stale) => json!(stale), + None => Value::Null, + }; + value + }) + .collect::>(); + return print_json(&json!({ "items": items })); + } + + if installed.is_empty() { + println!("No local skills installed."); + return Ok(()); + } + let style = config.style; + println!( + "{}", + style.bold(&format!("Installed skills ({}):", installed.len())) + ); + println!(); + for meta in &installed { + let marker = match update_available(meta) { + Some(true) => style.yellow(" (update available)"), + Some(false) => style.dim(" (up to date)"), + None => String::new(), + }; + println!( + " {} {}{marker}", + style.slug(&meta.slug), + style.dim(&format!("[{}]", meta.scope)) + ); + println!(" {} {}", style.label("version:"), meta.version_id); + if meta.pinned { + println!(" {} yes", style.label("pinned:")); + } + if meta.local_source { + println!(" {} yes", style.label("local source:")); + } + if !meta.targets.is_empty() { + println!( + " {} {}", + style.label("targets:"), + meta.targets.join(", ") + ); + } + if let Some(source_revision) = &meta.source_revision { + println!(" {} {source_revision}", style.label("source:")); + } + println!( + " {} {}", + style.label("path:"), + canonical_dir(config, scope, &meta.slug).display() + ); + println!(); + } + Ok(()) +} + +fn which(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let slug = matches + .get_one::("slug") + .context("expected skill slug")?; + let scope = if matches.get_flag("project") { + Scope::Project + } else { + Scope::Global + }; + let package_dir = canonical_dir(config, scope, slug); + let metadata = read_metadata(&package_dir).map_err(|_| { + failure( + exit_codes::GENERAL, + "not_installed", + format!("skill `{slug}` is not installed; run `bl skills install {slug}`"), + ) + })?; + + let registry = TargetRegistry::load_offline(config); + let mut links = Vec::new(); + if let Ok(resolved) = registry.resolve(&metadata.targets, scope) { + for target in &resolved { + for base_dir in &target.base_dirs { + let link_path = base_dir.join(slug); + links.push(( + target.name.clone(), + link_path.clone(), + inspect_link(&link_path, &package_dir), + )); + } + } + } + + if config.json { + return print_json(&json!({ + "slug": slug, + "package_dir": package_dir, + "metadata": metadata, + "links": links + .iter() + .map(|(target, path, state)| json!({ + "target": target, + "path": path, + "state": state, + })) + .collect::>(), + })); + } + + let style = config.style; + println!("{}", style.slug(slug)); + println!(" {} {}", style.label("package:"), package_dir.display()); + println!(" {} {}", style.label("version:"), metadata.version_id); + println!( + " {} {}", + style.label("installed at:"), + metadata.installed_at + ); + println!( + " {} {}", + style.label("installed via:"), + metadata.installed_via + ); + println!(" {} {}", style.label("scope:"), metadata.scope); + if let Some(source_revision) = &metadata.source_revision { + println!(" {} {source_revision}", style.label("source:")); + } + if metadata.local_source { + println!(" {} yes", style.label("local source:")); + } + if metadata.pinned { + println!(" {} yes", style.label("pinned:")); + } + if !links.is_empty() { + println!(" {}", style.label("links:")); + for (target, path, state) in &links { + let state_text = match state { + LinkState::Ok => config.style.green("ok"), + LinkState::Missing => config.style.red("missing"), + LinkState::Broken => config.style.red("broken"), + LinkState::Unmanaged => config.style.yellow("unmanaged"), + }; + println!(" [{state_text}] {target}: {}", path.display()); + } + } + Ok(()) +} + +fn doctor(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let fix = matches.get_flag("fix"); + let (report, payload) = run_doctor(config, fix)?; + if config.json { + return print_json(&payload); + } + + println!("BuilderLab skills doctor"); + println!(" profile: {}", config.profile); + println!(" local dev: {}", yes_no(config.local_dev)); + println!(" kgoose base: {}", config.kgoose_base_url); + println!(" kgoose service path: {}", config.kgoose_service_path); + println!(" config: {}", config.config_path.display()); + println!(" bl home: {}", config.bl_home.display()); + println!(" skills home: {}", config.skills_home.display()); + println!(); + for check in &report.checks { + let badge = match check.status { + CheckStatus::Pass => config.style.green("PASS"), + CheckStatus::Warn => config.style.yellow("WARN"), + CheckStatus::Fail => config.style.red("FAIL"), + }; + println!(" [{badge}] {}: {}", check.name, check.detail); + } + for fixed in &report.fixed { + config.style.success(&format!("fixed: {fixed}")); + } + if !report.ok() && !fix { + println!(); + config + .style + .info("some checks failed; `bl skills doctor --fix` repairs the safe ones"); + } + Ok(()) +} + +fn preferences(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let known_keys = || { + PREFERENCE_KEYS + .iter() + .map(|spec| spec.key) + .collect::>() + .join(", ") + }; + match matches.subcommand() { + Some(("path", _)) => { + if config.json { + return print_json(&json!({"path": config.preferences_path()})); + } + println!("{}", config.preferences_path().display()); + Ok(()) + } + Some(("get", get_matches)) => { + let key = get_matches + .get_one::("key") + .context("expected preference key")?; + let preferences = config.read_preferences()?; + let value: Value = match key.as_str() { + "org" => json!(preferences.org.unwrap_or_default()), + "targets" => json!(if preferences.targets.is_empty() { + "agents".to_string() + } else { + preferences.targets.join(",") + }), + "install_strategy" => json!(preferences + .install_strategy + .unwrap_or_else(|| "symlink".to_string())), + "no_auto_updates" => json!(preferences.no_auto_updates.unwrap_or(false)), + other => { + anyhow::bail!("unknown preference `{other}`; known keys: {}", known_keys()) + } + }; + if config.json { + return print_json(&json!({key: value})); + } + match value { + Value::String(text) => println!("{text}"), + other => println!("{other}"), + } + Ok(()) + } + Some(("set", set_matches)) => { + let key = set_matches + .get_one::("key") + .context("expected preference key")?; + let value = set_matches + .get_one::("value") + .context("expected preference value")?; + let mut preferences = config.read_preferences()?; + match key.as_str() { + "org" => preferences.org = Some(normalize_org(value)?), + "targets" => { + let names = value + .split(',') + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect::>(); + if names.is_empty() { + anyhow::bail!("targets cannot be empty; e.g. `agents,claude`"); + } + let registry = TargetRegistry::load_offline(config); + registry.resolve(&names, Scope::Global)?; + preferences.targets = names; + } + "install_strategy" => { + if value != "symlink" && value != "copy" { + anyhow::bail!("install_strategy must be `symlink` or `copy`"); + } + preferences.install_strategy = Some(value.clone()); + } + "no_auto_updates" => { + preferences.no_auto_updates = Some(match value.as_str() { + "true" | "1" | "yes" => true, + "false" | "0" | "no" => false, + other => { + anyhow::bail!("no_auto_updates must be true or false, got `{other}`") + } + }); + } + other => { + anyhow::bail!("unknown preference `{other}`; known keys: {}", known_keys()) + } + } + config.write_preferences(&preferences)?; + if config.json { + return print_json(&json!({"updated": key, "path": config.preferences_path()})); + } + config.style.success(&format!( + "set {key} in {}", + config.preferences_path().display() + )); + Ok(()) + } + _ => anyhow::bail!("expected a config subcommand"), + } +} + +fn report_plan(config: &SkillsConfig, plan: &InstallPlanResponse, dry_run: bool) -> Result<()> { + if config.json { + return print_json(&json!({ + "plan_id": plan.plan_id, + "dry_run": dry_run, + "operations": plan + .operations + .iter() + .map(|operation| json!({ + "action": operation.action, + "slug": operation.skill.slug, + "version_id": operation.skill.version_id, + "installed_via": operation.installed_via, + "reason": operation.reason, + })) + .collect::>(), + "warnings": plan.warnings, + })); + } + display_plan(config.style, plan); + if dry_run { + config.style.info("dry run: no changes were made"); + } + Ok(()) +} + +fn display_plan(style: Style, plan: &InstallPlanResponse) { + println!("Install plan {}", plan.plan_id); + if plan.operations.is_empty() { + println!(" No operations."); + } + for operation in &plan.operations { + let line = format!( + "{:>8} {} @ {}{}", + operation.action, + operation.skill.slug, + operation.skill.version_id, + provenance_suffix(&operation.installed_via), + ); + match operation.action.as_str() { + "noop" => println!(" {}", style.dim(&line)), + "remove" => println!(" {}", style.red(&line)), + _ => println!(" {line}"), + } + } + display_warnings(style, &plan.warnings); +} + +/// Human-readable provenance: distinguish "you asked for X" from "X was +/// pulled in by a dependency or bundle". +fn provenance_suffix(installed_via: &str) -> String { + if let Some(parent) = installed_via.strip_prefix("depends-on:") { + format!(" (dependency of {parent})") + } else if let Some(bundle) = installed_via.strip_prefix("bundle:") { + format!(" (from bundle {bundle})") + } else { + String::new() + } +} + +fn display_warnings(style: Style, warnings: &[super::skills_models::Warning]) { + for warning in warnings { + let mut message = format!("{} ({})", warning.message, warning.code); + if let Some(action) = &warning.suggested_action { + message.push_str(&format!(" — {action}")); + } + style.warn(&message); + } +} + +fn report_execution(config: &SkillsConfig, execution: PlanExecution) -> Result<()> { + if config.json { + return print_json(&execution.to_json()); + } + let style = config.style; + println!("Install plan {}", execution.plan_id); + if execution.installed.is_empty() && execution.removed.is_empty() { + println!(" No skill changes."); + } + for change in &execution.installed { + style.success(&format!( + "{} {} @ {}{}", + if change.action == "update" { + "updated" + } else { + "installed" + }, + style.bold(&change.slug), + change.version_id, + provenance_suffix(&change.installed_via), + )); + for backup in &change.backups { + println!( + " conflicting skill at {} was replaced. Backup created on {} at {}", + backup.source_path.display(), + backup.created_at, + backup.backup_path.display() + ); + } + for link in &change.links { + println!(" {} -> {}", link.strategy, link.path.display()); + } + } + for slug in &execution.removed { + style.success(&format!("removed {slug}")); + } + for slug in &execution.up_to_date { + println!(" {}", style.dim(&format!("{slug} is up to date"))); + } + for (slug, reason) in &execution.skipped { + style.warn(&format!("skipped {slug}: {reason}")); + } + display_warnings(style, &execution.warnings); + for change in &execution.installed { + if let Some(setup) = &change.setup { + display_setup_prompt(style, &change.slug, setup); + } + } + Ok(()) +} + +fn display_setup_prompt(style: Style, slug: &str, setup: &SetupSummary) { + println!(); + style.info(&format!( + "{} needs one-time setup: {}", + style.bold(slug), + setup.title + )); + for section in &setup.sections { + println!(" - {section}"); + } + println!(" See {}", setup.path.display()); +} + +fn display_marketplace_skills( + style: Style, + skills: &[SkillSummary], + installed_map: &BTreeMap, + bundles_by_skill: &BTreeMap>, +) { + let (installed, available): (Vec<&SkillSummary>, Vec<&SkillSummary>) = skills + .iter() + .partition(|skill| installed_map.contains_key(&skill.slug)); + // Skills installed from a local path (or a server the catalog no longer + // lists) still belong in the installed section. + let local_only = installed_map + .values() + .filter(|meta| !skills.iter().any(|skill| skill.slug == meta.slug)) + .collect::>(); + + if installed.is_empty() && available.is_empty() && local_only.is_empty() { + println!("No skills available."); + return; + } + + if !installed.is_empty() || !local_only.is_empty() { + println!( + "{}", + style.bold(&format!( + "Installed ({}):", + installed.len() + local_only.len() + )) + ); + println!(); + for skill in &installed { + let meta = &installed_map[&skill.slug]; + let stale = skill + .latest_content_sha256 + .as_deref() + .is_some_and(|latest| latest != meta.content_sha256); + let marker = if stale { + style.yellow(" (update available)") + } else { + style.dim(" (up to date)") + }; + display_skill_entry(style, skill, Some(meta), &marker, bundles_by_skill); + } + for meta in &local_only { + display_local_only_entry(style, meta); + } + } + + if !available.is_empty() { + println!( + "{}", + style.bold(&format!("Available ({}):", available.len())) + ); + println!(); + for skill in &available { + display_skill_entry(style, skill, None, "", bundles_by_skill); + } + println!("Install one with: bl skills install "); + } +} + +fn display_skill_entry( + style: Style, + skill: &SkillSummary, + meta: Option<&InstalledSkillMetadata>, + marker: &str, + bundles_by_skill: &BTreeMap>, +) { + println!( + " {} {}{marker}", + style.slug(&skill.slug), + status_badge(style, &skill.status, skill.enabled), + ); + if !skill.description.is_empty() { + println!( + " {}", + super::skills_api::truncate(&skill.description, 80) + ); + } + if !skill.name.is_empty() && skill.name != skill.slug { + println!(" {} {}", style.label("name:"), skill.name); + } + if let Some(meta) = meta { + let pin = if meta.pinned { " (pinned)" } else { "" }; + println!(" {} {}{pin}", style.label("version:"), meta.version_id); + if !meta.targets.is_empty() { + println!( + " {} {}", + style.label("targets:"), + meta.targets.join(", ") + ); + } + } + if !skill.tags.is_empty() { + println!(" {} {}", style.label("tags:"), skill.tags.join(", ")); + } + if let Some(bundles) = bundles_by_skill.get(&skill.slug) { + println!(" {} {}", style.label("bundles:"), bundles.join(", ")); + } + println!(); +} + +fn display_local_only_entry(style: Style, meta: &InstalledSkillMetadata) { + let marker = if meta.local_source { + style.cyan(" (local install)") + } else { + style.dim(" (not in marketplace)") + }; + println!(" {}{marker}", style.slug(&meta.slug)); + println!(" {} {}", style.label("version:"), meta.version_id); + if !meta.targets.is_empty() { + println!( + " {} {}", + style.label("targets:"), + meta.targets.join(", ") + ); + } + println!(); +} + +fn display_skill_detail(style: Style, skill: &SkillDetail) { + println!( + "{} {}", + style.slug(&skill.slug), + status_badge(style, &skill.status, skill.enabled) + ); + if !skill.name.is_empty() && skill.name != skill.slug { + println!(" {} {}", style.label("name:"), skill.name); + } + if !skill.description.is_empty() { + println!(" {}", skill.description); + } + println!(" {} {}", style.label("version:"), skill.latest_version_id); + if !skill.latest_content_sha256.is_empty() { + println!( + " {} {}", + style.label("content sha:"), + skill.latest_content_sha256 + ); + } + if let Some(source_id) = &skill.source_id { + println!(" {} {source_id}", style.label("source:")); + } + if !skill.tags.is_empty() { + println!(" {} {}", style.label("tags:"), skill.tags.join(", ")); + } + if !skill.dependencies.is_empty() { + println!( + " {} {}", + style.label("dependencies:"), + skill.dependencies.join(", ") + ); + } +} + +fn display_bundles(style: Style, bundles: &[BundleSummary]) { + if bundles.is_empty() { + println!("No bundles available."); + return; + } + println!( + "{}", + style.bold(&format!("Available bundles ({}):", bundles.len())) + ); + println!(); + for bundle in bundles { + println!( + " {} {}", + style.slug(&bundle.slug), + status_badge(style, &bundle.status, bundle.enabled) + ); + if !bundle.description.is_empty() { + println!( + " {}", + super::skills_api::truncate(&bundle.description, 80) + ); + } + if !bundle.skills.is_empty() { + println!( + " {} {}", + style.label("skills:"), + bundle.skills.join(", ") + ); + } + println!(); + } + println!("Install one with: bl skills install --bundle "); +} + +fn status_label(status: &str, enabled: bool) -> String { + if enabled { + status.to_string() + } else { + format!("{status}, disabled") + } +} + +/// `[stable]`-style badge: quiet for healthy statuses, yellow when the skill +/// is disabled or deprecated. +fn status_badge(style: Style, status: &str, enabled: bool) -> String { + let badge = format!("[{}]", status_label(status, enabled)); + if !enabled || status == "deprecated" { + style.yellow(&badge) + } else { + style.dim(&badge) + } +} + +fn yes_no(value: bool) -> &'static str { + if value { + "yes" + } else { + "no" + } +} + +/// Returns a machine-readable description of the `bl skills` command tree +/// for `bl --describe-commands`. +pub fn describe_commands() -> Value { + describe_command_tree(&skills_command()) +} + +/// Machine-readable description of the top-level `bl auth` command tree. +pub fn describe_auth_commands() -> Value { + describe_command_tree(&auth_command()) +} + +/// Machine-readable description of the top-level `bl config` command tree. +pub fn describe_config_commands() -> Value { + describe_command_tree(&config_command()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_path_like_detects_local_paths() { + assert!(is_path_like("./my-skill")); + assert!(is_path_like("../my-skill")); + assert!(is_path_like("/abs/skill")); + assert!(is_path_like("~/skills/demo")); + assert!(is_path_like("dir/skill")); + assert!(!is_path_like("slack")); + assert!(!is_path_like("builderlab-tools")); + } + + #[test] + fn provenance_suffix_labels_dependencies_and_bundles() { + assert_eq!(provenance_suffix("explicit"), ""); + assert_eq!( + provenance_suffix("depends-on:slack"), + " (dependency of slack)" + ); + assert_eq!( + provenance_suffix("bundle:frontend"), + " (from bundle frontend)" + ); + } + + #[test] + fn describe_commands_includes_lifecycle_subcommands() { + let description = describe_commands(); + let names = description["commands"] + .as_array() + .expect("commands array") + .iter() + .map(|command| command["name"].as_str().expect("name").to_string()) + .collect::>(); + for expected in [ + "search", + "list", + "show", + "files", + "bundles", + "install", + "update", + "remove", + "installed", + "which", + "doctor", + ] { + assert!(names.contains(&expected.to_string()), "missing {expected}"); + } + assert!(!names.contains(&"auth".to_string())); + assert!(!names.contains(&"config".to_string())); + } + + #[test] + fn describe_auth_and_config_commands_cover_their_subcommands() { + let auth = describe_auth_commands(); + assert_eq!(auth["name"], "auth"); + let auth_names = auth["commands"] + .as_array() + .expect("auth commands array") + .iter() + .map(|command| command["name"].as_str().expect("name").to_string()) + .collect::>(); + assert_eq!(auth_names, ["status", "login", "logout"]); + + let config = describe_config_commands(); + assert_eq!(config["name"], "config"); + let config_names = config["commands"] + .as_array() + .expect("config commands array") + .iter() + .map(|command| command["name"].as_str().expect("name").to_string()) + .collect::>(); + assert_eq!(config_names, ["get", "set", "path"]); + } +} diff --git a/src/bl/skills_api.rs b/src/bl/skills_api.rs new file mode 100644 index 0000000..73b4d76 --- /dev/null +++ b/src/bl/skills_api.rs @@ -0,0 +1,1068 @@ +//! BuilderLab marketplace HTTP client and error handling for `bl skills`. + +use anyhow::{Context, Result}; +use reqwest::blocking::Client; +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use super::agents_models::{ + AgentCatalogPage, AgentDetail, AgentInstallPlan, AgentInstallPlanRequest, + AgentInstallResolution, AgentOperationError, AgentVersion, InstalledAgentRequest, + AGENT_OPERATION_KIND, +}; +use super::auth::SESSION_CREDENTIAL_HEADER; +use super::auth_storage::stored_session_credential_header_value; +use super::display::Style; +use super::skills_config::{kgoose_service_url, SkillsConfig}; +use super::skills_models::{BundlePage, BundleSummary, SkillPage, SkillSummary}; + +/// Documented `bl skills` exit codes (see `bl skills --help`). +pub mod exit_codes { + pub const GENERAL: i32 = 1; + pub const AUTH_REQUIRED: i32 = 3; + pub const FORBIDDEN: i32 = 4; + pub const NETWORK: i32 = 5; + pub const PLAN_BLOCKED: i32 = 6; + pub const FS_CONFLICT: i32 = 7; + pub const VERIFICATION: i32 = 8; + pub const CANCELED: i32 = 9; +} + +/// A failure that carries a process exit code and a structured payload for +/// `--json` error output. `bl_main` walks the error chain looking for this. +#[derive(Debug)] +pub struct CliFailure { + pub exit_code: i32, + pub code: String, + pub message: String, + pub details: Option, +} + +impl CliFailure { + pub fn new(exit_code: i32, code: &str, message: impl Into) -> Self { + Self { + exit_code, + code: code.to_string(), + message: message.into(), + details: None, + } + } + + pub fn to_json(&self) -> Value { + json!({ + "error": { + "code": self.code, + "message": self.message, + "exit_code": self.exit_code, + "details": self.details, + } + }) + } +} + +impl std::fmt::Display for CliFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}", self.message) + } +} + +impl std::error::Error for CliFailure {} + +/// Builds an `anyhow::Error` carrying an exit code and stable error code. +pub fn failure(exit_code: i32, code: &str, message: impl Into) -> anyhow::Error { + anyhow::Error::new(CliFailure::new(exit_code, code, message)) +} + +/// Marker error: the failure was already reported as structured JSON on +/// stderr; `bl_main` should exit with this code without printing again. +#[derive(Debug)] +pub struct SilentJsonExit(pub i32); + +impl std::fmt::Display for SilentJsonExit { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "exit {}", self.0) + } +} + +impl std::error::Error for SilentJsonExit {} + +/// Finds the exit code and JSON payload for an error chain, defaulting to 1. +pub fn failure_info(error: &anyhow::Error) -> (i32, Value) { + for cause in error.chain() { + if let Some(cli) = cause.downcast_ref::() { + let mut payload = cli.to_json(); + // The outermost anyhow message may carry extra context; prefer it. + let full = format!("{error:#}"); + payload["error"]["message"] = json!(full); + return (cli.exit_code, payload); + } + } + ( + exit_codes::GENERAL, + json!({ + "error": { + "code": "cli_error", + "message": format!("{error:#}"), + "exit_code": exit_codes::GENERAL, + } + }), + ) +} + +#[derive(Debug)] +pub struct MarketplaceClient { + base_url: String, + client: Client, + has_auth: bool, + style: Style, +} + +impl MarketplaceClient { + pub fn new(config: &SkillsConfig) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let session_credential = stored_session_credential_header_value( + &config.profile, + &kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path), + config.bl_home.clone(), + )?; + if let Some(session_credential) = session_credential.as_deref() { + headers.insert( + SESSION_CREDENTIAL_HEADER, + HeaderValue::from_str(session_credential) + .context("build marketplace session credential header")?, + ); + } + if let Some(playpen) = &config.playpen { + headers.insert( + "Baggage", + HeaderValue::from_str(&format!("kgoose-builderlab-playpen={playpen}")) + .context("build marketplace Baggage header")?, + ); + } + Ok(Self { + base_url: kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path), + client: Client::builder() + .default_headers(headers) + .build() + .context("build marketplace HTTP client")?, + has_auth: session_credential.is_some(), + style: config.style, + }) + } + + pub fn has_auth(&self) -> bool { + self.has_auth + } + + pub fn get_json(&self, path: &str) -> Result + where + T: for<'de> Deserialize<'de>, + { + self.style.verbose(&format!("GET {path}")); + let response = self + .client + .get(self.url(path)) + .send() + .map_err(|err| network_failure("GET", path, err))?; + let status = response.status(); + let body = response + .text() + .with_context(|| format!("read GET {path} response"))?; + self.style + .verbose(&format!("GET {path} -> {status} ({} bytes)", body.len())); + self.ensure_success("GET", path, status, body.as_bytes())?; + serde_json::from_str(&body).with_context(|| format!("deserialize GET {path} response")) + } + + pub fn post_json(&self, path: &str, body: &B) -> Result + where + T: for<'de> Deserialize<'de>, + B: Serialize + ?Sized, + { + self.style.verbose(&format!("POST {path}")); + let response = self + .client + .post(self.url(path)) + .json(body) + .send() + .map_err(|err| network_failure("POST", path, err))?; + let status = response.status(); + let body = response + .text() + .with_context(|| format!("read POST {path} response"))?; + self.style + .verbose(&format!("POST {path} -> {status} ({} bytes)", body.len())); + self.ensure_success("POST", path, status, body.as_bytes())?; + serde_json::from_str(&body).with_context(|| format!("deserialize POST {path} response")) + } + + /// Fetch raw bytes from a marketplace path; used for file previews. + pub fn get_bytes(&self, path: &str) -> Result> { + self.style.verbose(&format!("GET {path}")); + let response = self + .client + .get(self.url(path)) + .send() + .map_err(|err| network_failure("GET", path, err))?; + let status = response.status(); + let bytes = response + .bytes() + .with_context(|| format!("read GET {path} response"))?; + self.ensure_success("GET", path, status, &bytes)?; + Ok(bytes.to_vec()) + } + + pub fn download(&self, path_or_url: &str) -> Result { + let url = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") { + path_or_url.to_string() + } else { + self.url(path_or_url) + }; + self.style.verbose(&format!("GET {path_or_url} (artifact)")); + let response = self + .client + .get(&url) + .send() + .map_err(|err| network_failure("GET", path_or_url, err))?; + let status = response.status(); + let headers = response.headers().clone(); + let bytes = response + .bytes() + .with_context(|| format!("read GET {path_or_url} response"))?; + self.style.verbose(&format!( + "GET {path_or_url} -> {status} ({} bytes)", + bytes.len() + )); + self.ensure_success("GET", path_or_url, status, &bytes)?; + Ok(DownloadedArtifact { + bytes: bytes.to_vec(), + header_sha256: headers + .get("X-Artifact-SHA256") + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned), + header_size: headers + .get("X-Artifact-Size") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()), + }) + } + + /// Lists skills, following pagination so large catalogs are not silently + /// truncated. `extra_query` entries are appended to every page request. + pub fn list_skills_all(&self, extra_query: &[(&str, &str)]) -> Result> { + let mut items = Vec::new(); + let mut cursor: Option = None; + loop { + let mut path = format!("/v1/marketplace/skills?limit={LIST_PAGE_LIMIT}"); + for (key, value) in extra_query { + path.push_str(&format!("&{key}={}", url_encode(value))); + } + if let Some(cursor_value) = &cursor { + path.push_str(&format!("&cursor={}", url_encode(cursor_value))); + } + let page = self.get_json::(&path)?; + items.extend(page.items); + match page.next_cursor.filter(|value| !value.is_empty()) { + Some(next) => cursor = Some(next), + None => break, + } + } + Ok(items) + } + + pub fn list_bundles_all(&self, query: Option<&str>) -> Result> { + let mut items = Vec::new(); + let mut cursor: Option = None; + loop { + let mut path = format!("/v1/marketplace/bundles?limit={LIST_PAGE_LIMIT}"); + if let Some(query) = query { + path.push_str(&format!("&query={}", url_encode(query))); + } + if let Some(cursor_value) = &cursor { + path.push_str(&format!("&cursor={}", url_encode(cursor_value))); + } + let page = self.get_json::(&path)?; + items.extend(page.items); + match page.next_cursor.filter(|value| !value.is_empty()) { + Some(next) => cursor = Some(next), + None => break, + } + } + Ok(items) + } + + pub fn agents(&self) -> AgentMarketplace<'_> { + AgentMarketplace { client: self } + } + + fn url(&self, path: &str) -> String { + if path.starts_with('/') { + format!("{}{}", self.base_url, path) + } else { + format!("{}/{}", self.base_url, path) + } + } + + fn ensure_success( + &self, + method: &str, + path: &str, + status: StatusCode, + body: &[u8], + ) -> Result<()> { + if status.is_success() { + return Ok(()); + } + let mut message = format_http_error(method, path, status, body); + let exit_code = match status.as_u16() { + 401 => { + message.push_str(if self.has_auth { + "\nhint: the marketplace rejected your credentials; run `bl auth login` to refresh your session" + } else { + "\nhint: no credentials are configured; run `bl auth login` first" + }); + exit_codes::AUTH_REQUIRED + } + 403 => { + message.push_str( + "\nhint: your credentials lack the required scope; run `bl auth login` with an authorized account", + ); + exit_codes::FORBIDDEN + } + 422 => exit_codes::PLAN_BLOCKED, + status if status >= 500 => exit_codes::NETWORK, + _ => exit_codes::GENERAL, + }; + let code = parse_error_envelope(body) + .map(|envelope| envelope.error.code) + .unwrap_or_else(|| format!("http_{}", status.as_u16())); + Err(anyhow::Error::new(CliFailure { + exit_code, + code, + message, + details: serde_json::from_slice::(body) + .ok() + .and_then(|value| value.get("error").cloned()), + })) + } +} + +pub struct AgentMarketplace<'a> { + client: &'a MarketplaceClient, +} + +impl AgentMarketplace<'_> { + pub fn list_all(&self, query: Option<&str>) -> Result> { + let mut items = Vec::new(); + let mut cursor: Option = None; + loop { + let mut path = format!("/v1/marketplace/agents?limit={LIST_PAGE_LIMIT}"); + if let Some(query) = query { + path.push_str(&format!("&query={}", url_encode(query))); + } + if let Some(cursor_value) = &cursor { + path.push_str(&format!("&cursor={}", url_encode(cursor_value))); + } + let page = self.client.get_json::(&path)?; + items.extend(page.items); + match page.next_cursor.filter(|value| !value.is_empty()) { + Some(next) => cursor = Some(next), + None => break, + } + } + Ok(items) + } + + pub fn show(&self, slug: &str) -> Result { + self.client + .get_json::(&format!("/v1/marketplace/agents/{}", url_encode(slug))) + } + + pub fn version(&self, slug: &str, version_id: &str) -> Result { + self.client.get_json::(&format!( + "/v1/marketplace/agents/{}/versions/{}", + url_encode(slug), + url_encode(version_id) + )) + } + + pub fn resolve_install( + &self, + slug: &str, + version_id: Option, + installed: Vec, + ) -> Result { + let agent = self.show(slug)?; + let requested_version_id = version_id.clone(); + let plan = self.client.post_json::( + "/v1/marketplace/install-plan", + &AgentInstallPlanRequest::for_agent(slug, version_id, installed), + )?; + let operation = plan + .operations + .into_iter() + .find(|operation| operation.skill.slug == slug) + .ok_or_else(|| { + invalid_agent_operation(AgentOperationError::Missing { + slug: slug.to_string(), + }) + })?; + if operation.kind != AGENT_OPERATION_KIND { + return Err(invalid_agent_operation(AgentOperationError::WrongKind { + slug: slug.to_string(), + actual: operation.kind, + })); + } + if let Some(requested_version_id) = requested_version_id { + if operation.skill.version_id != requested_version_id { + return Err(failure( + exit_codes::PLAN_BLOCKED, + "version_pin_unresolved", + format!( + "requested version `{requested_version_id}` but the server resolved `{}`; the marketplace currently serves only the latest stable version", + operation.skill.version_id + ), + )); + } + } + let version = self.version(slug, &operation.skill.version_id)?; + Ok(AgentInstallResolution { + action: operation.action, + reason: operation.reason, + agent, + version, + plan: operation.skill, + artifact: operation.artifact, + installed_via: operation.installed_via, + }) + } +} + +fn invalid_agent_operation(error: AgentOperationError) -> anyhow::Error { + failure( + exit_codes::VERIFICATION, + "invalid_agent_operation_kind", + error.to_string(), + ) +} + +pub const LIST_PAGE_LIMIT: u32 = 5000; + +fn network_failure(method: &str, path: &str, err: reqwest::Error) -> anyhow::Error { + anyhow::Error::new(CliFailure::new( + exit_codes::NETWORK, + "server_unreachable", + format!("{method} {path} failed: {err}"), + )) +} + +fn url_encode(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char) + } + other => encoded.push_str(&format!("%{other:02X}")), + } + } + encoded +} + +#[derive(Debug)] +pub struct DownloadedArtifact { + pub bytes: Vec, + pub header_sha256: Option, + pub header_size: Option, +} + +pub fn format_http_error(method: &str, path: &str, status: StatusCode, body: &[u8]) -> String { + if let Some(envelope) = parse_error_envelope(body) { + return envelope.display_message(method, path, status); + } + + let body = String::from_utf8_lossy(body); + let body = truncate(body.trim(), 800); + if body.is_empty() { + format!("{method} {path} failed with {status}") + } else { + format!("{method} {path} failed with {status}: {body}") + } +} + +fn parse_error_envelope(body: &[u8]) -> Option { + serde_json::from_slice::(body) + .ok() + .filter(|envelope| !envelope.error.code.trim().is_empty()) +} + +#[derive(Debug, Deserialize)] +struct MarketplaceErrorEnvelope { + error: MarketplaceApiError, +} + +#[derive(Debug, Deserialize)] +struct MarketplaceApiError { + code: String, + message: String, + request_id: Option, + retryable: Option, + details: Option, +} + +impl MarketplaceErrorEnvelope { + fn display_message(&self, method: &str, path: &str, status: StatusCode) -> String { + let error = &self.error; + let mut lines = vec![format!( + "{method} {path} failed with {status}: {} ({})", + error.message, error.code + )]; + if let Some(request_id) = error + .request_id + .as_deref() + .filter(|value| !value.is_empty()) + { + lines.push(format!("request_id: {request_id}")); + } + if let Some(retryable) = error.retryable { + lines.push(format!("retryable: {retryable}")); + } + if let Some(details) = error.details.as_ref() { + let detail = summarize_error_details(details); + if !detail.is_empty() { + lines.push(format!("details: {detail}")); + } + } + lines.join("\n") + } +} + +fn summarize_error_details(details: &Value) -> String { + match details { + Value::Array(items) => { + let rendered = items + .iter() + .take(3) + .map(summarize_error_detail) + .filter(|item| !item.is_empty()) + .collect::>(); + let mut summary = rendered.join("; "); + if items.len() > rendered.len() { + if !summary.is_empty() { + summary.push_str("; "); + } + summary.push_str(&format!("{} more", items.len() - rendered.len())); + } + summary + } + other => truncate(&other.to_string(), 500), + } +} + +fn summarize_error_detail(detail: &Value) -> String { + let Value::Object(fields) = detail else { + return truncate(&detail.to_string(), 240); + }; + + let message = fields + .get("message") + .and_then(Value::as_str) + .unwrap_or_default(); + let field = fields.get("field").and_then(Value::as_str); + let path = fields.get("path").and_then(Value::as_str); + let mut location = Vec::new(); + if let Some(path) = path.filter(|value| !value.is_empty()) { + location.push(path); + } + if let Some(field) = field.filter(|value| !value.is_empty()) { + location.push(field); + } + + match (location.is_empty(), message.is_empty()) { + (false, false) => format!("{}: {message}", location.join(".")), + (false, true) => location.join("."), + (true, false) => message.to_string(), + (true, true) => truncate(&detail.to_string(), 240), + } +} + +pub fn truncate(value: &str, max_len: usize) -> String { + let mut chars = value.chars(); + let truncated = chars.by_ref().take(max_len).collect::(); + if chars.next().is_none() { + value.to_string() + } else { + format!("{truncated}...") + } +} + +#[cfg(test)] +mod tests { + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::{Arc, Mutex}; + use std::thread; + + use super::*; + use serde_json::json; + + type RecordedRequest = (String, String, Value); + + struct TestServer { + base_url: String, + requests: Arc>>, + handle: thread::JoinHandle<()>, + } + + impl TestServer { + fn start(responses: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let requests = Arc::new(Mutex::new(Vec::new())); + let thread_requests = Arc::clone(&requests); + let handle = thread::spawn(move || { + for response in responses { + let (stream, _) = listener.accept().expect("accept client request"); + record_and_respond(stream, &thread_requests, response); + } + }); + Self { + base_url, + requests, + handle, + } + } + + fn client(&self) -> MarketplaceClient { + MarketplaceClient { + base_url: self.base_url.clone(), + client: Client::new(), + has_auth: false, + style: Style::new(true, true, false), + } + } + + fn finish(self) -> Vec { + self.handle.join().expect("join test server"); + self.requests.lock().expect("lock requests").clone() + } + } + + fn record_and_respond( + stream: TcpStream, + requests: &Arc>>, + response: Value, + ) { + let mut reader = BufReader::new(stream.try_clone().expect("clone test stream")); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .expect("read request line"); + let mut parts = request_line.split_whitespace(); + let method = parts.next().expect("request method").to_string(); + let path = parts.next().expect("request path").to_string(); + let mut content_length = 0usize; + loop { + let mut line = String::new(); + reader.read_line(&mut line).expect("read request header"); + if line == "\r\n" { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse().expect("content length"); + } + } + } + let mut body = vec![0; content_length]; + reader.read_exact(&mut body).expect("read request body"); + let body = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body).expect("parse request JSON") + }; + requests + .lock() + .expect("lock requests") + .push((method, path, body)); + + let body = serde_json::to_vec(&response).expect("serialize test response"); + let mut stream = stream; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .expect("write response headers"); + stream.write_all(&body).expect("write response body"); + } + + fn read_artifact() -> Value { + json!({ + "id": "art_release_notes", + "sha256": "artifact-sha", + "size_bytes": 123, + "media_type": "application/zip" + }) + } + + fn install_plan_artifact() -> Value { + let mut artifact = read_artifact(); + artifact + .as_object_mut() + .expect("read artifact object") + .insert( + "download_url".to_string(), + json!("/v1/marketplace/artifacts/art_release_notes/download"), + ); + artifact + } + + fn version(slug: &str, version_id: &str) -> Value { + json!({ + "id": version_id, + "slug": slug, + "name": "Release Notes", + "status": "stable", + "content_sha256": "content-sha", + "persona_body": "Agent body.", + "artifact": read_artifact(), + "frontmatter": {"name": "Release Notes", "description": "Writes release notes."}, + "normalized": {}, + "files": [], + "source": { + "source_id": "src_builtin_agents", + "snapshot_id": "snap_123", + "revision": "main@abc123", + "path": "agents/release-notes.md" + }, + "created_at": "2026-07-29T00:00:00Z" + }) + } + + fn agent_detail(slug: &str, version_id: &str) -> Value { + json!({ + "slug": slug, + "name": "Release Notes", + "description": "Writes release notes.", + "status": "stable", + "visibility": "public", + "enabled": true, + "latest_version_id": version_id, + "latest_content_sha256": "content-sha", + "source_id": "src_builtin_agents", + "source_revision": "main@abc123", + "source_path": "agents/release-notes.md", + "source_enabled": true, + "tags": ["release"], + "updated_at": "2026-07-29T00:00:00Z", + "source_type": "builtin", + "risk_level": "low", + "latest_version": version(slug, version_id), + "versions": [{ + "id": version_id, + "status": "stable", + "content_sha256": "content-sha", + "created_at": "2026-07-29T00:00:00Z" + }] + }) + } + + fn agent_summary(slug: &str, version_id: &str) -> Value { + let mut detail = agent_detail(slug, version_id); + detail + .as_object_mut() + .expect("agent detail object") + .remove("latest_version"); + detail + .as_object_mut() + .expect("agent detail object") + .remove("versions"); + detail + } + + #[test] + fn marketplace_error_envelope_formats_stably() { + let body = br#"{ + "error": { + "code": "validation_failed", + "message": "Source sync produced validation errors.", + "request_id": "req_123", + "retryable": false, + "details": [ + { + "path": "skills/foo/SKILL.md", + "field": "description", + "message": "description is required" + } + ] + } + }"#; + + assert_eq!( + format_http_error( + "POST", + "/v1/marketplace/install-plan", + StatusCode::UNPROCESSABLE_ENTITY, + body, + ), + "POST /v1/marketplace/install-plan failed with 422 Unprocessable Entity: Source sync produced validation errors. (validation_failed)\nrequest_id: req_123\nretryable: false\ndetails: skills/foo/SKILL.md.description: description is required" + ); + } + + #[test] + fn non_envelope_http_errors_keep_body_context() { + assert_eq!( + format_http_error( + "GET", + "/v1/marketplace/skills", + StatusCode::INTERNAL_SERVER_ERROR, + b"plain failure", + ), + "GET /v1/marketplace/skills failed with 500 Internal Server Error: plain failure" + ); + } + + #[test] + fn invalid_agent_operation_uses_the_structured_verification_error() { + let error = invalid_agent_operation(AgentOperationError::WrongKind { + slug: "release-notes".to_string(), + actual: "skill".to_string(), + }); + let (exit_code, payload) = failure_info(&error); + + assert_eq!(exit_code, exit_codes::VERIFICATION); + assert_eq!(payload["error"]["code"], "invalid_agent_operation_kind"); + } + + #[test] + fn agent_marketplace_uses_authoritative_read_and_install_plan_contracts() { + let server = TestServer::start(vec![ + json!({"items": [agent_summary("release-notes", "agent-v1")], "next_cursor": "next cursor"}), + json!({"items": [agent_summary("security-review", "agent-v2")], "next_cursor": ""}), + agent_detail("release-notes", "agent-v1"), + json!({ + "plan_id": "plan_release_notes", + "expires_at": "2026-07-29T01:00:00Z", + "operations": [{ + "action": "install", + "reason": "Install latest stable marketplace agent artifact.", + "kind": "agent", + "skill": { + "slug": "release-notes", + "version_id": "agent-v2", + "content_sha256": "content-sha-v2" + }, + "artifact": install_plan_artifact(), + "installed_via": "explicit" + }], + "warnings": [] + }), + version("release-notes", "agent-v2"), + ]); + let client = server.client(); + let marketplace = client.agents(); + + let agents = marketplace + .list_all(Some("release notes")) + .expect("list agent catalog"); + let resolution = marketplace + .resolve_install( + "release-notes", + Some("agent-v2".to_string()), + vec![InstalledAgentRequest { + slug: "release-notes".to_string(), + version_id: Some("agent-v1".to_string()), + content_sha256: Some("content-sha".to_string()), + scope: Some("global".to_string()), + targets: Vec::new(), + installed_via: Some("explicit".to_string()), + local_source: false, + }], + ) + .expect("resolve agent install"); + + assert_eq!(agents.len(), 2); + assert_eq!(agents[0].source_path, "agents/release-notes.md"); + assert_eq!(resolution.plan.version_id, "agent-v2"); + assert_eq!(resolution.agent.source_revision, "main@abc123"); + assert_eq!(resolution.version.source.snapshot_id, "snap_123"); + assert_eq!(resolution.version.source.path, "agents/release-notes.md"); + assert_eq!( + resolution.artifact.expect("install artifact").media_type, + "application/zip" + ); + + let requests = server.finish(); + assert_eq!(requests[0].0, "GET"); + assert_eq!( + requests[0].1, + "/v1/marketplace/agents?limit=5000&query=release%20notes" + ); + assert_eq!( + requests[1].1, + "/v1/marketplace/agents?limit=5000&query=release%20notes&cursor=next%20cursor" + ); + assert_eq!(requests[2].1, "/v1/marketplace/agents/release-notes"); + assert_eq!(requests[3].0, "POST"); + assert_eq!(requests[3].1, "/v1/marketplace/install-plan"); + assert_eq!( + requests[3].2, + json!({ + "scope": "global", + "targets": [{"type": "agent", "slug": "release-notes", "version_id": "agent-v2"}], + "installed": [{ + "slug": "release-notes", + "version_id": "agent-v1", + "content_sha256": "content-sha", + "scope": "global", + "targets": [], + "installed_via": "explicit", + "local_source": false + }], + "client": {}, + "include_dependencies": false, + "allow_removals": false, + "dry_run": false + }) + ); + assert_eq!( + requests[4].1, + "/v1/marketplace/agents/release-notes/versions/agent-v2" + ); + } + + #[test] + fn agent_install_plan_rejects_missing_or_non_agent_operations() { + for operations in [ + json!([]), + json!([{ + "action": "install", + "reason": "Wrong content type.", + "kind": "skill", + "skill": {"slug": "release-notes", "version_id": "agent-v1", "content_sha256": "content-sha"}, + "artifact": null, + "installed_via": "explicit" + }]), + ] { + let server = TestServer::start(vec![ + agent_detail("release-notes", "agent-v1"), + json!({"operations": operations}), + ]); + let client = server.client(); + let error = client + .agents() + .resolve_install("release-notes", None, Vec::new()) + .expect_err("invalid agent operation must fail"); + let (exit_code, payload) = failure_info(&error); + assert_eq!(exit_code, exit_codes::VERIFICATION); + assert_eq!(payload["error"]["code"], "invalid_agent_operation_kind"); + server.finish(); + } + } + + #[test] + fn agent_install_plan_rejects_unresolved_version_pin() { + let server = TestServer::start(vec![ + agent_detail("release-notes", "agent-v2"), + json!({ + "operations": [{ + "action": "install", + "reason": "Install latest stable marketplace agent artifact.", + "kind": "agent", + "skill": { + "slug": "release-notes", + "version_id": "agent-v2", + "content_sha256": "content-sha-v2" + }, + "artifact": install_plan_artifact(), + "installed_via": "explicit" + }] + }), + ]); + + let error = server + .client() + .agents() + .resolve_install("release-notes", Some("agent-v1".to_string()), Vec::new()) + .expect_err("unresolved version pin must fail"); + let (exit_code, payload) = failure_info(&error); + + assert_eq!(exit_code, exit_codes::PLAN_BLOCKED); + assert_eq!(payload["error"]["code"], "version_pin_unresolved"); + assert_eq!( + payload["error"]["message"], + "requested version `agent-v1` but the server resolved `agent-v2`; the marketplace currently serves only the latest stable version" + ); + server.finish(); + } + + #[test] + fn agent_install_plan_resolves_noop_without_an_artifact() { + let server = TestServer::start(vec![ + agent_detail("release-notes", "agent-v1"), + json!({ + "operations": [{ + "action": "noop", + "reason": "Already at the requested version.", + "kind": "agent", + "skill": { + "slug": "release-notes", + "version_id": "agent-v1", + "content_sha256": "content-sha" + }, + "artifact": null, + "installed_via": "explicit" + }] + }), + version("release-notes", "agent-v1"), + ]); + + let resolution = server + .client() + .agents() + .resolve_install("release-notes", None, Vec::new()) + .expect("resolve agent noop"); + + assert_eq!(resolution.action, "noop"); + assert_eq!(resolution.reason, "Already at the requested version."); + assert_eq!(resolution.plan.version_id, "agent-v1"); + assert!(resolution.artifact.is_none()); + assert_eq!(resolution.installed_via, "explicit"); + server.finish(); + } + + #[test] + fn failure_info_defaults_to_general_exit_code() { + let error = anyhow::anyhow!("boom"); + let (exit_code, payload) = failure_info(&error); + assert_eq!(exit_code, exit_codes::GENERAL); + assert_eq!(payload["error"]["code"], "cli_error"); + } + + #[test] + fn failure_info_extracts_cli_failure_exit_code() { + let error = failure( + exit_codes::VERIFICATION, + "checksum_mismatch", + "bad artifact", + ) + .context("install builderlab-tools"); + let (exit_code, payload) = failure_info(&error); + assert_eq!(exit_code, exit_codes::VERIFICATION); + assert_eq!(payload["error"]["code"], "checksum_mismatch"); + let message = payload["error"]["message"].as_str().expect("message"); + assert!(message.contains("install builderlab-tools")); + assert!(message.contains("bad artifact")); + } + + #[test] + fn url_encode_escapes_reserved_characters() { + assert_eq!(url_encode("pull request"), "pull%20request"); + assert_eq!(url_encode("a/b&c"), "a%2Fb%26c"); + } +} diff --git a/src/bl/skills_archive.rs b/src/bl/skills_archive.rs new file mode 100644 index 0000000..081c0bd --- /dev/null +++ b/src/bl/skills_archive.rs @@ -0,0 +1,209 @@ +//! Checksum verification and safe zip extraction for skill artifacts. + +use std::io::Cursor; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; +use zip::ZipArchive; + +use super::agents_models::AgentInstallArtifact; +use super::skills_api::{exit_codes, failure, DownloadedArtifact}; +use super::skills_models::PlanArtifact; + +pub fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let digest = hasher.finalize(); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +pub fn verify_artifact(download: &DownloadedArtifact, artifact: &PlanArtifact) -> Result<()> { + verify_artifact_parts( + download, + &artifact.id, + artifact.size_bytes, + &artifact.sha256, + ) +} + +#[allow(dead_code)] +pub fn verify_agent_artifact( + download: &DownloadedArtifact, + artifact: &AgentInstallArtifact, +) -> Result<()> { + if artifact.media_type != "application/zip" { + return Err(failure( + exit_codes::VERIFICATION, + "unsupported_agent_artifact_media_type", + format!( + "agent artifact {} has media type {}; expected application/zip", + artifact.id, artifact.media_type + ), + )); + } + verify_artifact_parts( + download, + &artifact.id, + artifact.size_bytes, + &artifact.sha256, + ) +} + +fn verify_artifact_parts( + download: &DownloadedArtifact, + artifact_id: &str, + expected_size: u64, + expected_sha256: &str, +) -> Result<()> { + let size = download.bytes.len() as u64; + if size != expected_size { + return Err(failure( + exit_codes::VERIFICATION, + "artifact_size_mismatch", + format!( + "artifact size mismatch for {}: expected {}, got {}", + artifact_id, expected_size, size + ), + )); + } + if let Some(header_size) = download.header_size { + if header_size != expected_size { + return Err(failure( + exit_codes::VERIFICATION, + "artifact_header_size_mismatch", + format!( + "artifact header size mismatch for {}: expected {}, got {}", + artifact_id, expected_size, header_size + ), + )); + } + } + + let sha = sha256_hex(&download.bytes); + if sha != expected_sha256 { + return Err(failure( + exit_codes::VERIFICATION, + "artifact_checksum_mismatch", + format!( + "artifact checksum mismatch for {}: expected {}, got {}", + artifact_id, expected_sha256, sha + ), + )); + } + if let Some(header_sha256) = &download.header_sha256 { + if header_sha256 != expected_sha256 { + return Err(failure( + exit_codes::VERIFICATION, + "artifact_header_checksum_mismatch", + format!( + "artifact header checksum mismatch for {}: expected {}, got {}", + artifact_id, expected_sha256, header_sha256 + ), + )); + } + } + Ok(()) +} + +pub fn extract_zip_safely(zip_bytes: &[u8], destination: &Path) -> Result<()> { + let mut archive = ZipArchive::new(Cursor::new(zip_bytes)).context("open zip artifact")?; + for index in 0..archive.len() { + let mut file = archive.by_index(index).context("read zip entry")?; + let name = file.name().to_string(); + let relative_path = + safe_zip_path(&name).with_context(|| format!("unsafe zip entry `{name}`"))?; + if is_unix_symlink(file.unix_mode()) { + anyhow::bail!("unsafe zip entry `{name}` is a symlink"); + } + let out_path = destination.join(relative_path); + if file.is_dir() { + fs_create_dir_all(&out_path)?; + continue; + } + if let Some(parent) = out_path.parent() { + fs_create_dir_all(parent)?; + } + let mut out = std::fs::File::create(&out_path) + .with_context(|| format!("create {}", out_path.display()))?; + std::io::copy(&mut file, &mut out) + .with_context(|| format!("write {}", out_path.display()))?; + } + Ok(()) +} + +fn fs_create_dir_all(path: &Path) -> Result<()> { + std::fs::create_dir_all(path).with_context(|| format!("create {}", path.display())) +} + +pub fn safe_zip_path(name: &str) -> Result { + if name.is_empty() || name.contains('\\') || name.contains('\0') || name.contains(':') { + anyhow::bail!("path contains unsupported characters") + } + let path = Path::new(name); + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => out.push(part), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + anyhow::bail!("path escapes package root") + } + } + } + if out.as_os_str().is_empty() { + anyhow::bail!("path is empty") + } + Ok(out) +} + +fn is_unix_symlink(unix_mode: Option) -> bool { + unix_mode + .map(|mode| mode & 0o170000 == 0o120000) + .unwrap_or(false) +} + +/// Validates a user-supplied `--file` path before sending it to the server: +/// relative, no traversal, no special characters. +pub fn validate_preview_path(path: &str) -> Result<()> { + safe_zip_path(path) + .map(|_| ()) + .map_err(|err| anyhow::anyhow!("invalid --file path `{path}`: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_zip_path_rejects_traversal_and_absolute_paths() { + assert!(safe_zip_path("../escape.md").is_err()); + assert!(safe_zip_path("/abs.md").is_err()); + assert!(safe_zip_path("a\\b.md").is_err()); + assert!(safe_zip_path("ok/nested.md").is_ok()); + } + + #[test] + fn validate_preview_path_rejects_escapes() { + assert!(validate_preview_path("../../secret").is_err()); + assert!(validate_preview_path("references/setup.md").is_ok()); + } + + #[test] + fn rejects_non_zip_agent_artifacts_before_extraction() { + let download = DownloadedArtifact { + bytes: b"not a zip".to_vec(), + header_sha256: None, + header_size: None, + }; + let artifact = AgentInstallArtifact { + id: "agent-artifact".to_string(), + download_url: "/artifact".to_string(), + sha256: sha256_hex(&download.bytes), + size_bytes: download.bytes.len() as u64, + media_type: "text/plain".to_string(), + }; + + assert!(verify_agent_artifact(&download, &artifact).is_err()); + } +} diff --git a/src/bl/skills_config.rs b/src/bl/skills_config.rs new file mode 100644 index 0000000..bdfe56b --- /dev/null +++ b/src/bl/skills_config.rs @@ -0,0 +1,340 @@ +//! Configuration and profile resolution for `bl skills`. +//! +//! Resolution order for every setting: CLI flag > environment variable > +//! selected profile > built-in default. `--local-dev` flips the profile and +//! path resolution to the checked-in `bl-local-dev-config.yaml`. + +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +pub use builderlab_auth::config::{ + default_bl_home, default_kgoose_service_path, default_preferences_path, kgoose_service_url, + normalize_kgoose_base_url_with_service_path, normalize_kgoose_service_path, read_optional_env, + read_preferences_file, BL_HOME_ENV_VAR, BL_SKILLS_PROFILE_ENV_VAR, DEFAULT_KGOOSE_SERVICE_PATH, + DEFAULT_PROFILE_NAME, KGOOSE_SERVICE_PATH_ENV_VAR, +}; +use clap::ArgMatches; +use serde::{Deserialize, Serialize}; + +use crate::kgoose::DEFAULT_KGOOSE_BASE_URL; + +use super::display::Style; +use super::org_routing::resolve_org_kgoose_base_url; +use super::skills_models::SkillsPreferences; + +pub const KGOOSE_BASE_URL_ENV_VAR: &str = "KGOOSE_BASE_URL"; +pub const LOCAL_DEV_CONFIG_FILE_NAME: &str = "bl-local-dev-config.yaml"; +pub const BL_SKILLS_HOME_ENV_VAR: &str = "BL_SKILLS_HOME"; +pub const BL_SKILLS_PACKAGES_DIR_ENV_VAR: &str = "BL_SKILLS_PACKAGES_DIR"; +pub const BL_SKILLS_CONFIG_ENV_VAR: &str = "BL_SKILLS_CONFIG"; +pub const BL_KGOOSE_PLAYPEN_ENV_VAR: &str = "BL_KGOOSE_PLAYPEN"; +pub const KGOOSE_PLAYPEN_ENV_VAR: &str = "KGOOSE_PLAYPEN"; +pub const DEFAULT_CONFIG_FILE_NAME: &str = "skills.yaml"; +pub const META_FILE_NAME: &str = ".bl-skills-meta.json"; +#[derive(Debug, Clone, Default)] +pub struct SkillsProfileResolveOptions { + pub local_dev: bool, + pub explicit_config_path: Option, + pub explicit_profile: Option, + pub require_file_config: bool, +} + +#[derive(Debug, Clone)] +pub struct SkillsProfileContext { + pub bl_home: PathBuf, + pub config_path: PathBuf, + pub profile: String, + pub file_config: SkillsFileConfig, +} + +#[derive(Debug, Clone)] +pub struct SkillsConfig { + pub kgoose_base_url: String, + pub kgoose_service_path: String, + pub playpen: Option, + pub org: Option, + pub bl_home: PathBuf, + pub skills_home: PathBuf, + packages_dir: PathBuf, + pub config_path: PathBuf, + pub profile: String, + pub local_dev: bool, + pub json: bool, + pub style: Style, +} + +impl SkillsConfig { + pub fn resolve(matches: &ArgMatches) -> Result { + Self::resolve_with_org_routing(matches, true) + } + + pub fn resolve_for_config(matches: &ArgMatches) -> Result { + Self::resolve_with_org_routing(matches, false) + } + + fn resolve_with_org_routing(matches: &ArgMatches, org_routing: bool) -> Result { + let local_dev = matches.get_flag("local-dev"); + let profile_context = resolve_skills_profile_context(SkillsProfileResolveOptions { + local_dev, + explicit_config_path: matches + .get_one::("skills-config") + .map(PathBuf::from), + explicit_profile: matches.get_one::("skills-profile").cloned(), + require_file_config: true, + })?; + let bl_home = profile_context.bl_home; + let config_path = profile_context.config_path; + let file_config = profile_context.file_config; + let profile = profile_context.profile; + let profile_config = file_config.profiles.get(&profile); + + let configured_kgoose_base_url = read_optional_env(KGOOSE_BASE_URL_ENV_VAR)? + .unwrap_or_else(|| DEFAULT_KGOOSE_BASE_URL.to_string()); + let kgoose_service_path = matches + .get_one::("kgoose-service-path") + .cloned() + .or(read_optional_env(KGOOSE_SERVICE_PATH_ENV_VAR)?) + .map(|value| normalize_kgoose_service_path(&value)) + .transpose()? + .unwrap_or_else(|| { + default_kgoose_service_path(local_dev, &configured_kgoose_base_url).to_string() + }); + let raw_kgoose_base_url = normalize_kgoose_base_url_with_service_path( + &configured_kgoose_base_url, + &kgoose_service_path, + ); + let playpen = read_optional_env(BL_KGOOSE_PLAYPEN_ENV_VAR)? + .or(read_optional_env(KGOOSE_PLAYPEN_ENV_VAR)?) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let env_skills_home = read_optional_env(BL_SKILLS_HOME_ENV_VAR)?.map(PathBuf::from); + let profile_skills_home = profile_config + .and_then(|profile| profile.skills_home.clone().map(PathBuf::from)) + .map(|path| resolve_profile_path(local_dev, &config_path, path)); + let skills_home = local_dev + .then(|| profile_skills_home.clone()) + .flatten() + .or(env_skills_home) + .or(profile_skills_home) + .unwrap_or_else(|| bl_home.join("skills")); + + // Canonical skill files live in the shared agents skills directory + // (`~/.agents/skills`); `skills_home` under `~/.bl` keeps only bl + // state (downloads, cache, locks). `--local-dev` falls back to + // `/packages` so dev runs never touch real agent dirs. + let env_packages_dir = + read_optional_env(BL_SKILLS_PACKAGES_DIR_ENV_VAR)?.map(PathBuf::from); + let profile_packages_dir = profile_config + .and_then(|profile| profile.packages_dir.clone().map(PathBuf::from)) + .map(|path| resolve_profile_path(local_dev, &config_path, path)); + let packages_dir = local_dev + .then(|| profile_packages_dir.clone()) + .flatten() + .or(env_packages_dir) + .or(profile_packages_dir) + .unwrap_or_else(|| { + if local_dev { + skills_home.join("packages") + } else { + default_agents_skills_dir() + } + }); + let json = matches.get_flag("json"); + let style = Style::new( + matches.get_flag("no-color"), + json, + matches.get_flag("verbose"), + ); + let preferences_path = default_preferences_path(&bl_home); + let preferences = read_preferences_file(&preferences_path)?; + let org = preferences.org.clone(); + let kgoose_base_url = if org_routing { + resolve_org_kgoose_base_url( + &raw_kgoose_base_url, + org.as_deref(), + local_dev, + &kgoose_service_path, + )? + } else { + raw_kgoose_base_url + }; + + Ok(Self { + kgoose_base_url, + kgoose_service_path, + playpen, + org, + bl_home, + skills_home, + packages_dir, + config_path, + profile, + local_dev, + json, + style, + }) + } + + /// Canonical package directory: skills are real files here (default + /// `~/.agents/skills`) and every other target gets a link into it. + pub fn packages_dir(&self) -> PathBuf { + self.packages_dir.clone() + } + + pub fn downloads_dir(&self) -> PathBuf { + self.skills_home.join("downloads") + } + + pub fn cache_dir(&self) -> PathBuf { + self.skills_home.join("cache") + } + + pub fn locks_dir(&self) -> PathBuf { + self.skills_home.join("locks") + } + + /// Legacy Phase 1 copy location (`/targets/`). Still + /// cleaned up by `remove` and `doctor --fix` for older installs. + pub fn legacy_target_dir(&self, target: &str) -> PathBuf { + self.skills_home.join("targets").join(target) + } + + /// `~/.bl/config.yaml` by default: preferences are bl configuration, so + /// they sit next to `skills.yaml` rather than inside the skills state dir. + pub fn preferences_path(&self) -> PathBuf { + default_preferences_path(&self.bl_home) + } + + pub fn read_preferences(&self) -> Result { + read_preferences_file(&self.preferences_path()) + } + + pub fn write_preferences(&self, preferences: &SkillsPreferences) -> Result<()> { + builderlab_auth::config::write_preferences_file(&self.preferences_path(), preferences) + } +} + +pub fn resolve_skills_profile_context( + options: SkillsProfileResolveOptions, +) -> Result { + let bl_home = read_optional_env(BL_HOME_ENV_VAR)? + .map(PathBuf::from) + .unwrap_or_else(default_bl_home); + let config_path = if let Some(path) = options.explicit_config_path { + path + } else if options.local_dev { + discover_local_dev_config()? + } else if let Some(path) = read_optional_env(BL_SKILLS_CONFIG_ENV_VAR)?.map(PathBuf::from) { + path + } else { + bl_home.join(DEFAULT_CONFIG_FILE_NAME) + }; + let env_profile = read_optional_env(BL_SKILLS_PROFILE_ENV_VAR)?; + let profile_before_config = options + .explicit_profile + .clone() + .or_else(|| (!options.local_dev).then(|| env_profile.clone()).flatten()); + if !options.require_file_config { + if let Some(profile) = profile_before_config.clone() { + return Ok(SkillsProfileContext { + bl_home, + config_path, + profile, + file_config: SkillsFileConfig::default(), + }); + } + } + + let file_config = match SkillsFileConfig::read(&config_path) { + Ok(file_config) => file_config, + Err(_) if !options.require_file_config => SkillsFileConfig::default(), + Err(error) => return Err(error), + }; + let profile = profile_before_config + .or_else(|| { + options + .local_dev + .then(|| file_config.current_profile.clone()) + .flatten() + }) + .or(env_profile) + .or_else(|| file_config.current_profile.clone()) + .unwrap_or_else(|| DEFAULT_PROFILE_NAME.to_string()); + + Ok(SkillsProfileContext { + bl_home, + config_path, + profile, + file_config, + }) +} + +fn discover_local_dev_config() -> Result { + let cwd = env::current_dir().context("read current directory")?; + for dir in cwd.ancestors() { + let candidate = dir.join(LOCAL_DEV_CONFIG_FILE_NAME); + if candidate.exists() { + return Ok(candidate); + } + } + anyhow::bail!( + "--local-dev could not find {LOCAL_DEV_CONFIG_FILE_NAME} in {} or an ancestor", + cwd.display() + ) +} + +fn resolve_profile_path(local_dev: bool, config_path: &Path, path: PathBuf) -> PathBuf { + if !local_dev || path.is_absolute() { + return path; + } + config_path + .parent() + .map(|parent| parent.join(&path)) + .unwrap_or(path) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SkillsFileConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub current_profile: Option, + #[serde(default)] + pub profiles: BTreeMap, +} + +impl SkillsFileConfig { + pub fn read(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + serde_yaml::from_slice(&bytes).with_context(|| format!("parse {}", path.display())) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SkillsProfileConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub skills_home: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub packages_dir: Option, +} + +/// Shared agents skills directory: the canonical home for installed skill +/// files. Other agents (`~/.claude/skills`, ...) link here instead of each +/// holding their own copy. +pub fn default_agents_skills_dir() -> PathBuf { + env::var("HOME") + .map(|home| PathBuf::from(home).join(".agents").join("skills")) + .unwrap_or_else(|_| PathBuf::from(".agents/skills")) +} + +#[allow(dead_code)] +pub fn default_agents_agents_dir() -> PathBuf { + env::var("HOME") + .map(|home| PathBuf::from(home).join(".agents").join("agents")) + .unwrap_or_else(|_| PathBuf::from(".agents/agents")) +} diff --git a/src/bl/skills_doctor.rs b/src/bl/skills_doctor.rs new file mode 100644 index 0000000..9b41bee --- /dev/null +++ b/src/bl/skills_doctor.rs @@ -0,0 +1,324 @@ +//! `bl skills doctor`: independent diagnostic probes with optional repair. +//! +//! Every probe reports pass/warn/fail independently — the doctor never aborts +//! because one probe (like server reachability) failed; that is exactly when +//! diagnostics matter most. + +use std::fs; + +use anyhow::Result; +use serde::Serialize; +use serde_json::{json, Value}; + +use super::skills_api::MarketplaceClient; +use super::skills_config::{kgoose_service_url, SkillsConfig, SkillsFileConfig}; +use super::skills_install::{find_orphaned_work_dirs, link_targets, read_installed}; +use super::skills_models::{CapabilitiesResponse, InstalledSkillMetadata}; +use super::skills_targets::{inspect_link, LinkState, Scope, TargetRegistry}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CheckStatus { + Pass, + Warn, + Fail, +} + +#[derive(Debug, Serialize)] +pub struct Check { + pub name: &'static str, + pub status: CheckStatus, + pub detail: String, +} + +#[derive(Debug, Default)] +pub struct DoctorReport { + pub checks: Vec, + pub fixed: Vec, +} + +impl DoctorReport { + fn add(&mut self, name: &'static str, status: CheckStatus, detail: impl Into) { + self.checks.push(Check { + name, + status, + detail: detail.into(), + }); + } + + pub fn ok(&self) -> bool { + self.checks + .iter() + .all(|check| check.status != CheckStatus::Fail) + } +} + +pub fn run_doctor(config: &SkillsConfig, fix: bool) -> Result<(DoctorReport, Value)> { + let mut report = DoctorReport::default(); + + // Config file parses and the selected profile exists. + match SkillsFileConfig::read(&config.config_path) { + Ok(file_config) => { + report.add( + "config_file", + CheckStatus::Pass, + format!("parsed {}", config.config_path.display()), + ); + if file_config.profiles.contains_key(&config.profile) + || config.profile == super::skills_config::DEFAULT_PROFILE_NAME + { + report.add("profile", CheckStatus::Pass, config.profile.clone()); + } else { + report.add( + "profile", + CheckStatus::Warn, + format!( + "profile `{}` is not defined in {}", + config.profile, + config.config_path.display() + ), + ); + } + } + Err(err) => { + report.add("config_file", CheckStatus::Fail, format!("{err:#}")); + report.add("profile", CheckStatus::Warn, "skipped: config unreadable"); + } + } + + // Server reachable + capabilities, distinguishing auth failures from the + // server being down. + let mut registry_from_server: Option = None; + let service_url = kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path); + match MarketplaceClient::new(config) { + Ok(client) => { + if client.has_auth() { + report.add("auth", CheckStatus::Pass, "stored CLI auth session"); + } else { + report.add("auth", CheckStatus::Warn, "no stored CLI auth session"); + } + match client.get_json::("/v1/marketplace/capabilities") { + Ok(capabilities) => { + report.add("server", CheckStatus::Pass, service_url.clone()); + if capabilities.target_registry.is_empty() { + report.add( + "capabilities", + CheckStatus::Warn, + "server returned an empty target registry", + ); + } else { + report.add( + "capabilities", + CheckStatus::Pass, + format!( + "targets: {}", + capabilities + .target_registry + .keys() + .cloned() + .collect::>() + .join(", ") + ), + ); + registry_from_server = Some(TargetRegistry { + targets: capabilities.target_registry, + source: "server", + }); + } + } + Err(err) => { + let message = format!("{err:#}"); + let status_check = if message.contains("401") || message.contains("403") { + ("auth", "server reachable but credentials were rejected") + } else { + ("unreachable", "server unreachable") + }; + report.add( + "server", + CheckStatus::Fail, + format!("{} ({}): {message}", status_check.1, service_url), + ); + report.add( + "capabilities", + CheckStatus::Warn, + "skipped: server check failed", + ); + } + } + } + Err(err) => { + report.add("server", CheckStatus::Fail, format!("{err:#}")); + } + } + + // Packages dir exists/writable. + let packages_dir = config.packages_dir(); + if packages_dir.is_dir() { + let probe = packages_dir.join(".bl-doctor-probe"); + match fs::write(&probe, b"probe") { + Ok(()) => { + let _ = fs::remove_file(&probe); + report.add( + "packages_dir", + CheckStatus::Pass, + format!("writable: {}", packages_dir.display()), + ); + } + Err(err) => report.add( + "packages_dir", + CheckStatus::Fail, + format!("not writable: {err}"), + ), + } + } else if fix { + match fs::create_dir_all(&packages_dir) { + Ok(()) => { + report + .fixed + .push(format!("created {}", packages_dir.display())); + report.add( + "packages_dir", + CheckStatus::Pass, + format!("created: {}", packages_dir.display()), + ); + } + Err(err) => report.add("packages_dir", CheckStatus::Fail, format!("{err}")), + } + } else { + report.add( + "packages_dir", + CheckStatus::Warn, + format!( + "missing: {} (run `bl skills doctor --fix` or install a skill)", + packages_dir.display() + ), + ); + } + + // Per-package metadata parses. + let installed: Vec = match read_installed(config, Scope::Global) { + Ok(installed) => { + report.add( + "metadata", + CheckStatus::Pass, + format!("{} installed skill(s) parsed", installed.len()), + ); + installed + } + Err(err) => { + report.add("metadata", CheckStatus::Fail, format!("{err:#}")); + Vec::new() + } + }; + + // Target links point at the canonical packages. + let registry = registry_from_server.unwrap_or_else(|| TargetRegistry::load_offline(config)); + let mut link_problems = Vec::new(); + let mut relinked = 0usize; + for meta in &installed { + let package_dir = config.packages_dir().join(&meta.slug); + let Ok(resolved) = registry.resolve(&meta.targets, Scope::Global) else { + link_problems.push(format!( + "{}: targets {:?} not in registry", + meta.slug, meta.targets + )); + continue; + }; + for target in &resolved { + for base_dir in &target.base_dirs { + let link_path = base_dir.join(&meta.slug); + match inspect_link(&link_path, &package_dir) { + LinkState::Ok => {} + LinkState::Unmanaged => link_problems.push(format!( + "{}: {} exists but is unmanaged", + meta.slug, + link_path.display() + )), + LinkState::Missing | LinkState::Broken => { + if fix { + match link_targets( + &package_dir, + std::slice::from_ref(target), + &meta.slug, + ) { + Ok(_) => relinked += 1, + Err(err) => link_problems.push(format!( + "{}: could not repair {}: {err:#}", + meta.slug, + link_path.display() + )), + } + } else { + link_problems.push(format!( + "{}: {} is missing or broken", + meta.slug, + link_path.display() + )); + } + } + } + } + } + } + if relinked > 0 { + report + .fixed + .push(format!("re-linked {relinked} target link(s)")); + } + if link_problems.is_empty() { + report.add( + "target_links", + CheckStatus::Pass, + format!("checked against {} registry", registry.source), + ); + } else { + report.add("target_links", CheckStatus::Warn, link_problems.join("; ")); + } + + // Orphaned staging/backup directories from crashed installs. + let orphans = find_orphaned_work_dirs(&packages_dir); + if orphans.is_empty() { + report.add("orphaned_dirs", CheckStatus::Pass, "none"); + } else if fix { + let mut removed = 0usize; + for orphan in &orphans { + if fs::remove_dir_all(orphan).is_ok() { + removed += 1; + } + } + report + .fixed + .push(format!("removed {removed} orphaned staging/backup dir(s)")); + report.add( + "orphaned_dirs", + CheckStatus::Pass, + format!("removed {removed}"), + ); + } else { + report.add( + "orphaned_dirs", + CheckStatus::Warn, + format!( + "{} leftover staging/backup dir(s); run `bl skills doctor --fix`", + orphans.len() + ), + ); + } + + // Stable JSON shape: a checklist array plus the resolved configuration, + // so CI and agents can assert on it. + let payload = json!({ + "ok": report.ok(), + "local_dev": config.local_dev, + "profile": config.profile, + "config_path": config.config_path, + "kgoose_base_url": config.kgoose_base_url, + "kgoose_service_path": config.kgoose_service_path, + "bl_home": config.bl_home, + "bl_skills_home": config.skills_home, + "installed_count": installed.len(), + "checks": report.checks, + "fixed": report.fixed, + }); + Ok((report, payload)) +} diff --git a/src/bl/skills_install.rs b/src/bl/skills_install.rs new file mode 100644 index 0000000..6fa5c97 --- /dev/null +++ b/src/bl/skills_install.rs @@ -0,0 +1,1176 @@ +//! Install-plan execution, local metadata, locking, and removal for +//! `bl skills`. + +use std::fs; +use std::io::Write as IoWrite; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use serde_json::{json, Value}; + +use super::display::stdin_is_tty; +use super::skills_api::{exit_codes, failure, MarketplaceClient}; +use super::skills_archive::{extract_zip_safely, sha256_hex, verify_artifact}; +use super::skills_config::{kgoose_service_url, SkillsConfig, META_FILE_NAME}; +use super::skills_models::{ + InstallOperation, InstallPlanResponse, InstalledSkillMetadata, InstalledSkillRequest, + SkillDetail, Warning, +}; +use super::skills_slug::{confined_skill_path, ensure_confined_skill_path, validate_slug}; +use super::skills_targets::{ + backup_unmanaged_path, copy_dir_recursive, finish_link, iso8601_utc, link_into_target, + remove_any, rollback_link, BackupOutcome, LinkOutcome, ResolvedTarget, Scope, +}; + +const LOCK_FILE_NAME: &str = "skills.lock"; +/// Locks older than this are treated as leftovers from a crashed process. +const LOCK_STALE_SECS: u64 = 15 * 60; +pub const SETUP_FILE_NAME: &str = "SETUP.md"; + +/// Filesystem lock covering install/update/remove for one skills home, so +/// concurrent `bl skills` runs cannot race on `packages/` and target dirs. +#[derive(Debug)] +pub struct InstallLock { + path: PathBuf, +} + +impl InstallLock { + pub fn acquire(config: &SkillsConfig) -> Result { + let locks_dir = config.locks_dir(); + fs::create_dir_all(&locks_dir) + .with_context(|| format!("create {}", locks_dir.display()))?; + let path = locks_dir.join(LOCK_FILE_NAME); + + for _ in 0..2 { + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut file) => { + let _ = writeln!(file, "{}", std::process::id()); + return Ok(Self { path }); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + let stale = fs::metadata(&path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age.as_secs() > LOCK_STALE_SECS); + if stale { + config + .style + .warn("removing stale install lock left by a previous run"); + let _ = fs::remove_file(&path); + continue; + } + let holder = fs::read_to_string(&path).unwrap_or_default(); + return Err(failure( + exit_codes::FS_CONFLICT, + "install_locked", + format!( + "another bl skills install appears to be running (pid {}); remove {} if this is stale", + holder.trim(), + path.display() + ), + )); + } + Err(err) => { + return Err(err).with_context(|| format!("create lock {}", path.display())) + } + } + } + Err(failure( + exit_codes::FS_CONFLICT, + "install_locked", + format!("could not acquire install lock at {}", path.display()), + )) + } +} + +impl Drop for InstallLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +/// Canonical package directory for a slug in the given scope. Global +/// installs are real files in the shared agents skills directory (default +/// `~/.agents/skills`); other targets link to them. Project-scope installs +/// are materialized under `./.agents/skills` (committable to VCS), +/// mirroring sq-agents' project layout. +pub fn canonical_dir(config: &SkillsConfig, scope: Scope, slug: &str) -> PathBuf { + match scope { + Scope::Global => config.packages_dir().join(slug), + Scope::Project => PathBuf::from("./.agents/skills").join(slug), + } +} + +pub fn canonical_root(config: &SkillsConfig, scope: Scope) -> PathBuf { + match scope { + Scope::Global => config.packages_dir(), + Scope::Project => PathBuf::from("./.agents/skills"), + } +} + +pub fn read_installed(config: &SkillsConfig, scope: Scope) -> Result> { + let root = canonical_root(config, scope); + if !root.exists() { + return Ok(Vec::new()); + } + let mut installed = Vec::new(); + for entry in fs::read_dir(&root).with_context(|| format!("read {}", root.display()))? { + let entry = entry.with_context(|| format!("read entry in {}", root.display()))?; + if !entry + .file_type() + .with_context(|| format!("stat {}", entry.path().display()))? + .is_dir() + { + continue; + } + let meta_path = entry.path().join(META_FILE_NAME); + if !meta_path.is_file() { + continue; + } + let metadata = serde_json::from_slice::( + &fs::read(&meta_path).with_context(|| format!("read {}", meta_path.display()))?, + ) + .with_context(|| format!("parse {}", meta_path.display()))?; + installed.push(metadata); + } + installed.sort_by(|left, right| left.slug.cmp(&right.slug)); + Ok(installed) +} + +pub fn installed_request_payload( + installed: &[InstalledSkillMetadata], + force_slugs: &[String], +) -> Vec { + installed + .iter() + // Omitting a forced slug makes the server plan a fresh install even + // when the installed content already matches the latest version. + .filter(|meta| !force_slugs.contains(&meta.slug)) + .map(|meta| InstalledSkillRequest { + slug: meta.slug.clone(), + version_id: Some(meta.version_id.clone()), + content_sha256: Some(meta.content_sha256.clone()), + scope: Some(meta.scope.clone()), + targets: meta.targets.clone(), + installed_via: Some(meta.installed_via.clone()), + local_source: meta.local_source, + }) + .collect() +} + +pub fn ensure_base_dirs(config: &SkillsConfig) -> Result<()> { + fs::create_dir_all(&config.bl_home) + .with_context(|| format!("create {}", config.bl_home.display()))?; + fs::create_dir_all(&config.skills_home) + .with_context(|| format!("create {}", config.skills_home.display()))?; + fs::create_dir_all(config.packages_dir()).context("create packages directory")?; + fs::create_dir_all(config.downloads_dir()).context("create downloads directory")?; + fs::create_dir_all(config.cache_dir()).context("create cache directory")?; + Ok(()) +} + +#[derive(Debug)] +pub struct PackageReplacement { + persistent_backup: Option, + rollback: Option, +} + +impl PackageReplacement { + fn finish(self) -> Result> { + if let Some(rollback) = self.rollback { + remove_any(&rollback).with_context(|| format!("remove {}", rollback.display()))?; + } + Ok(self.persistent_backup) + } + + fn restore(self, final_dir: &Path) -> Result> { + if let Some(rollback) = self.rollback { + remove_any(final_dir) + .with_context(|| format!("remove failed replacement {}", final_dir.display()))?; + fs::rename(&rollback, final_dir).with_context(|| { + format!( + "restore previous package {} to {}", + rollback.display(), + final_dir.display() + ) + })?; + } else if let Some(backup) = &self.persistent_backup { + remove_any(final_dir) + .with_context(|| format!("remove failed replacement {}", final_dir.display()))?; + fs::rename(&backup.backup_path, final_dir).with_context(|| { + format!( + "restore previous package {} to {}", + backup.backup_path.display(), + final_dir.display() + ) + })?; + return Ok(None); + } + Ok(self.persistent_backup) + } +} + +pub fn replace_managed_dir( + root: &Path, + staging: &Path, + final_dir: &Path, +) -> Result { + ensure_confined_skill_path(root, final_dir)?; + let final_metadata = fs::symlink_metadata(final_dir).ok(); + let final_exists = final_metadata.is_some(); + let is_bl_owned = final_metadata.is_some_and(|metadata| metadata.is_dir()) + && final_dir.join(META_FILE_NAME).is_file(); + let rollback = final_dir.with_file_name(format!( + ".{}.previous-{}", + final_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("package"), + unique_suffix() + )); + let persistent_backup = if final_exists && !is_bl_owned { + Some(backup_unmanaged_path(final_dir)?) + } else { + if final_exists { + fs::rename(final_dir, &rollback) + .with_context(|| format!("prepare to replace {}", final_dir.display()))?; + } + None + }; + match fs::rename(staging, final_dir) { + Ok(()) => Ok(PackageReplacement { + persistent_backup, + rollback: fs::symlink_metadata(&rollback).ok().map(|_| rollback), + }), + Err(err) => { + if let Some(backup) = &persistent_backup { + let _ = fs::rename(&backup.backup_path, final_dir); + } else if fs::symlink_metadata(&rollback).is_ok() { + let _ = fs::rename(&rollback, final_dir); + } + Err(err).with_context(|| format!("install {}", final_dir.display())) + } + } +} + +fn recovery_message(final_dir: &Path, backup: Option<&BackupOutcome>) -> String { + match backup { + Some(backup) => format!( + "target linking failed; restored the previous package at {} (unmanaged backup retained at {})", + final_dir.display(), + backup.backup_path.display() + ), + None => format!( + "target linking failed; restored the previous package at {}", + final_dir.display() + ), + } +} + +#[derive(Debug)] +pub struct InstalledChange { + pub slug: String, + pub action: String, + pub version_id: String, + pub artifact_sha256: String, + pub installed_via: String, + pub targets: Vec, + pub links: Vec, + pub backups: Vec, + pub setup: Option, +} + +#[derive(Debug, Default)] +pub struct PlanExecution { + pub plan_id: String, + pub installed: Vec, + pub up_to_date: Vec, + pub removed: Vec, + pub skipped: Vec<(String, String)>, + pub warnings: Vec, +} + +impl PlanExecution { + pub fn to_json(&self) -> Value { + json!({ + "plan_id": self.plan_id, + "installed": self + .installed + .iter() + .map(|change| { + json!({ + "slug": change.slug, + "action": change.action, + "version_id": change.version_id, + "artifact_sha256": change.artifact_sha256, + "installed_via": change.installed_via, + "targets": change.targets, + "links": change.links, + "backups": change.backups, + "setup": change.setup.as_ref().map(|setup| json!({ + "path": setup.path, + "title": setup.title, + "sections": setup.sections, + })), + }) + }) + .collect::>(), + "up_to_date": self.up_to_date, + "removed": self.removed, + "skipped": self + .skipped + .iter() + .map(|(slug, reason)| json!({"slug": slug, "reason": reason})) + .collect::>(), + "warnings": self.warnings, + }) + } +} + +pub struct ExecuteOptions<'a> { + pub targets: &'a [ResolvedTarget], + pub scope: Scope, + pub allow_removals: bool, + /// Slugs explicitly pinned with `--version` (recorded in metadata). + pub pinned_slugs: &'a [String], +} + +/// Executes the install/update/remove operations of a server plan. Unknown +/// future actions are skipped with a warning instead of aborting mid-plan +/// with some packages already mutated. +pub fn execute_plan( + config: &SkillsConfig, + client: &MarketplaceClient, + plan: InstallPlanResponse, + options: &ExecuteOptions, +) -> Result { + // Validate the entire untrusted plan before the first operation can fetch + // an artifact or mutate the filesystem. Deserialization already applies + // this contract; this second gate protects programmatic future callers. + for operation in &plan.operations { + validate_slug(&operation.skill.slug).with_context(|| { + format!( + "invalid skill slug in install plan operation `{}`", + operation.action + ) + })?; + } + + let mut execution = PlanExecution { + plan_id: plan.plan_id, + warnings: plan.warnings, + ..Default::default() + }; + for operation in &plan.operations { + match operation.action.as_str() { + "noop" => execution.up_to_date.push(operation.skill.slug.clone()), + "install" | "update" => { + let change = execute_install_operation(config, client, operation, options)?; + execution.installed.push(change); + } + "remove" if options.allow_removals => { + let report = remove_skill( + config, + &operation.skill.slug, + None, + options.scope, + false, + false, + )?; + execution.removed.push(operation.skill.slug.clone()); + let _ = report; + } + other => { + config.style.warn(&format!( + "skipping unsupported plan action `{other}` for {}", + operation.skill.slug + )); + execution.skipped.push(( + operation.skill.slug.clone(), + format!("unsupported action `{other}`"), + )); + } + } + } + Ok(execution) +} + +fn execute_install_operation( + config: &SkillsConfig, + client: &MarketplaceClient, + operation: &InstallOperation, + options: &ExecuteOptions, +) -> Result { + let slug = &operation.skill.slug; + let artifact = operation + .artifact + .as_ref() + .context("install operation did not include artifact metadata")?; + + let final_dir = confined_skill_path(&canonical_root(config, options.scope), slug)?; + + // Source provenance comes from the catalog detail; failures downgrade to + // missing provenance rather than blocking the install. + let detail = client + .get_json::(&format!("/v1/marketplace/skills/{slug}")) + .ok(); + + let download = client.download(&artifact.download_url)?; + verify_artifact(&download, artifact)?; + persist_download(config, slug, &operation.skill.version_id, &download.bytes); + + let metadata = InstalledSkillMetadata { + schema_version: "bl-skills-install/v1".to_string(), + server_url: kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path), + slug: slug.clone(), + version_id: operation.skill.version_id.clone(), + content_sha256: operation.skill.content_sha256.clone(), + artifact_sha256: artifact.sha256.clone(), + artifact_size_bytes: artifact.size_bytes, + installed_at: iso8601_utc(unix_seconds()?), + installed_via: operation.installed_via.clone(), + source_id: detail.as_ref().and_then(|detail| detail.source_id.clone()), + source_revision: detail + .as_ref() + .and_then(|detail| detail.source_revision.clone()), + scope: options.scope.as_str().to_string(), + targets: options + .targets + .iter() + .map(|target| target.name.clone()) + .collect(), + local_source: false, + pinned: options.pinned_slugs.contains(slug), + }; + + let package_replacement = write_package(config, &final_dir, &download.bytes, &metadata)?; + let links = match link_targets(&final_dir, options.targets, slug) { + Ok(links) => links, + Err(error) => { + let recovered = package_replacement.restore(&final_dir)?; + return Err(error).with_context(|| recovery_message(&final_dir, recovered.as_ref())); + } + }; + let package_backup = package_replacement.finish()?; + let backups = package_backup + .into_iter() + .chain(links.iter().filter_map(|link| link.backup.clone())) + .collect(); + let setup = setup_summary(&final_dir); + + Ok(InstalledChange { + slug: slug.clone(), + action: operation.action.clone(), + version_id: operation.skill.version_id.clone(), + artifact_sha256: artifact.sha256.clone(), + installed_via: operation.installed_via.clone(), + targets: metadata.targets, + links, + backups, + setup, + }) +} + +/// Keeps the verified artifact in `/downloads` for audit and +/// re-install without re-downloading. +fn persist_download(config: &SkillsConfig, slug: &str, version_id: &str, bytes: &[u8]) { + let downloads = config.downloads_dir(); + if fs::create_dir_all(&downloads).is_err() { + return; + } + let version_key = sha256_hex(version_id.as_bytes()); + let Ok(download_path) = confined_skill_path(&downloads, slug) + .map(|path| path.with_file_name(format!("{slug}-{version_key}.zip"))) + else { + return; + }; + let _ = fs::write(download_path, bytes); +} + +fn write_package( + config: &SkillsConfig, + final_dir: &Path, + zip_bytes: &[u8], + metadata: &InstalledSkillMetadata, +) -> Result { + let parent = final_dir + .parent() + .context("package directory has no parent")?; + ensure_confined_skill_path(parent, final_dir)?; + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + + let staging = parent.join(format!(".{}.tmp-{}", metadata.slug, unique_suffix())); + if staging.exists() { + fs::remove_dir_all(&staging).context("remove stale staging directory")?; + } + fs::create_dir_all(&staging).context("create staging package directory")?; + + let result = (|| -> Result { + extract_zip_safely(zip_bytes, &staging).context("extract artifact")?; + if !staging.join("SKILL.md").is_file() { + anyhow::bail!( + "artifact for {} did not contain SKILL.md at package root", + metadata.slug + ); + } + fs::write( + staging.join(META_FILE_NAME), + serde_json::to_vec_pretty(metadata).context("serialize install metadata")?, + ) + .context("write install metadata")?; + replace_managed_dir(parent, &staging, final_dir) + })(); + if result.is_err() && staging.exists() { + let _ = fs::remove_dir_all(&staging); + } + let _ = config; + result +} + +pub fn link_targets( + package_dir: &Path, + targets: &[ResolvedTarget], + slug: &str, +) -> Result> { + validate_slug(slug)?; + let mut links = Vec::new(); + for target in targets { + for base_dir in &target.base_dirs { + match link_into_target(package_dir, base_dir, slug, target.prefer_symlink) { + Ok(link) => links.push(link), + Err(error) => { + for link in links.iter().rev() { + rollback_link(link).with_context(|| { + format!( + "roll back target {} after link failure", + link.path.display() + ) + })?; + } + return Err(error); + } + } + } + } + for link in &links { + finish_link(link); + } + Ok(links) +} + +/// Confirms a mutating plan with the user. `--yes` skips the prompt; JSON +/// mode and non-interactive shells require it. +pub fn confirm_or_bail(config: &SkillsConfig, yes: bool, summary: &str) -> Result<()> { + if yes { + return Ok(()); + } + if config.json { + return Err(failure( + exit_codes::CANCELED, + "confirmation_required", + "--json mode never prompts; pass --yes to confirm the changes", + )); + } + if !stdin_is_tty() { + return Err(failure( + exit_codes::CANCELED, + "confirmation_required", + "Non-interactive shell — pass --yes to confirm the changes", + )); + } + eprint!("{summary} Proceed? [y/N] "); + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .context("read confirmation")?; + let answer = answer.trim().to_ascii_lowercase(); + if answer == "y" || answer == "yes" { + Ok(()) + } else { + Err(failure( + exit_codes::CANCELED, + "canceled", + "canceled by user", + )) + } +} + +#[derive(Debug, Clone)] +pub struct SetupSummary { + pub path: PathBuf, + pub title: String, + pub sections: Vec, +} + +/// Extracts a short summary from a package's SETUP.md so installs can prompt +/// "this skill needs one-time setup" like sq-agents does. +pub fn setup_summary(package_dir: &Path) -> Option { + let path = package_dir.join(SETUP_FILE_NAME); + let contents = fs::read_to_string(&path).ok()?; + let mut title = "Setup Required".to_string(); + let mut sections = Vec::new(); + for line in contents.lines() { + let line = line.trim(); + if let Some(heading) = line.strip_prefix("# ") { + if title == "Setup Required" { + title = heading.trim().to_string(); + } + } else if let Some(section) = line.strip_prefix("## ") { + if sections.len() < 5 { + sections.push(section.trim().to_string()); + } + } + } + Some(SetupSummary { + path, + title, + sections, + }) +} + +/// Installs a skill from a local directory (the skill-author dev loop). +/// Never goes through the marketplace; metadata records `local_source: true` +/// so remote updates refuse to overwrite it without `--force`. +pub fn install_local_path( + config: &SkillsConfig, + source: &Path, + slug_override: Option<&str>, + targets: &[ResolvedTarget], + scope: Scope, + force: bool, +) -> Result { + if !source.is_dir() { + anyhow::bail!("local install path {} is not a directory", source.display()); + } + if !source.join("SKILL.md").is_file() { + anyhow::bail!( + "local install path {} does not contain SKILL.md", + source.display() + ); + } + let slug = match slug_override { + Some(name) => name.to_string(), + None => source + .file_name() + .and_then(|name| name.to_str()) + .context("could not derive a skill name from the path; pass --name")? + .to_string(), + }; + validate_slug(&slug)?; + + let final_dir = canonical_dir(config, scope, &slug); + if let Ok(existing) = read_metadata(&final_dir) { + if existing.local_source && !force { + return Err(failure( + exit_codes::FS_CONFLICT, + "local_source_installed", + format!( + "skill `{slug}` is already installed from a local source; pass --force to overwrite" + ), + )); + } + } + let parent = final_dir + .parent() + .context("package directory has no parent")?; + ensure_confined_skill_path(parent, &final_dir)?; + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + let staging = parent.join(format!(".{slug}.tmp-{}", unique_suffix())); + if staging.exists() { + fs::remove_dir_all(&staging).context("remove stale staging directory")?; + } + copy_dir_recursive(source, &staging)?; + // Drop any metadata copied from a previously installed source directory. + let _ = fs::remove_file(staging.join(META_FILE_NAME)); + + let content_sha = hash_directory(&staging)?; + let metadata = InstalledSkillMetadata { + schema_version: "bl-skills-install/v1".to_string(), + server_url: kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path), + slug: slug.clone(), + version_id: format!("local-{}", &content_sha[..12]), + content_sha256: content_sha, + artifact_sha256: String::new(), + artifact_size_bytes: 0, + installed_at: iso8601_utc(unix_seconds()?), + installed_via: "local-path".to_string(), + source_id: None, + source_revision: Some(source.display().to_string()), + scope: scope.as_str().to_string(), + targets: targets.iter().map(|target| target.name.clone()).collect(), + local_source: true, + pinned: false, + }; + fs::write( + staging.join(META_FILE_NAME), + serde_json::to_vec_pretty(&metadata).context("serialize install metadata")?, + ) + .context("write install metadata")?; + let package_replacement = replace_managed_dir(parent, &staging, &final_dir)?; + let links = match link_targets(&final_dir, targets, &slug) { + Ok(links) => links, + Err(error) => { + let recovered = package_replacement.restore(&final_dir)?; + return Err(error).with_context(|| recovery_message(&final_dir, recovered.as_ref())); + } + }; + let package_backup = package_replacement.finish()?; + let backups = package_backup + .into_iter() + .chain(links.iter().filter_map(|link| link.backup.clone())) + .collect(); + let setup = setup_summary(&final_dir); + + Ok(PlanExecution { + plan_id: "local".to_string(), + installed: vec![InstalledChange { + slug, + action: "install".to_string(), + version_id: metadata.version_id, + artifact_sha256: String::new(), + installed_via: "local-path".to_string(), + targets: metadata.targets, + links, + backups, + setup, + }], + ..Default::default() + }) +} + +/// Deterministic content hash over a directory's files (path + bytes). +fn hash_directory(dir: &Path) -> Result { + let mut entries = Vec::new(); + collect_files(dir, dir, &mut entries)?; + entries.sort(); + let mut rollup = String::new(); + for relative in entries { + let bytes = fs::read(dir.join(&relative)) + .with_context(|| format!("read {}", dir.join(&relative).display()))?; + rollup.push_str(&relative); + rollup.push(':'); + rollup.push_str(&sha256_hex(&bytes)); + rollup.push('\n'); + } + Ok(sha256_hex(rollup.as_bytes())) +} + +fn collect_files(root: &Path, dir: &Path, into: &mut Vec) -> Result<()> { + for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { + let entry = entry.with_context(|| format!("read entry in {}", dir.display()))?; + let path = entry.path(); + if path.is_dir() { + collect_files(root, &path, into)?; + } else if path.is_file() { + if let Ok(relative) = path.strip_prefix(root) { + into.push(relative.to_string_lossy().to_string()); + } + } + } + Ok(()) +} + +pub fn read_metadata(package_dir: &Path) -> Result { + let meta_path = package_dir.join(META_FILE_NAME); + let bytes = fs::read(&meta_path).with_context(|| format!("read {}", meta_path.display()))?; + serde_json::from_slice(&bytes).with_context(|| format!("parse {}", meta_path.display())) +} + +#[derive(Debug, Default)] +pub struct RemovalReport { + pub slug: String, + pub removed_links: Vec, + pub skipped_paths: Vec<(PathBuf, String)>, + pub removed_package: bool, +} + +impl RemovalReport { + pub fn to_json(&self) -> Value { + json!({ + "slug": self.slug, + "removed_links": self.removed_links, + "skipped": self + .skipped_paths + .iter() + .map(|(path, reason)| json!({"path": path, "reason": reason})) + .collect::>(), + "removed_package": self.removed_package, + }) + } +} + +/// Removes a skill: target links first, then the canonical package when no +/// target subset was requested. Unmanaged directories are skipped unless +/// `--include-unmanaged --force`. +pub fn remove_skill( + config: &SkillsConfig, + slug: &str, + only_targets: Option<&[ResolvedTarget]>, + scope: Scope, + include_unmanaged: bool, + force: bool, +) -> Result { + use super::skills_targets::{inspect_link, remove_any, LinkState, TargetRegistry}; + + let final_dir = confined_skill_path(&canonical_root(config, scope), slug)?; + let metadata = read_metadata(&final_dir).ok(); + if metadata.is_none() && !(include_unmanaged && force) { + return Err(failure( + exit_codes::GENERAL, + "not_installed", + format!( + "skill `{slug}` is not installed (no managed package at {}); pass --include-unmanaged --force to remove unmanaged files", + final_dir.display() + ), + )); + } + + let registry = TargetRegistry::load_offline(config); + let resolved_storage; + let targets: &[ResolvedTarget] = match only_targets { + Some(targets) => targets, + None => { + // Default to the targets recorded at install time; fall back to + // every known target when metadata is missing. + let names = metadata + .as_ref() + .map(|meta| meta.targets.clone()) + .unwrap_or_else(|| registry.targets.keys().cloned().collect()); + resolved_storage = registry.resolve(&names, scope).unwrap_or_default(); + &resolved_storage + } + }; + + let mut report = RemovalReport { + slug: slug.to_string(), + ..Default::default() + }; + + let canonical_root = final_dir.parent(); + for target in targets { + for base_dir in &target.base_dirs { + let link_path = confined_skill_path(base_dir, slug)?; + // The agents target's directory is the canonical packages root + // itself (skills live there; other targets link to it), so its + // entry is never a link to remove — package removal below + // handles it. + if canonical_root.is_some_and(|root| is_same_location(base_dir, root)) { + if only_targets.is_some() { + report.skipped_paths.push(( + link_path, + "canonical package directory; run remove without --target to delete the skill" + .to_string(), + )); + } + continue; + } + match inspect_link(&link_path, &final_dir) { + LinkState::Missing => {} + LinkState::Ok | LinkState::Broken => { + remove_any(&link_path)?; + report.removed_links.push(link_path); + } + LinkState::Unmanaged => { + if include_unmanaged && force { + remove_any(&link_path)?; + report.removed_links.push(link_path); + } else { + report.skipped_paths.push(( + link_path, + "unmanaged; pass --include-unmanaged --force".to_string(), + )); + } + } + } + } + // Clean up legacy Phase 1 copies under /targets/. + let legacy_root = config.legacy_target_dir(&target.name); + let legacy = confined_skill_path(&legacy_root, slug)?; + if legacy.exists() { + if legacy.join(META_FILE_NAME).is_file() || (include_unmanaged && force) { + remove_any(&legacy)?; + report.removed_links.push(legacy); + } else { + report + .skipped_paths + .push((legacy, "unmanaged legacy copy".to_string())); + } + } + } + + if only_targets.is_none() { + if final_dir.exists() && (metadata.is_some() || (include_unmanaged && force)) { + remove_any(&final_dir)?; + report.removed_package = true; + } + } else if let Some(mut meta) = metadata { + // Partial removal: drop the removed targets from metadata, keeping + // any target whose directory is the canonical packages root (nothing + // was removed for it). + let removed_names: Vec<&str> = targets + .iter() + .filter(|target| { + !target.base_dirs.iter().any(|base_dir| { + canonical_root.is_some_and(|root| is_same_location(base_dir, root)) + }) + }) + .map(|target| target.name.as_str()) + .collect(); + meta.targets + .retain(|name| !removed_names.contains(&name.as_str())); + fs::write( + final_dir.join(META_FILE_NAME), + serde_json::to_vec_pretty(&meta).context("serialize install metadata")?, + ) + .context("update install metadata")?; + } + + Ok(report) +} + +/// True when both paths refer to the same location (canonicalized when they +/// exist). Detects the agents target whose directory IS the canonical +/// packages root. +fn is_same_location(left: &Path, right: &Path) -> bool { + match (fs::canonicalize(left), fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + +pub fn unix_seconds() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX epoch")? + .as_secs()) +} + +pub fn unique_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + format!("{}-{nanos}", std::process::id()) +} + +/// Detects orphaned staging/backup directories left behind by crashes. +pub fn find_orphaned_work_dirs(root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(root) else { + return Vec::new(); + }; + entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with('.') && (name.contains(".tmp-") || name.contains(".previous-")) + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso8601_formats_known_timestamps() { + assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z"); + // 2026-06-10T00:00:00Z + assert_eq!(iso8601_utc(1_781_049_600), "2026-06-10T00:00:00Z"); + } + + #[test] + fn setup_summary_extracts_title_and_sections() { + let temp = std::env::temp_dir().join(format!("bl-setup-{}", unique_suffix())); + fs::create_dir_all(&temp).expect("create temp"); + fs::write( + temp.join(SETUP_FILE_NAME), + "# Slack Setup\n\nIntro\n\n## Create a token\n\n## Configure env\n", + ) + .expect("write SETUP.md"); + + let summary = setup_summary(&temp).expect("summary"); + assert_eq!(summary.title, "Slack Setup"); + assert_eq!(summary.sections, vec!["Create a token", "Configure env"]); + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn replace_managed_dir_does_not_keep_backup_for_bl_owned_skill() { + let temp = std::env::temp_dir().join(format!("bl-owned-replace-{}", unique_suffix())); + let final_dir = temp.join("demo"); + let staging = temp.join(".demo.tmp-test"); + fs::create_dir_all(&final_dir).expect("create existing skill"); + fs::write(final_dir.join("SKILL.md"), "old").expect("write old skill"); + fs::write(final_dir.join(META_FILE_NAME), "{}").expect("write ownership marker"); + fs::create_dir_all(&staging).expect("create staging skill"); + fs::write(staging.join("SKILL.md"), "new").expect("write new skill"); + + let backup = replace_managed_dir(&temp, &staging, &final_dir) + .expect("replace managed skill") + .finish() + .expect("finish replacement"); + + assert!(backup.is_none()); + assert_eq!( + fs::read_to_string(final_dir.join("SKILL.md")).expect("read installed skill"), + "new" + ); + let leftovers = fs::read_dir(&temp) + .expect("read temp directory") + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_name() != "demo") + .collect::>(); + assert!(leftovers.is_empty(), "leftovers: {leftovers:?}"); + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn replacement_restores_previous_package_when_target_linking_fails() { + let temp = std::env::temp_dir().join(format!("bl-owned-restore-{}", unique_suffix())); + let final_dir = temp.join("demo"); + let staging = temp.join(".demo.tmp-test"); + fs::create_dir_all(&final_dir).expect("create existing skill"); + fs::write(final_dir.join("SKILL.md"), "old").expect("write old skill"); + fs::write(final_dir.join(META_FILE_NAME), "{}").expect("write ownership marker"); + fs::create_dir_all(&staging).expect("create staging skill"); + fs::write(staging.join("SKILL.md"), "new").expect("write new skill"); + + let recovery = replace_managed_dir(&temp, &staging, &final_dir) + .expect("replace managed skill") + .restore(&final_dir) + .expect("restore previous package"); + + assert!(recovery.is_none()); + assert_eq!( + fs::read_to_string(final_dir.join("SKILL.md")).expect("read restored skill"), + "old" + ); + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn replacement_restores_unmanaged_package_when_target_linking_fails() { + let temp = std::env::temp_dir().join(format!("bl-unmanaged-restore-{}", unique_suffix())); + let final_dir = temp.join("demo"); + let staging = temp.join(".demo.tmp-test"); + fs::create_dir_all(&final_dir).expect("create unmanaged skill"); + fs::write(final_dir.join("SKILL.md"), "user-owned").expect("write unmanaged skill"); + fs::create_dir_all(&staging).expect("create staging skill"); + fs::write(staging.join("SKILL.md"), "new").expect("write new skill"); + + let recovery = replace_managed_dir(&temp, &staging, &final_dir) + .expect("replace unmanaged skill") + .restore(&final_dir) + .expect("restore unmanaged skill"); + + assert!(recovery.is_none()); + assert_eq!( + fs::read_to_string(final_dir.join("SKILL.md")).expect("read restored skill"), + "user-owned" + ); + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[cfg(unix)] + #[test] + fn replacement_restores_manual_package_symlink_when_target_linking_fails() { + let temp = std::env::temp_dir().join(format!("bl-symlink-restore-{}", unique_suffix())); + let source = temp.join("manual-source"); + let final_dir = temp.join("demo"); + let staging = temp.join(".demo.tmp-test"); + fs::create_dir_all(&source).expect("create manual source"); + fs::write(source.join("SKILL.md"), "user-owned").expect("write manual skill"); + fs::write(source.join(META_FILE_NAME), "{}").expect("write incidental metadata"); + std::os::unix::fs::symlink(&source, &final_dir).expect("create manual package symlink"); + fs::create_dir_all(&staging).expect("create staging skill"); + fs::write(staging.join("SKILL.md"), "new").expect("write new skill"); + + replace_managed_dir(&temp, &staging, &final_dir) + .expect("replace manual symlink") + .restore(&final_dir) + .expect("restore manual symlink"); + + assert!(fs::symlink_metadata(&final_dir) + .expect("restored symlink metadata") + .file_type() + .is_symlink()); + assert_eq!( + fs::read_link(&final_dir).expect("read restored symlink"), + source + ); + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn link_targets_rolls_back_earlier_copy_targets_after_later_failure() { + let temp = std::env::temp_dir().join(format!("bl-target-transaction-{}", unique_suffix())); + let package = temp.join("package"); + let managed_base = temp.join("managed-target"); + let unmanaged_base = temp.join("unmanaged-target"); + let invalid_base = temp.join("invalid-target"); + fs::create_dir_all(&package).expect("create package"); + fs::write(package.join("SKILL.md"), "new").expect("write new package"); + + let managed = managed_base.join("demo"); + fs::create_dir_all(&managed).expect("create managed target"); + fs::write(managed.join("SKILL.md"), "old-copy").expect("write old copy"); + fs::write(managed.join(META_FILE_NAME), "{}").expect("write ownership marker"); + + let unmanaged = unmanaged_base.join("demo"); + fs::create_dir_all(&unmanaged).expect("create unmanaged target"); + fs::write(unmanaged.join("SKILL.md"), "user-owned").expect("write unmanaged target"); + fs::write(&invalid_base, "not a directory").expect("create invalid target"); + + let targets = vec![ + ResolvedTarget { + name: "managed".to_string(), + base_dirs: vec![managed_base], + prefer_symlink: false, + }, + ResolvedTarget { + name: "unmanaged".to_string(), + base_dirs: vec![unmanaged_base], + prefer_symlink: false, + }, + ResolvedTarget { + name: "invalid".to_string(), + base_dirs: vec![invalid_base], + prefer_symlink: false, + }, + ]; + + link_targets(&package, &targets, "demo").expect_err("later target should fail"); + + assert_eq!( + fs::read_to_string(managed.join("SKILL.md")).expect("read restored managed target"), + "old-copy" + ); + assert_eq!( + fs::read_to_string(unmanaged.join("SKILL.md")).expect("read restored unmanaged target"), + "user-owned" + ); + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn find_orphaned_work_dirs_matches_staging_and_backup() { + let temp = std::env::temp_dir().join(format!("bl-orphans-{}", unique_suffix())); + fs::create_dir_all(temp.join(".slack.tmp-123")).expect("staging"); + fs::create_dir_all(temp.join(".slack.previous-123")).expect("backup"); + fs::create_dir_all(temp.join("slack")).expect("real package"); + + let orphans = find_orphaned_work_dirs(&temp); + assert_eq!(orphans.len(), 2); + fs::remove_dir_all(temp).expect("cleanup"); + } +} diff --git a/src/bl/skills_models.rs b/src/bl/skills_models.rs new file mode 100644 index 0000000..6bd128f --- /dev/null +++ b/src/bl/skills_models.rs @@ -0,0 +1,286 @@ +//! API and local DTOs for `bl skills`. + +use std::collections::BTreeMap; + +use serde::{de::Error as _, Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +pub use builderlab_auth::preferences::{ + BuilderLabPreferences as SkillsPreferences, PREFERENCE_KEYS, +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct SkillPage { + pub items: Vec, + #[serde(default)] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillSummary { + pub slug: String, + pub name: String, + pub description: String, + pub status: String, + pub enabled: bool, + pub latest_version_id: String, + #[serde(default)] + pub latest_content_sha256: Option, + #[serde(default)] + pub source_id: Option, + #[serde(default)] + pub source_revision: Option, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SkillDetail { + pub slug: String, + pub name: String, + pub description: String, + pub status: String, + pub enabled: bool, + pub latest_version_id: String, + pub latest_content_sha256: String, + #[serde(default)] + pub source_id: Option, + #[serde(default)] + pub source_revision: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub dependencies: Vec, + pub latest_version: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SkillVersionDetail { + pub id: String, + pub slug: String, + pub status: String, + pub content_sha256: String, + #[serde(default)] + pub files: Vec, + #[serde(default)] + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileEntry { + pub path: String, + #[serde(default)] + pub size_bytes: u64, + #[serde(default)] + pub sha256: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BundlePage { + pub items: Vec, + #[serde(default)] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundleSummary { + pub slug: String, + pub name: String, + pub description: String, + pub status: String, + pub enabled: bool, + #[serde(default)] + pub skills: Vec, + #[serde(default)] + pub resolved_skills_count: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CapabilitiesResponse { + #[serde(default)] + pub target_registry: BTreeMap, +} + +/// One entry of the server's target registry. Unknown or partial entries +/// (older servers, local mocks) deserialize with safe defaults. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TargetConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub global_paths: Vec, + #[serde(default)] + pub project_paths: Vec, + #[serde(default)] + pub link_strategies: Vec, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Serialize)] +pub struct InstallPlanRequest { + pub scope: String, + pub targets: Vec, + pub installed: Vec, + pub client: BTreeMap, + pub include_dependencies: bool, + pub allow_removals: bool, + pub dry_run: bool, +} + +#[derive(Debug, Serialize)] +pub struct RequestedTarget { + #[serde(rename = "type")] + pub target_type: String, + pub slug: String, + pub version_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct InstalledSkillRequest { + pub slug: String, + pub version_id: Option, + pub content_sha256: Option, + pub scope: Option, + pub targets: Vec, + pub installed_via: Option, + pub local_source: bool, +} + +#[derive(Debug, Deserialize)] +pub struct InstallPlanResponse { + pub plan_id: String, + pub operations: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct InstallOperation { + pub action: String, + #[serde(default)] + pub reason: Option, + pub skill: PlanSkill, + pub artifact: Option, + pub installed_via: String, +} + +#[derive(Debug, Deserialize)] +pub struct PlanSkill { + #[serde(deserialize_with = "deserialize_skill_slug")] + pub slug: String, + pub version_id: String, + pub content_sha256: String, +} + +fn deserialize_skill_slug<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let slug = String::deserialize(deserializer)?; + super::skills_slug::validate_slug(&slug).map_err(D::Error::custom)?; + Ok(slug) +} + +#[derive(Debug, Deserialize)] +pub struct PlanArtifact { + pub id: String, + pub download_url: String, + pub sha256: String, + pub size_bytes: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Warning { + pub code: String, + pub message: String, + pub skill: Option, + pub suggested_action: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledSkillMetadata { + pub schema_version: String, + pub server_url: String, + pub slug: String, + pub version_id: String, + pub content_sha256: String, + pub artifact_sha256: String, + pub artifact_size_bytes: u64, + pub installed_at: String, + pub installed_via: String, + pub source_id: Option, + pub source_revision: Option, + pub scope: String, + pub targets: Vec, + pub local_source: bool, + pub pinned: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn install_plan_rejects_unsafe_slugs_for_every_action() { + let oversized = "a".repeat(super::super::skills_slug::MAX_SKILL_SLUG_BYTES + 1); + let unsafe_slugs = [ + "", + ".", + "..", + "../escape", + "foo/bar", + r"foo\bar", + "/absolute", + r"C:\absolute", + r"C:relative", + r"\\server\share", + oversized.as_str(), + ]; + + for action in ["install", "update", "remove", "noop", "future"] { + for slug in unsafe_slugs { + let plan = json!({ + "plan_id": "malicious", + "operations": [{ + "action": action, + "skill": { + "slug": slug, + "version_id": "version-1", + "content_sha256": "content-sha" + }, + "artifact": null, + "installed_via": "explicit" + }], + "warnings": [] + }); + let error = serde_json::from_value::(plan) + .expect_err("unsafe slug must reject the entire plan"); + assert!(error.to_string().contains("invalid skill name")); + } + } + } + + #[test] + fn install_plan_accepts_valid_marketplace_slug() { + let plan = json!({ + "plan_id": "valid", + "operations": [{ + "action": "noop", + "skill": { + "slug": "builderlab-tools", + "version_id": "version-1", + "content_sha256": "content-sha" + }, + "artifact": null, + "installed_via": "explicit" + }], + "warnings": [] + }); + + let plan = serde_json::from_value::(plan).expect("valid plan"); + assert_eq!(plan.operations[0].skill.slug, "builderlab-tools"); + } +} diff --git a/src/bl/skills_slug.rs b/src/bl/skills_slug.rs new file mode 100644 index 0000000..17658ec --- /dev/null +++ b/src/bl/skills_slug.rs @@ -0,0 +1,112 @@ +//! Skill slug validation and path confinement. +//! +//! Marketplace plan data is untrusted. Keep every skill name to one portable +//! path component before joining it to an installation root. + +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result}; + +pub const MAX_SKILL_SLUG_BYTES: usize = 128; + +pub fn validate_slug(slug: &str) -> Result<()> { + let portable_component = !slug.is_empty() + && slug.len() <= MAX_SKILL_SLUG_BYTES + && !slug.starts_with('-') + && slug + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + && matches!( + Path::new(slug).components().collect::>().as_slice(), + [Component::Normal(_)] + ); + + if portable_component { + Ok(()) + } else { + anyhow::bail!( + "invalid skill name `{slug}`; use 1-{MAX_SKILL_SLUG_BYTES} bytes of lowercase letters, digits, `-`, and `_`, without a leading `-`" + ) + } +} + +/// Joins a validated slug to a filesystem root and verifies the resulting +/// lexical path remains an immediate child. This is deliberately repeated at +/// mutation boundaries so future callers cannot bypass plan validation. +pub fn confined_skill_path(root: &Path, slug: &str) -> Result { + validate_slug(slug)?; + let path = root.join(slug); + if path.parent() != Some(root) || path.file_name() != Some(slug.as_ref()) { + anyhow::bail!( + "skill path {} escapes installation root {}", + path.display(), + root.display() + ); + } + Ok(path) +} + +pub fn ensure_confined_skill_path(root: &Path, path: &Path) -> Result<()> { + let slug = path + .file_name() + .and_then(|name| name.to_str()) + .context("skill path has no UTF-8 file name")?; + let expected = confined_skill_path(root, slug)?; + if path != expected { + anyhow::bail!( + "skill path {} is outside installation root {}", + path.display(), + root.display() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_portable_marketplace_slugs() { + for slug in ["builderlab-tools", "ach_tables", "a11y-web-audit-and-fix"] { + assert!(validate_slug(slug).is_ok(), "expected valid slug: {slug}"); + } + } + + #[test] + fn rejects_traversal_absolute_windows_and_oversized_slugs() { + let oversized = "a".repeat(MAX_SKILL_SLUG_BYTES + 1); + for slug in [ + "", + ".", + "..", + "../escape", + "foo/bar", + r"foo\bar", + "/absolute", + r"C:\absolute", + r"C:relative", + r"\\server\share", + "--project", + "has space", + "Uppercase", + oversized.as_str(), + ] { + assert!( + validate_slug(slug).is_err(), + "expected invalid slug: {slug:?}" + ); + } + } + + #[test] + fn confined_paths_are_immediate_children() { + let root = Path::new("skills"); + assert_eq!( + confined_skill_path(root, "demo").unwrap(), + root.join("demo") + ); + assert!(confined_skill_path(root, "../escape").is_err()); + assert!(ensure_confined_skill_path(root, Path::new("other/demo")).is_err()); + } +} diff --git a/src/bl/skills_targets.rs b/src/bl/skills_targets.rs new file mode 100644 index 0000000..587f1d1 --- /dev/null +++ b/src/bl/skills_targets.rs @@ -0,0 +1,677 @@ +//! Target registry resolution and package linking for `bl skills`. +//! +//! The server's `/v1/marketplace/capabilities` response defines which agent +//! targets exist (claude, codex, agents, ...) and where each one reads skills +//! from. Installs write one canonical copy into the shared agents skills +//! directory (default `~/.agents/skills/`) and then link it into every +//! other requested target's real directory (e.g. `~/.claude/skills/`), +//! preferring symlinks with a copy fallback — mirroring sq-agents' install +//! behavior. `~/.bl` keeps only bl state (downloads, cache, locks) and +//! configuration. + +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Serialize; + +use super::skills_api::{exit_codes, failure, MarketplaceClient}; +use super::skills_config::{SkillsConfig, META_FILE_NAME}; +use super::skills_models::{CapabilitiesResponse, TargetConfig}; +use super::skills_slug::confined_skill_path; + +const CAPABILITIES_CACHE_FILE: &str = "capabilities.json"; + +/// Install scope: global agent directories or project-local ones. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + Global, + Project, +} + +impl Scope { + pub fn as_str(self) -> &'static str { + match self { + Self::Global => "global", + Self::Project => "project", + } + } +} + +#[derive(Debug, Clone)] +pub struct TargetRegistry { + pub targets: BTreeMap, + /// Where the registry came from: `server`, `cache`, or `builtin`. + pub source: &'static str, +} + +impl TargetRegistry { + /// Loads the registry from the server, caching it locally; falls back to + /// the cached copy and then to built-in defaults when offline. + pub fn load(config: &SkillsConfig, client: &MarketplaceClient) -> Result { + match client.get_json::("/v1/marketplace/capabilities") { + Ok(capabilities) if !capabilities.target_registry.is_empty() => { + let registry = Self { + targets: capabilities.target_registry, + source: "server", + }; + registry.write_cache(config); + Ok(registry) + } + Ok(_) => Ok(Self::builtin()), + Err(err) => { + config.style.verbose(&format!( + "capabilities fetch failed, using cached/builtin registry: {err:#}" + )); + Ok(Self::load_cache(config).unwrap_or_else(Self::builtin)) + } + } + } + + /// Loads only from the local cache or built-in defaults; never touches + /// the network. Used by `remove` and `which` so they work offline. + pub fn load_offline(config: &SkillsConfig) -> Self { + Self::load_cache(config).unwrap_or_else(Self::builtin) + } + + fn load_cache(config: &SkillsConfig) -> Option { + let path = config.cache_dir().join(CAPABILITIES_CACHE_FILE); + let bytes = fs::read(path).ok()?; + let capabilities = serde_json::from_slice::(&bytes).ok()?; + if capabilities.target_registry.is_empty() { + return None; + } + Some(Self { + targets: capabilities.target_registry, + source: "cache", + }) + } + + fn write_cache(&self, config: &SkillsConfig) { + let cache_dir = config.cache_dir(); + if fs::create_dir_all(&cache_dir).is_err() { + return; + } + let payload = serde_json::json!({ "target_registry": self.targets }); + let _ = fs::write( + cache_dir.join(CAPABILITIES_CACHE_FILE), + serde_json::to_vec_pretty(&payload).unwrap_or_default(), + ); + } + + /// Built-in fallback mirroring the server's default registry. + pub fn builtin() -> Self { + let target = |name: &str| TargetConfig { + enabled: true, + global_paths: vec![format!("~/.{name}/skills")], + project_paths: vec![format!("./.{name}/skills")], + link_strategies: vec!["symlink".to_string(), "copy".to_string()], + }; + Self { + targets: BTreeMap::from([ + ("agents".to_string(), target("agents")), + ("claude".to_string(), target("claude")), + ("codex".to_string(), target("codex")), + ]), + source: "builtin", + } + } + + /// Validates requested target names against the registry and resolves + /// their concrete directories for the given scope. + pub fn resolve(&self, requested: &[String], scope: Scope) -> Result> { + let mut resolved = Vec::new(); + for name in requested { + let Some(target) = self.targets.get(name) else { + let known = self.targets.keys().cloned().collect::>().join(", "); + return Err(failure( + exit_codes::PLAN_BLOCKED, + "unknown_target", + format!("unknown target `{name}`; known targets: {known}"), + )); + }; + if !target.enabled { + return Err(failure( + exit_codes::PLAN_BLOCKED, + "target_disabled", + format!("target `{name}` is disabled in the target registry"), + )); + } + let paths = match scope { + Scope::Global => &target.global_paths, + Scope::Project => &target.project_paths, + }; + let base_dirs = paths + .iter() + .map(|path| expand_path(path)) + .collect::>(); + resolved.push(ResolvedTarget { + name: name.clone(), + base_dirs, + prefer_symlink: prefer_symlink(&target.link_strategies), + }); + } + Ok(resolved) + } +} + +/// Prefer symlinks whenever the registry allows them; `copy` is the fallback. +fn prefer_symlink(strategies: &[String]) -> bool { + strategies.is_empty() || strategies.iter().any(|strategy| strategy == "symlink") +} + +#[derive(Debug, Clone, Serialize)] +pub struct ResolvedTarget { + pub name: String, + pub base_dirs: Vec, + pub prefer_symlink: bool, +} + +/// Expands `~/...` against `$HOME` and leaves other paths (including +/// `./project` relative paths) untouched. +pub fn expand_path(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Ok(home) = env::var("HOME") { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(path) +} + +#[derive(Debug, Clone, Serialize)] +pub struct LinkOutcome { + pub path: PathBuf, + /// `symlink`, `copy`, or `existing` (already resolves to the package). + pub strategy: &'static str, + /// An unmanaged skill displaced while creating this link. Reported with + /// the enclosing install/update result rather than nested under `links`. + #[serde(skip)] + pub backup: Option, + #[serde(skip)] + rollback: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BackupOutcome { + pub source_path: PathBuf, + pub backup_path: PathBuf, + pub created_at: String, +} + +/// Links `package_dir` into `/` using the target's preferred +/// strategy. Handles the gnarly cases sq-agents handles: the base dir itself +/// being a symlink to the canonical location, tools replacing symlinks with +/// real directories, and missing parent directories. +pub fn link_into_target( + package_dir: &Path, + base_dir: &Path, + slug: &str, + prefer_symlink: bool, +) -> Result { + let link_path = confined_skill_path(base_dir, slug)?; + + // If the path already resolves to the canonical package (for example + // ~/.claude/skills is itself a symlink into the packages dir), leave it. + if let (Ok(resolved), Ok(canonical_package)) = + (fs::canonicalize(&link_path), fs::canonicalize(package_dir)) + { + if resolved == canonical_package { + return Ok(LinkOutcome { + path: link_path, + strategy: "existing", + backup: None, + rollback: None, + }); + } + } + + fs::create_dir_all(base_dir).with_context(|| format!("create {}", base_dir.display()))?; + let (backup, rollback) = prepare_target_path(&link_path, package_dir)?; + + if prefer_symlink { + #[cfg(unix)] + { + match std::os::unix::fs::symlink(package_dir, &link_path) { + Ok(()) => { + return Ok(LinkOutcome { + path: link_path, + strategy: "symlink", + backup, + rollback, + }) + } + Err(err) => { + // Fall through to the copy strategy on filesystems that + // reject symlinks. + let _ = err; + } + } + } + } + + if let Err(error) = copy_dir_recursive(package_dir, &link_path) { + restore_target_path(&link_path, &backup, &rollback)?; + return Err(error).with_context(|| { + format!( + "link {} into {}", + package_dir.display(), + link_path.display() + ) + }); + } + Ok(LinkOutcome { + path: link_path, + strategy: "copy", + backup, + rollback, + }) +} + +/// Removes an existing symlink, or a bl-owned directory that replaced one, +/// returning any unmanaged skill backup created while clearing the path +/// (some tools like Cursor materialize symlinks into real directories). +/// Unmanaged paths are moved under the skills directory's `.backups` folder +/// before the target link is installed. +fn prepare_target_path( + path: &Path, + package_dir: &Path, +) -> Result<(Option, Option)> { + let Ok(metadata) = fs::symlink_metadata(path) else { + return Ok((None, None)); + }; + if metadata.file_type().is_symlink() { + // A metadata file alone is not proof of ownership: a user may link + // their own package which happens to contain one. Only replace a link + // when both paths resolve to the canonical package we are installing. + if matches!( + (fs::canonicalize(path), fs::canonicalize(package_dir)), + (Ok(target), Ok(package)) if target == package + ) { + fs::remove_file(path).with_context(|| format!("remove symlink {}", path.display()))?; + return Ok((None, None)); + } + return backup_unmanaged_path(path).map(|backup| (Some(backup), None)); + } + if metadata.is_dir() { + if path.join(META_FILE_NAME).is_file() { + let rollback = target_rollback_path(path); + fs::rename(path, &rollback) + .with_context(|| format!("prepare to replace {}", path.display()))?; + return Ok((None, Some(rollback))); + } + return backup_unmanaged_path(path).map(|backup| (Some(backup), None)); + } + backup_unmanaged_path(path).map(|backup| (Some(backup), None)) +} + +fn target_rollback_path(path: &Path) -> PathBuf { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("skill"); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + path.with_file_name(format!(".{name}.previous-{}-{nanos}", std::process::id())) +} + +fn restore_target_path( + path: &Path, + backup: &Option, + rollback: &Option, +) -> Result<()> { + remove_any(path).with_context(|| format!("remove failed target {}", path.display()))?; + if let Some(backup) = backup { + fs::rename(&backup.backup_path, &backup.source_path).with_context(|| { + format!( + "restore target backup {} to {} after link failure", + backup.backup_path.display(), + backup.source_path.display() + ) + })?; + } else if let Some(rollback) = rollback { + fs::rename(rollback, path).with_context(|| { + format!( + "restore previous target {} to {} after link failure", + rollback.display(), + path.display() + ) + })?; + } + Ok(()) +} + +pub fn rollback_link(outcome: &LinkOutcome) -> Result<()> { + if outcome.strategy == "existing" { + return Ok(()); + } + restore_target_path(&outcome.path, &outcome.backup, &outcome.rollback) +} + +pub fn finish_link(outcome: &LinkOutcome) { + if let Some(rollback) = &outcome.rollback { + let _ = remove_any(rollback); + } +} + +pub fn backup_unmanaged_path(path: &Path) -> Result { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("skill"); + let parent = path.parent().context("skill path has no parent")?; + let backup_root = parent.join(".backups"); + fs::create_dir_all(&backup_root) + .with_context(|| format!("create backup directory {}", backup_root.display()))?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock is before UNIX epoch")?; + let created_at = iso8601_utc(now.as_secs()); + let created_at = format!("{}Z", &created_at[..16]); + let timestamp = created_at.trim_end_matches('Z').replace(['-', ':'], ""); + let backup = available_backup_path(&backup_root, name, &format!("{timestamp}Z"))?; + fs::rename(path, &backup).with_context(|| { + format!( + "backup unmanaged skill {} to {}", + path.display(), + backup.display() + ) + })?; + Ok(BackupOutcome { + source_path: path.to_path_buf(), + backup_path: backup, + created_at, + }) +} + +fn available_backup_path(root: &Path, name: &str, timestamp: &str) -> Result { + for sequence in 1_u64.. { + let suffix = if sequence == 1 { + String::new() + } else { + format!("-{sequence}") + }; + let candidate = root.join(format!("{name}-{timestamp}{suffix}")); + match fs::symlink_metadata(&candidate) { + Ok(_) => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidate), + Err(error) => { + return Err(error) + .with_context(|| format!("inspect backup path {}", candidate.display())) + } + } + } + unreachable!("backup sequence is unbounded") +} + +/// Formats a UNIX timestamp as ISO-8601 UTC (`2026-06-10T12:34:56Z`) without +/// pulling in a date dependency. Uses Howard Hinnant's civil-date algorithm. +pub fn iso8601_utc(secs: u64) -> String { + let days = (secs / 86_400) as i64; + let secs_of_day = secs % 86_400; + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if month <= 2 { year + 1 } else { year }; + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + secs_of_day / 3600, + (secs_of_day % 3600) / 60, + secs_of_day % 60 + ) +} + +/// Forcefully removes whatever is at `path` (used by `remove +/// --include-unmanaged --force`). +pub fn remove_any(path: &Path) -> Result<()> { + let Ok(metadata) = fs::symlink_metadata(path) else { + return Ok(()); + }; + if metadata.file_type().is_symlink() || metadata.is_file() { + fs::remove_file(path).with_context(|| format!("remove {}", path.display())) + } else { + fs::remove_dir_all(path).with_context(|| format!("remove {}", path.display())) + } +} + +/// Reports the state of one expected target link. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LinkState { + Ok, + Missing, + Broken, + Unmanaged, +} + +pub fn inspect_link(link_path: &Path, package_dir: &Path) -> LinkState { + let Ok(metadata) = fs::symlink_metadata(link_path) else { + return LinkState::Missing; + }; + if metadata.file_type().is_symlink() { + match (fs::canonicalize(link_path), fs::canonicalize(package_dir)) { + (Ok(resolved), Ok(canonical)) if resolved == canonical => LinkState::Ok, + (Err(_), _) => LinkState::Broken, + _ => LinkState::Broken, + } + } else if metadata.is_dir() { + if link_path.join(META_FILE_NAME).is_file() { + LinkState::Ok + } else { + LinkState::Unmanaged + } + } else { + LinkState::Unmanaged + } +} + +pub fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir_all(destination).with_context(|| format!("create {}", destination.display()))?; + for entry in fs::read_dir(source).with_context(|| format!("read {}", source.display()))? { + let entry = entry.with_context(|| format!("read entry in {}", source.display()))?; + let file_type = entry + .file_type() + .with_context(|| format!("stat {}", entry.path().display()))?; + let to = destination.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&entry.path(), &to)?; + } else if file_type.is_file() { + fs::copy(entry.path(), &to).with_context(|| format!("copy {}", to.display()))?; + } else { + anyhow::bail!("refusing to copy special file {}", entry.path().display()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_registry_covers_default_targets() { + let registry = TargetRegistry::builtin(); + assert!(registry.targets.contains_key("agents")); + assert!(registry.targets.contains_key("claude")); + assert!(registry.targets.contains_key("codex")); + } + + #[test] + fn resolve_rejects_unknown_targets() { + let registry = TargetRegistry::builtin(); + let error = registry + .resolve(&["bogus".to_string()], Scope::Global) + .expect_err("unknown target should fail"); + assert!(error.to_string().contains("unknown target `bogus`")); + assert!(error.to_string().contains("agents")); + } + + #[test] + fn expand_path_resolves_home_prefix() { + let home = env::var("HOME").expect("HOME set in tests"); + assert_eq!( + expand_path("~/.claude/skills"), + PathBuf::from(home).join(".claude/skills") + ); + assert_eq!( + expand_path("./.claude/skills"), + PathBuf::from("./.claude/skills") + ); + } + + #[cfg(unix)] + #[test] + fn link_into_target_creates_and_replaces_symlinks() { + let temp = std::env::temp_dir().join(format!( + "bl-link-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let package = temp.join("packages/demo"); + fs::create_dir_all(&package).expect("create package"); + fs::write(package.join("SKILL.md"), "# Demo").expect("write skill"); + let base = temp.join("agent/skills"); + + let outcome = link_into_target(&package, &base, "demo", true).expect("link into target"); + assert_eq!(outcome.strategy, "symlink"); + assert!(base.join("demo/SKILL.md").is_file()); + assert_eq!(inspect_link(&base.join("demo"), &package), LinkState::Ok); + + // Re-linking replaces the existing symlink without error. + let outcome = link_into_target(&package, &base, "demo", true).expect("relink into target"); + assert_eq!(outcome.strategy, "existing"); + + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[cfg(unix)] + #[test] + fn link_into_target_backs_up_unmanaged_directories() { + let temp = std::env::temp_dir().join(format!( + "bl-link-unmanaged-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let package = temp.join("packages/demo"); + fs::create_dir_all(&package).expect("create package"); + let base = temp.join("agent/skills"); + fs::create_dir_all(base.join("demo")).expect("create unmanaged dir"); + fs::write(base.join("demo/user.md"), "mine").expect("write user file"); + + let outcome = + link_into_target(&package, &base, "demo", true).expect("link should replace conflict"); + assert_eq!(outcome.strategy, "symlink"); + assert!(base.join("demo").is_symlink()); + let backup = outcome.backup.expect("backup outcome"); + assert_eq!(backup.source_path, base.join("demo")); + assert_eq!( + backup.backup_path.parent(), + Some(base.join(".backups").as_path()) + ); + assert!(backup + .backup_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("demo-"))); + assert!(backup.created_at.ends_with('Z')); + assert!(backup.created_at.contains('T')); + assert_eq!(backup.created_at.matches(':').count(), 1); + let backups = fs::read_dir(base.join(".backups")) + .expect("read backup directory") + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .collect::>(); + assert_eq!(backups.len(), 1); + assert_eq!( + fs::read_to_string(backups[0].join("user.md")).expect("read backup"), + "mine" + ); + + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[cfg(unix)] + #[test] + fn link_into_target_preserves_unmanaged_and_broken_symlinks() { + let temp = std::env::temp_dir().join(format!( + "bl-link-symlink-conflicts-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let package = temp.join("packages/demo"); + let foreign = temp.join("packages/foreign"); + fs::create_dir_all(&package).expect("create package"); + fs::create_dir_all(&foreign).expect("create foreign package"); + fs::write(foreign.join(META_FILE_NAME), "{}").expect("write foreign marker"); + let base = temp.join("agent/skills"); + fs::create_dir_all(&base).expect("create target directory"); + + for (slug, target) in [ + ("manual", foreign.as_path()), + ("broken", Path::new("missing-package")), + ] { + let link = base.join(slug); + std::os::unix::fs::symlink(target, &link).expect("create conflict link"); + let original = fs::read_link(&link).expect("read original link"); + + let outcome = link_into_target(&package, &base, slug, true).expect("replace conflict"); + let backup = outcome.backup.expect("backup outcome"); + assert_eq!( + fs::read_link(&backup.backup_path).expect("read backed up link"), + original + ); + assert!(base.join(slug).is_symlink()); + } + + let relative = base.join("relative"); + std::os::unix::fs::symlink("../../packages/foreign", &relative) + .expect("create relative link"); + let outcome = + link_into_target(&package, &base, "relative", true).expect("replace relative link"); + let backup = outcome.backup.expect("relative link backup"); + assert_eq!( + fs::read_link(&backup.backup_path).expect("read relative backup"), + PathBuf::from("../../packages/foreign") + ); + + fs::remove_dir_all(temp).expect("cleanup"); + } + + #[test] + fn available_backup_path_adds_sequence_for_same_minute() { + let temp = std::env::temp_dir().join(format!( + "bl-backup-sequence-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(temp.join("demo-20260713T2248Z")).expect("create first backup"); + fs::create_dir_all(temp.join("demo-20260713T2248Z-2")).expect("create second backup"); + + let available = available_backup_path(&temp, "demo", "20260713T2248Z") + .expect("find available backup path"); + + assert_eq!(available, temp.join("demo-20260713T2248Z-3")); + fs::remove_dir_all(temp).expect("cleanup"); + } +} diff --git a/src/bl/workspace.rs b/src/bl/workspace.rs new file mode 100644 index 0000000..edf664d --- /dev/null +++ b/src/bl/workspace.rs @@ -0,0 +1,347 @@ +use std::fmt::Write as _; +use std::io::Write as _; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use builderlab_auth::auth_login::build_auth_http_client; +use builderlab_auth::auth_storage::{SessionCredentialStorage, StoredSessionCredential}; +use builderlab_auth::workspace::{ + list_workspaces, switch_workspace, ListWorkspacesResponse, Workspace, WorkspaceHttpError, +}; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use reqwest::header::HeaderValue; +use serde_json::json; + +use super::auth_storage::{ + default_session_storage, session_storage_key_from_config, SessionStorageKey, +}; +use super::display::{print_json, stdin_is_tty, terminal_safe_text}; +use super::runner; +use super::skills_api::{exit_codes, failure}; +use super::skills_config::{kgoose_service_url, SkillsConfig}; + +pub fn command() -> Command { + Command::new("workspace") + .about("Manage BuilderLab workspaces") + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand(Command::new("list").about("List accessible workspaces")) + .subcommand( + Command::new("switch") + .about("Switch the active workspace") + .long_about( + "Switch the active BuilderLab workspace. Without --workspace, \ + lists accessible workspaces and prompts for a selection.", + ) + .arg( + Arg::new("workspace") + .long("workspace") + .value_name("ID") + .help("Workspace identifier; skips interactive selection") + .action(ArgAction::Set), + ), + ) +} + +pub fn run(matches: &ArgMatches) -> Result<()> { + runner::run(matches, dispatch) +} + +fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + match matches.subcommand() { + Some(("list", _)) => run_list(config), + Some(("switch", switch_matches)) => run_switch(config, switch_matches), + _ => anyhow::bail!("expected a workspace subcommand"), + } +} + +pub fn describe_commands() -> serde_json::Value { + super::description::describe_command_tree(&command()) +} + +fn run_list(config: &SkillsConfig) -> Result<()> { + runner::ensure_org_configured(config)?; + let session = WorkspaceSession::load(config)?; + let response = session.list(config).map_err(map_workspace_request_error)?; + + if config.json { + return print_json(&response); + } + + print_workspace_list(config, &response); + Ok(()) +} + +fn run_switch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + runner::ensure_org_configured(config)?; + let session = WorkspaceSession::load(config)?; + let requested_workspace = matches + .get_one::("workspace") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + let workspace_identifier = match requested_workspace { + Some(identifier) => identifier.to_string(), + None if config.json => { + return Err(failure( + exit_codes::GENERAL, + "workspace_required", + "`bl workspace switch --json` requires --workspace ", + )) + } + None if !stdin_is_tty() => { + return Err(failure( + exit_codes::GENERAL, + "workspace_required", + "Non-interactive shell — pass --workspace ", + )) + } + None => { + let response = session.list(config).map_err(map_workspace_request_error)?; + prompt_for_workspace(config, &response)? + } + }; + + let response = session + .switch(config, &workspace_identifier) + .map_err(map_workspace_request_error)?; + let switched = if let Some(new_credential) = response.session_credential { + if new_credential.trim().is_empty() { + anyhow::bail!( + "/v1/workspaces/switch returned an empty replacement credential; run `bl auth login`" + ); + } + HeaderValue::from_str(&new_credential).context( + "/v1/workspaces/switch returned an invalid replacement credential; run `bl auth login`", + )?; + session + .storage + .set( + &session.storage_key, + &StoredSessionCredential { + session_credential: new_credential, + expires_at: session.credential.expires_at.clone(), + }, + ) + .context( + "store rotated workspace credential; the previous credential is invalid, so run `bl auth login` to recover", + )?; + true + } else { + false + }; + let workspace = response + .workspace + .context("/v1/workspaces/switch returned no workspace")?; + + if config.json { + return print_json(&json!({ + "workspace": workspace, + "switched": switched, + })); + } + + let display_name = workspace + .display_name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .unwrap_or("Unnamed workspace"); + let identifier = workspace + .workspace_identifier + .as_deref() + .unwrap_or(&workspace_identifier); + if switched { + config.style.success(&format!( + "Switched to {} ({})", + terminal_safe_text(display_name), + terminal_safe_text(identifier) + )); + } else { + config.style.info(&format!( + "{} ({}) is already active", + terminal_safe_text(display_name), + terminal_safe_text(identifier) + )); + } + Ok(()) +} + +struct WorkspaceSession { + storage: Box, + storage_key: SessionStorageKey, + credential: StoredSessionCredential, +} + +impl WorkspaceSession { + fn load(config: &SkillsConfig) -> Result { + let storage = default_session_storage(config)?; + let storage_key = session_storage_key_from_config(config); + let credential = storage.get(&storage_key)?.ok_or_else(auth_required_error)?; + if credential.session_credential_header_value().is_none() { + return Err(auth_required_error()); + } + Ok(Self { + storage, + storage_key, + credential, + }) + } + + fn list(&self, config: &SkillsConfig) -> Result { + let client = build_auth_http_client(Duration::from_secs(30))?; + list_workspaces( + &client, + config.playpen.as_deref(), + &kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path), + &self.credential, + ) + } + + fn switch( + &self, + config: &SkillsConfig, + workspace_identifier: &str, + ) -> Result { + let client = build_auth_http_client(Duration::from_secs(30))?; + switch_workspace( + &client, + config.playpen.as_deref(), + &kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path), + &self.credential, + workspace_identifier, + ) + } +} + +fn auth_required_error() -> anyhow::Error { + failure( + exit_codes::AUTH_REQUIRED, + "auth_required", + "BuilderLab CLI auth is required; run `bl auth login`", + ) +} + +fn map_workspace_request_error(error: anyhow::Error) -> anyhow::Error { + let Some(http_error) = error.downcast_ref::() else { + return error; + }; + match http_error.status { + 401 => auth_required_error(), + 403 => failure( + exit_codes::FORBIDDEN, + "forbidden", + format!("workspace request is forbidden: {}", http_error.body), + ), + _ => error, + } +} + +fn print_workspace_list(config: &SkillsConfig, response: &ListWorkspacesResponse) { + if response.workspaces.is_empty() { + println!("No workspaces found."); + return; + } + for workspace in &response.workspaces { + let active = workspace.workspace_identifier.as_deref() + == response.active_workspace_identifier.as_deref(); + println!("{}", workspace_line(config, workspace, active)); + } +} + +fn prompt_for_workspace( + config: &SkillsConfig, + response: &ListWorkspacesResponse, +) -> Result { + if response.workspaces.is_empty() { + anyhow::bail!("No workspaces are available to switch to"); + } + eprintln!("Select a workspace:"); + for (index, workspace) in response.workspaces.iter().enumerate() { + let active = workspace.workspace_identifier.as_deref() + == response.active_workspace_identifier.as_deref(); + eprintln!( + " {}) {}", + index + 1, + workspace_line(config, workspace, active) + ); + } + eprint!("Selection: "); + std::io::stderr() + .flush() + .context("flush workspace prompt")?; + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .context("read workspace selection")?; + select_workspace(&response.workspaces, &answer) +} + +fn select_workspace(workspaces: &[Workspace], answer: &str) -> Result { + let selection = answer + .trim() + .parse::() + .ok() + .filter(|selection| (1..=workspaces.len()).contains(selection)) + .ok_or_else(|| anyhow!("Enter a workspace number from 1 to {}", workspaces.len()))?; + workspaces[selection - 1] + .workspace_identifier + .clone() + .filter(|identifier| !identifier.trim().is_empty()) + .context("Selected workspace has no identifier") +} + +fn workspace_line(config: &SkillsConfig, workspace: &Workspace, active: bool) -> String { + let display_name = workspace + .display_name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .unwrap_or("Unnamed workspace"); + let identifier = workspace + .workspace_identifier + .as_deref() + .unwrap_or("unknown"); + let mut line = format!( + "{} {}", + terminal_safe_text(display_name), + config.style.dim(&terminal_safe_text(identifier)) + ); + if active { + write!(line, " {}", config.style.green("(active)")) + .expect("writing to a String cannot fail"); + } + line +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workspace(identifier: Option<&str>) -> Workspace { + Workspace { + workspace_identifier: identifier.map(str::to_string), + display_name: Some("Test".to_string()), + roles: vec![], + } + } + + #[test] + fn selection_resolves_one_based_workspace_number() { + let workspaces = vec![ + workspace(Some("workspace-one")), + workspace(Some("workspace-two")), + ]; + + assert_eq!( + select_workspace(&workspaces, "2\n").expect("selection"), + "workspace-two" + ); + } + + #[test] + fn selection_rejects_invalid_number_and_missing_identifier() { + let workspaces = vec![workspace(Some("workspace-one")), workspace(None)]; + + assert!(select_workspace(&workspaces, "3").is_err()); + assert!(select_workspace(&workspaces, "2").is_err()); + } +} diff --git a/src/catalog.rs b/src/catalog.rs new file mode 100644 index 0000000..0157099 --- /dev/null +++ b/src/catalog.rs @@ -0,0 +1,179 @@ +use std::env; +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::runtime::{compact_text, ExtensionSummary}; + +pub const EXTENSIONS_CATALOG_ENV_VAR: &str = "KGOOSE_EXTENSIONS_CATALOG"; + +const DEFAULT_EXTENSIONS_CATALOG: &str = include_str!("../extensions.yaml"); +const GENERATED_HEADER: &str = + "# Generated via `just update-extensions-catalog`, then curated manually.\n"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ExtensionCatalogEntry { + name: String, + #[serde(default)] + about: String, +} + +pub fn load_extensions_catalog() -> Result> { + match env::var(EXTENSIONS_CATALOG_ENV_VAR) { + Ok(path) => load_extensions_catalog_from_path(path), + Err(env::VarError::NotPresent) => parse_extensions_catalog(DEFAULT_EXTENSIONS_CATALOG), + Err(err) => anyhow::bail!("failed to read {EXTENSIONS_CATALOG_ENV_VAR}: {err}"), + } +} + +pub fn load_extensions_catalog_from_path(path: impl AsRef) -> Result> { + let path = path.as_ref(); + let source = fs::read_to_string(path) + .with_context(|| format!("read extensions catalog {}", path.display()))?; + + parse_extensions_catalog(&source) + .with_context(|| format!("parse extensions catalog {}", path.display())) +} + +pub fn write_extensions_catalog( + path: impl AsRef, + extensions: &[ExtensionSummary], +) -> Result<()> { + let path = path.as_ref(); + let rendered = render_extensions_catalog(extensions)?; + fs::write(path, rendered) + .with_context(|| format!("write extensions catalog {}", path.display())) +} + +fn parse_extensions_catalog(source: &str) -> Result> { + let entries = serde_yaml::from_str::>(source) + .context("parse extensions catalog YAML")?; + + Ok(normalize_entries(entries)) +} + +fn render_extensions_catalog(extensions: &[ExtensionSummary]) -> Result { + let entries = normalize_entries( + extensions + .iter() + .map(|extension| ExtensionCatalogEntry { + name: extension.name.clone(), + about: extension.about.clone(), + }) + .collect(), + ); + let yaml = serde_yaml::to_string(&entries).context("serialize extensions catalog YAML")?; + + Ok(format!("{GENERATED_HEADER}{yaml}")) +} + +fn normalize_entries(entries: Vec) -> Vec { + let mut normalized = entries + .into_iter() + .filter_map(|entry| { + let name = entry.name.trim().to_string(); + if name.is_empty() { + return None; + } + + Some(ExtensionSummary { + about: extension_about(&name, &entry.about), + name, + }) + }) + .collect::>(); + normalized.sort_by(|left, right| left.name.cmp(&right.name)); + normalized.dedup_by(|left, right| left.name == right.name); + normalized +} + +fn extension_about(name: &str, about: &str) -> String { + let about = about.trim().trim_start_matches('#').trim(); + if about.is_empty() { + format!("{name} tools") + } else { + compact_text(about) + } +} + +#[cfg(test)] +mod tests { + use super::{load_extensions_catalog_from_path, render_extensions_catalog}; + use crate::runtime::ExtensionSummary; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn embedded_extensions_catalog_is_valid_yaml() { + let extensions = super::parse_extensions_catalog(super::DEFAULT_EXTENSIONS_CATALOG) + .expect("parse embedded catalog"); + + assert!(!extensions.is_empty()); + } + + #[test] + fn render_extensions_catalog_sorts_and_normalizes_entries() { + let rendered = render_extensions_catalog(&[ + ExtensionSummary { + name: " utils ".to_string(), + about: " Utility helpers ".to_string(), + }, + ExtensionSummary { + name: "slack".to_string(), + about: String::new(), + }, + ]) + .expect("render catalog"); + + assert!(rendered.starts_with("# Generated via `just update-extensions-catalog`")); + assert!(rendered.contains("name: slack")); + assert!(rendered.contains("about: slack tools")); + assert!(rendered.contains("name: utils")); + assert!(rendered.contains("about: Utility helpers")); + } + + #[test] + fn load_extensions_catalog_from_path_sorts_and_deduplicates_entries() { + let path = temp_catalog_path("catalog-load"); + fs::write( + &path, + r#" +- name: slack + about: Slack tools +- name: utils + about: Utility helpers +- name: slack + about: Duplicate +"#, + ) + .expect("write catalog"); + + let extensions = load_extensions_catalog_from_path(&path).expect("load catalog"); + fs::remove_file(&path).expect("remove catalog"); + + assert_eq!( + extensions, + vec![ + ExtensionSummary { + name: "slack".to_string(), + about: "Slack tools".to_string(), + }, + ExtensionSummary { + name: "utils".to_string(), + about: "Utility helpers".to_string(), + }, + ] + ); + } + + fn temp_catalog_path(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + std::env::temp_dir().join(format!("{prefix}-{unique}.yaml")) + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..7430a1d --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,957 @@ +use std::env; + +use anyhow::{Context, Result}; +use clap::builder::{PossibleValuesParser, ValueHint}; +use clap::{Arg, ArgAction, ArgGroup, Command}; +use serde_json::Value; + +use crate::bl::skills_config::{ + normalize_kgoose_service_path, DEFAULT_KGOOSE_SERVICE_PATH, KGOOSE_SERVICE_PATH_ENV_VAR, +}; +use crate::kgoose::{DEFAULT_KGOOSE_BASE_URL, DEFAULT_KGOOSE_TIMEOUT_SECS}; +use crate::runtime::{LoadedExtension, ParameterKind, RuntimeTool, ScalarKind, ToolParameter}; + +pub const ROOT_COMMAND_NAME: &str = "agent-tools"; +pub const ROOT_BIN_NAME: &str = "sq agent-tools"; +pub const ROOT_SUMMARY: &str = "Discover auth-backed tool extensions exposed through kGoose"; +pub const TOOLS_COMMAND_NAME: &str = "tools"; +pub const BL_TOOLS_BIN_NAME: &str = "bl tools"; +pub const APPKIT_COMMAND_NAME: &str = "appkit"; +pub const APPKIT_COMMAND_ABOUT: &str = "Cloudflare-backed internal Block App Kit CLI (local exec)"; +pub const APPKIT_COMMAND_LONG_ABOUT: &str = + "Proxies to the Cloudflare-backed internal appkit CLI. This is separate from the external \ + BuilderLab Apps Platform control plane exposed at root `bl apps`.\n\ + Requires appkit on PATH, or uvx to run mcp_block_app_kit on demand."; +pub const EXTENSION_DESCRIBE_COMMAND_NAME: &str = "describe"; +pub const EXTENSION_DESCRIBE_COMMAND_ABOUT: &str = + "Print the full extension description/instructions."; +const IS_BLOX_ENV_VAR: &str = "IS_BLOX"; +const BLOX_ENVIRONMENT_ENV_VAR: &str = "BLOX_ENVIRONMENT"; +const BLOX_ENVIRONMENT_STAGING: &str = "staging"; +const BLOX_ENVIRONMENT_PRODUCTION: &str = "production"; +const BLOX_STAGING_BASE_URL: &str = "http://kgoose.cashappservicesstaging.com"; +const BLOX_PRODUCTION_BASE_URL: &str = "http://kgoose.cashappservices.com"; + +#[derive(Debug, Clone, PartialEq)] +pub struct BootstrapArgs { + pub base_url: String, + pub service_path: String, + pub playpen: Option, + pub goosemcp_playpen: Option, + pub timeout_secs: f64, + pub command_tokens: Vec, + pub write_extensions: Option, + pub describe_commands: bool, + pub summary_only: bool, + pub version_only: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GlobalValueFlag { + BaseUrl, + ServicePath, + Playpen, + GoosemcpPlaypen, + Timeout, + WriteExtensions, +} + +impl GlobalValueFlag { + fn from_name(name: &str) -> Option { + match name { + "--base-url" => Some(Self::BaseUrl), + "--kgoose-service-path" => Some(Self::ServicePath), + "--playpen" => Some(Self::Playpen), + "--goosemcp-playpen" => Some(Self::GoosemcpPlaypen), + "--timeout" => Some(Self::Timeout), + "--write-extensions" => Some(Self::WriteExtensions), + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Self::BaseUrl => "--base-url", + Self::ServicePath => "--kgoose-service-path", + Self::Playpen => "--playpen", + Self::GoosemcpPlaypen => "--goosemcp-playpen", + Self::Timeout => "--timeout", + Self::WriteExtensions => "--write-extensions", + } + } +} + +pub fn global_arg_skip_count(arg: &str) -> usize { + if GlobalValueFlag::from_name(arg).is_some() { + 2 + } else if split_global_value_assignment(arg).is_some() || is_global_switch_flag(arg) { + 1 + } else { + 0 + } +} + +fn split_global_value_assignment(arg: &str) -> Option<(GlobalValueFlag, &str)> { + let (name, value) = arg.split_once('=')?; + GlobalValueFlag::from_name(name).map(|flag| (flag, value)) +} + +fn is_global_switch_flag(arg: &str) -> bool { + matches!( + arg, + "--describe-commands" | "--summary" | "--version" | "-V" + ) +} + +pub fn bootstrap_args(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let env_base_url = read_optional_env("KGOOSE_BASE_URL")?; + let mut values = BootstrapValueState::from_env()?; + + let mut command_tokens = Vec::new(); + let mut describe_commands = false; + let mut summary_only = false; + let mut version_only = false; + let mut args = args.into_iter().map(Into::into).peekable(); + + while let Some(arg) = args.next() { + if let Some(flag) = GlobalValueFlag::from_name(arg.as_str()) { + let value = args + .next() + .with_context(|| format!("missing value for {}", flag.name()))?; + apply_global_value_flag(flag, value, &mut values)?; + continue; + } + + if let Some((flag, value)) = split_global_value_assignment(&arg) { + apply_global_value_flag(flag, value.to_string(), &mut values)?; + continue; + } + + match arg.as_str() { + "--describe-commands" => describe_commands = true, + "--summary" => summary_only = true, + "--version" | "-V" => version_only = true, + "--" => { + command_tokens.extend(args); + break; + } + _ => command_tokens.push(arg), + } + } + + Ok(BootstrapArgs { + base_url: resolve_base_url( + env_base_url.as_deref(), + values.cli_base_url.as_deref(), + read_optional_env(IS_BLOX_ENV_VAR)?.as_deref(), + read_optional_env(BLOX_ENVIRONMENT_ENV_VAR)?.as_deref(), + ), + service_path: values.service_path, + playpen: values.playpen, + goosemcp_playpen: values.goosemcp_playpen, + timeout_secs: values.timeout_secs, + command_tokens, + write_extensions: values.write_extensions, + describe_commands, + summary_only, + version_only, + }) +} + +fn read_optional_env(name: &str) -> Result> { + match env::var(name) { + Ok(value) => Ok(Some(value)), + Err(env::VarError::NotPresent) => Ok(None), + Err(err) => anyhow::bail!("failed to read {name}: {err}"), + } +} + +#[derive(Debug)] +struct BootstrapValueState { + cli_base_url: Option, + service_path: String, + playpen: Option, + goosemcp_playpen: Option, + timeout_secs: f64, + write_extensions: Option, +} + +impl BootstrapValueState { + fn from_env() -> Result { + let service_path = read_optional_env(KGOOSE_SERVICE_PATH_ENV_VAR)? + .map(|value| normalize_kgoose_service_path(&value)) + .transpose()? + .unwrap_or_else(|| DEFAULT_KGOOSE_SERVICE_PATH.to_string()); + let playpen = match env::var("KGOOSE_PLAYPEN") { + Ok(value) => Some(value), + Err(env::VarError::NotPresent) => None, + Err(err) => anyhow::bail!("failed to read KGOOSE_PLAYPEN: {err}"), + }; + let goosemcp_playpen = match env::var("GOOSEMCP_PLAYPEN") { + Ok(value) => Some(value), + Err(env::VarError::NotPresent) => None, + Err(err) => anyhow::bail!("failed to read GOOSEMCP_PLAYPEN: {err}"), + }; + let timeout_secs = match env::var("KGOOSE_TIMEOUT") { + Ok(value) => parse_timeout(&value).context("parse KGOOSE_TIMEOUT")?, + Err(env::VarError::NotPresent) => DEFAULT_KGOOSE_TIMEOUT_SECS, + Err(err) => anyhow::bail!("failed to read KGOOSE_TIMEOUT: {err}"), + }; + + Ok(Self { + cli_base_url: None, + service_path, + playpen, + goosemcp_playpen, + timeout_secs, + write_extensions: None, + }) + } +} + +fn apply_global_value_flag( + flag: GlobalValueFlag, + value: String, + values: &mut BootstrapValueState, +) -> Result<()> { + match flag { + GlobalValueFlag::BaseUrl => values.cli_base_url = Some(value), + GlobalValueFlag::ServicePath => { + values.service_path = normalize_kgoose_service_path(&value)? + } + GlobalValueFlag::Playpen => values.playpen = Some(value), + GlobalValueFlag::GoosemcpPlaypen => values.goosemcp_playpen = Some(value), + GlobalValueFlag::Timeout => values.timeout_secs = parse_timeout(&value)?, + GlobalValueFlag::WriteExtensions => values.write_extensions = Some(value), + } + + Ok(()) +} + +fn resolve_base_url( + kgoose_base_url: Option<&str>, + cli_base_url: Option<&str>, + is_blox: Option<&str>, + blox_environment: Option<&str>, +) -> String { + if let Some(base_url) = kgoose_base_url { + return base_url.to_string(); + } + + if let Some(base_url) = cli_base_url { + return base_url.to_string(); + } + + if is_blox == Some("true") { + match blox_environment { + Some(BLOX_ENVIRONMENT_STAGING) => return BLOX_STAGING_BASE_URL.to_string(), + Some(BLOX_ENVIRONMENT_PRODUCTION) => return BLOX_PRODUCTION_BASE_URL.to_string(), + _ => {} + } + } + + DEFAULT_KGOOSE_BASE_URL.to_string() +} + +#[cfg(test)] +pub fn build_command( + extensions: &[crate::runtime::ExtensionSummary], + loaded_extension: Option<&LoadedExtension>, +) -> Command { + build_tools_command( + ToolCommandConfig { + command_name: ROOT_COMMAND_NAME, + bin_name: ROOT_BIN_NAME, + example: "sq agent-tools utils calculate --numbers 2 3 --operation add", + }, + extensions, + loaded_extension, + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ToolCommandConfig { + pub command_name: &'static str, + pub bin_name: &'static str, + pub example: &'static str, +} + +pub fn build_tools_command( + config: ToolCommandConfig, + extensions: &[crate::runtime::ExtensionSummary], + loaded_extension: Option<&LoadedExtension>, +) -> Command { + let mut command = Command::new(config.command_name) + .bin_name(config.bin_name) + .version(env!("CARGO_PKG_VERSION")) + .about(ROOT_SUMMARY) + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand_help_heading("Extensions") + .after_help(leak_str(format!("Examples:\n {}", config.example))) + .after_long_help( + "Environment:\n KGOOSE_BASE_URL\n KGOOSE_SERVICE_PATH\n KGOOSE_PLAYPEN\n GOOSEMCP_PLAYPEN\n KGOOSE_TIMEOUT\n STS_ACCESS_TOKEN", + ); + + for arg in global_args() { + command = command.arg(arg); + } + + command = command.subcommand(appkit_command()); + + for extension in extensions + .iter() + .filter(|extension| extension.name != APPKIT_COMMAND_NAME) + { + let mut subcommand = Command::new(leak_str(extension.name.clone())) + .about(leak_str(extension.about.clone())) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand_help_heading("Commands"); + + if let Some(loaded) = loaded_extension.filter(|loaded| loaded.name == extension.name) { + subcommand = subcommand + .long_about(leak_str(extension_help_preview(&loaded.description))) + .subcommand(extension_describe_command()); + for tool in &loaded.tools { + subcommand = subcommand.subcommand(build_tool_command(tool)); + } + } + + command = command.subcommand(subcommand); + } + + command +} + +fn appkit_command() -> Command { + Command::new(APPKIT_COMMAND_NAME) + .about(APPKIT_COMMAND_ABOUT) + .long_about(APPKIT_COMMAND_LONG_ABOUT) + .arg_required_else_help(true) + .disable_help_subcommand(true) +} + +fn extension_describe_command() -> Command { + Command::new(EXTENSION_DESCRIBE_COMMAND_NAME).about(EXTENSION_DESCRIBE_COMMAND_ABOUT) +} + +fn global_args() -> Vec { + vec![ + Arg::new("base-url") + .long("base-url") + .global(true) + // Hidden because `sq` help/discovery cannot honor dynamic target selection. + .hide(true) + .env("KGOOSE_BASE_URL") + .value_name("URL") + .help("Base URL for the kgoose service. [default: prod, use https://kgoose.stage.sqprod.co for staging]") + .value_hint(ValueHint::Url), + Arg::new("playpen") + .long("playpen") + .global(true) + // Hidden because `sq` help/discovery cannot honor dynamic target selection. + .hide(true) + .env("KGOOSE_PLAYPEN") + .value_name("NAME") + .help("Route the kgoose service with `Baggage: kgoose-playpen=`."), + Arg::new("kgoose-service-path") + .long("kgoose-service-path") + .global(true) + .hide(true) + .env(KGOOSE_SERVICE_PATH_ENV_VAR) + .value_name("PATH") + .help("Path prefix for kgoose endpoints. [default: /cash-app/goose]"), + Arg::new("goosemcp-playpen") + .long("goosemcp-playpen") + .global(true) + // Hidden because `sq` help/discovery cannot honor dynamic target selection. + .hide(true) + .env("GOOSEMCP_PLAYPEN") + .value_name("NAME") + .help( + "Route the downstream goosemcp Envoy with `Baggage: envoy-route--goosemcp=playpen-`. Only set when a matching playpen pod exists.", + ), + Arg::new("timeout") + .long("timeout") + .global(true) + .env("KGOOSE_TIMEOUT") + .value_name("SECONDS") + .value_parser(parse_timeout) + .help("HTTP request timeout in seconds."), + ] +} + +fn build_tool_command(tool: &RuntimeTool) -> Command { + let mut command = Command::new(leak_str(tool.cli_name.clone())) + .about(leak_str(tool.about.clone())) + .long_about(leak_str(tool.description.clone())) + .disable_help_subcommand(true) + .after_help("Use `--json '{...}'` for nested object or array payloads."); + + if tool.kgoose_name != tool.cli_name { + command = command.alias(leak_str(tool.kgoose_name.clone())); + } + + let mut json_arg = Arg::new("json") + .long("json") + .value_name("JSON") + .help("Provide the entire tool payload as a JSON object.") + .help_heading("Command options") + .allow_hyphen_values(true); + + command = command.arg( + Arg::new("raw") + .long("raw") + .action(ArgAction::SetTrue) + .help("Print the full CallToolResponse envelope as JSON.") + .help_heading("Command options"), + ); + + command = command.arg( + Arg::new("header") + .long("header") + .action(ArgAction::Append) + .num_args(1) + .value_name("KEY=VALUE") + .help("Forward a header to CallToolRequest.headers. Repeatable.") + .help_heading("Command options") + .allow_hyphen_values(true), + ); + + let mut groups = Vec::new(); + + for parameter in &tool.parameters { + for arg in build_parameter_args(parameter) { + let arg_id = leak_str(arg.get_id().to_string()); + json_arg = json_arg.conflicts_with(arg_id); + command = command.arg(arg); + } + + if let Some(group) = build_parameter_group(parameter) { + groups.push(group); + } + } + + command = command.arg(json_arg); + for group in groups { + command = command.group(group); + } + + command +} + +fn build_parameter_args(parameter: &ToolParameter) -> Vec { + match ¶meter.kind { + ParameterKind::Scalar(ScalarKind::Boolean) => build_boolean_args(parameter), + ParameterKind::Scalar(kind) => vec![apply_enum_values( + configure_value_parsing( + base_value_arg(parameter, scalar_value_name(kind)).required(parameter.required), + parameter, + ), + kind, + )], + ParameterKind::Array(kind) => vec![apply_enum_values( + configure_value_parsing( + base_value_arg(parameter, scalar_value_name(kind)) + .required(parameter.required) + .action(ArgAction::Append) + .num_args(1..), + parameter, + ), + kind, + )], + ParameterKind::Json => vec![base_value_arg(parameter, "JSON") + .required(parameter.required) + .allow_hyphen_values(true)], + } +} + +fn build_boolean_args(parameter: &ToolParameter) -> Vec { + let default_true = parameter.default.as_ref().and_then(Value::as_bool) == Some(true); + vec![ + bool_arg(parameter, true, default_true), + bool_arg(parameter, false, default_true), + ] +} + +fn build_parameter_group(parameter: &ToolParameter) -> Option { + if matches!(parameter.kind, ParameterKind::Scalar(ScalarKind::Boolean)) && parameter.required { + Some( + ArgGroup::new(leak_str(bool_group_id(parameter))) + .args([ + leak_str(bool_true_id(parameter)), + leak_str(bool_false_id(parameter)), + ]) + .required(true), + ) + } else { + None + } +} + +fn base_value_arg(parameter: &ToolParameter, value_name: &'static str) -> Arg { + Arg::new(leak_str(parameter_id(parameter))) + .long(leak_str(parameter.cli_name.clone())) + .value_name(value_name) + .help(leak_str(parameter_help(parameter))) + .help_heading("Tool options") + .allow_hyphen_values(allow_hyphen_values(parameter)) +} + +fn bool_arg(parameter: &ToolParameter, positive: bool, default_true: bool) -> Arg { + let (id, long, help) = if positive { + ( + bool_true_id(parameter), + parameter.cli_name.clone(), + boolean_help(parameter, default_true, true), + ) + } else { + ( + bool_false_id(parameter), + format!("no-{}", parameter.cli_name), + boolean_help(parameter, default_true, false), + ) + }; + + let mut arg = Arg::new(leak_str(id)) + .long(leak_str(long)) + .action(ArgAction::SetTrue) + .help(leak_str(help)) + .help_heading("Tool options") + .conflicts_with("json"); + + arg = arg.conflicts_with(leak_str(if positive { + bool_false_id(parameter) + } else { + bool_true_id(parameter) + })); + + arg +} + +fn boolean_help(parameter: &ToolParameter, default_true: bool, positive: bool) -> String { + let mut parts = Vec::new(); + if parameter.required { + parts.push("Required.".to_string()); + } else if default_true { + parts.push("Default: true.".to_string()); + } + + if let Some(description) = parameter.description.as_deref() { + let summary = crate::runtime::compact_text(description); + if !summary.is_empty() { + parts.push(if positive { + summary + } else { + format!("Disable: {summary}") + }); + } + } else if !positive { + parts.push(format!("Disable `{}`.", parameter.cli_name)); + } + + parts.join(" ") +} + +fn parameter_help(parameter: &ToolParameter) -> String { + let mut parts = Vec::new(); + if parameter.required { + parts.push("Required.".to_string()); + } + if let Some(default) = parameter.default.as_ref() { + parts.push(format!("Default: {}.", json_preview(default))); + } + if let Some(description) = parameter.description.as_deref() { + let summary = crate::runtime::compact_text(description); + if !summary.is_empty() { + parts.push(summary); + } + } + parts.join(" ") +} + +fn allow_hyphen_values(parameter: &ToolParameter) -> bool { + matches!(parameter.kind, ParameterKind::Json) +} + +fn configure_value_parsing(arg: Arg, parameter: &ToolParameter) -> Arg { + match parameter.kind { + ParameterKind::Scalar(ScalarKind::Integer { .. }) + | ParameterKind::Scalar(ScalarKind::Number { .. }) + | ParameterKind::Array(ScalarKind::Integer { .. }) + | ParameterKind::Array(ScalarKind::Number { .. }) => arg.allow_negative_numbers(true), + _ => arg, + } +} + +fn apply_enum_values(arg: Arg, kind: &ScalarKind) -> Arg { + let values = match kind { + ScalarKind::String { enum_values, .. } + | ScalarKind::Integer { enum_values } + | ScalarKind::Number { enum_values } => enum_values + .iter() + .map(json_preview) + .map(leak_str) + .collect::>(), + ScalarKind::Boolean => return arg, + }; + + if values.is_empty() { + arg + } else { + arg.value_parser(PossibleValuesParser::new(values)) + } +} + +fn scalar_value_name(kind: &ScalarKind) -> &'static str { + match kind { + ScalarKind::String { .. } => "TEXT", + ScalarKind::Integer { .. } => "INTEGER", + ScalarKind::Number { .. } => "NUMBER", + ScalarKind::Boolean => "BOOL", + } +} + +pub fn parameter_id(parameter: &ToolParameter) -> String { + format!("param:{}", parameter.name) +} + +pub fn bool_true_id(parameter: &ToolParameter) -> String { + format!("param:{}:true", parameter.name) +} + +pub fn bool_false_id(parameter: &ToolParameter) -> String { + format!("param:{}:false", parameter.name) +} + +fn bool_group_id(parameter: &ToolParameter) -> String { + format!("param:{}:choice", parameter.name) +} + +pub fn parse_timeout(value: &str) -> Result { + let timeout_secs = value + .parse::() + .with_context(|| format!("invalid timeout `{value}`"))?; + + if timeout_secs <= 0.0 { + anyhow::bail!("timeout must be greater than 0 seconds"); + } + + Ok(timeout_secs) +} + +fn json_preview(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + _ => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), + } +} + +fn extension_help_preview(description: &str) -> String { + let preview = crate::runtime::compact_text_with_limit(description, 120); + + format!("{preview}\n\nUse `describe` to print the full extension description.") +} + +fn leak_str(value: String) -> &'static str { + Box::leak(value.into_boxed_str()) +} + +#[cfg(test)] +mod tests { + use super::{ + bootstrap_args, build_command, extension_help_preview, resolve_base_url, + BLOX_ENVIRONMENT_PRODUCTION, BLOX_ENVIRONMENT_STAGING, BLOX_PRODUCTION_BASE_URL, + BLOX_STAGING_BASE_URL, EXTENSION_DESCRIBE_COMMAND_ABOUT, EXTENSION_DESCRIBE_COMMAND_NAME, + }; + use crate::bl::skills_config::DEFAULT_KGOOSE_SERVICE_PATH; + use crate::kgoose::DEFAULT_KGOOSE_BASE_URL; + use crate::runtime::{ + ExtensionSummary, LoadedExtension, ParameterKind, RuntimeTool, ScalarKind, ToolParameter, + }; + + #[test] + fn bootstrap_args_collects_kgoose_flags_without_consuming_command_tokens() { + let parsed = bootstrap_args([ + "--base-url", + "http://127.0.0.1:8080", + "utils", + "--playpen", + "baxen", + "calculate", + "--numbers", + "2", + "3", + "--timeout=12.5", + ]) + .expect("bootstrap args"); + + assert_eq!(parsed.base_url, "http://127.0.0.1:8080"); + assert_eq!(parsed.service_path, DEFAULT_KGOOSE_SERVICE_PATH); + assert_eq!(parsed.playpen.as_deref(), Some("baxen")); + assert_eq!(parsed.timeout_secs, 12.5); + assert!(!parsed.describe_commands); + assert!(!parsed.summary_only); + assert_eq!(parsed.write_extensions, None); + assert_eq!( + parsed.command_tokens, + vec!["utils", "calculate", "--numbers", "2", "3",] + ); + } + + #[test] + fn bootstrap_args_parses_write_extensions_flag() { + let parsed = + bootstrap_args(["--write-extensions", "extensions.yaml"]).expect("bootstrap args"); + + assert_eq!(parsed.write_extensions.as_deref(), Some("extensions.yaml")); + assert!(parsed.command_tokens.is_empty()); + } + + #[test] + fn bootstrap_args_recognizes_sq_exoskeleton_flags() { + let parsed = + bootstrap_args(["utils", "--describe-commands", "--summary"]).expect("bootstrap args"); + + assert_eq!(parsed.base_url, DEFAULT_KGOOSE_BASE_URL); + assert_eq!(parsed.service_path, DEFAULT_KGOOSE_SERVICE_PATH); + assert!(parsed.describe_commands); + assert!(parsed.summary_only); + assert_eq!(parsed.command_tokens, vec!["utils"]); + } + + #[test] + fn bootstrap_args_defaults_to_prod_base_url() { + let parsed = bootstrap_args(["utils", "calculate"]).expect("bootstrap args"); + + assert_eq!(parsed.base_url, DEFAULT_KGOOSE_BASE_URL); + assert_eq!(parsed.service_path, DEFAULT_KGOOSE_SERVICE_PATH); + assert_eq!(parsed.command_tokens, vec!["utils", "calculate"]); + } + + #[test] + fn bootstrap_args_accepts_base_url_override() { + let parsed = bootstrap_args(["--base-url", "http://127.0.0.1:8080", "utils", "calculate"]) + .expect("bootstrap args"); + + assert_eq!(parsed.base_url, "http://127.0.0.1:8080"); + assert_eq!(parsed.command_tokens, vec!["utils", "calculate"]); + } + + #[test] + fn bootstrap_args_accepts_service_path_override() { + let parsed = bootstrap_args([ + "--kgoose-service-path", + "cash-app/goose-square/", + "utils", + "calculate", + ]) + .expect("bootstrap args"); + + assert_eq!(parsed.service_path, "/cash-app/goose-square"); + assert_eq!(parsed.command_tokens, vec!["utils", "calculate"]); + } + + #[test] + fn bootstrap_args_accepts_playpen_with_custom_base_url() { + let parsed = bootstrap_args([ + "--base-url", + "https://kgoose.sqprod.co", + "--playpen", + "baxen", + "utils", + ]) + .expect("bootstrap args"); + + assert_eq!(parsed.base_url, "https://kgoose.sqprod.co"); + assert_eq!(parsed.playpen.as_deref(), Some("baxen")); + assert_eq!(parsed.command_tokens, vec!["utils"]); + } + + #[test] + fn resolve_base_url_prefers_explicit_kgoose_base_url() { + let resolved = resolve_base_url( + Some("https://explicit.example.test"), + Some("https://ignored.example.test"), + Some("true"), + Some(BLOX_ENVIRONMENT_PRODUCTION), + ); + + assert_eq!(resolved, "https://explicit.example.test"); + } + + #[test] + fn resolve_base_url_uses_blox_staging_host() { + let resolved = resolve_base_url(None, None, Some("true"), Some(BLOX_ENVIRONMENT_STAGING)); + + assert_eq!(resolved, BLOX_STAGING_BASE_URL); + } + + #[test] + fn resolve_base_url_uses_blox_production_host() { + let resolved = + resolve_base_url(None, None, Some("true"), Some(BLOX_ENVIRONMENT_PRODUCTION)); + + assert_eq!(resolved, BLOX_PRODUCTION_BASE_URL); + } + + #[test] + fn resolve_base_url_defaults_when_not_in_blox() { + let resolved = resolve_base_url(None, None, Some("false"), Some(BLOX_ENVIRONMENT_STAGING)); + + assert_eq!(resolved, DEFAULT_KGOOSE_BASE_URL); + } + + #[test] + fn resolve_base_url_uses_cli_override_when_env_is_absent() { + let resolved = resolve_base_url( + None, + Some("https://cli.example.test"), + Some("true"), + Some(BLOX_ENVIRONMENT_STAGING), + ); + + assert_eq!(resolved, "https://cli.example.test"); + } + + #[test] + fn build_command_lists_extensions_and_tools() { + let mut command = build_command( + &[ExtensionSummary { + name: "utils".to_string(), + about: "Utility helpers".to_string(), + }], + Some(&LoadedExtension { + name: "utils".to_string(), + about: "Utility helpers".to_string(), + description: "Utility helpers".to_string(), + tools: vec![RuntimeTool { + extension_name: "utils".to_string(), + kgoose_name: "calculate".to_string(), + cli_name: "calculate".to_string(), + about: "Perform math".to_string(), + description: "Perform math".to_string(), + parameters: vec![ToolParameter { + name: "numbers".to_string(), + cli_name: "numbers".to_string(), + required: true, + description: Some("Numbers to add".to_string()), + kind: ParameterKind::Array(ScalarKind::Number { + enum_values: Vec::new(), + }), + default: None, + }], + }], + }), + ); + + let help = command.render_long_help().to_string(); + assert!(help.contains("appkit")); + assert!(help.contains("Block App Kit CLI (local exec)")); + assert!(help.contains("utils")); + assert!(help.contains("Extensions")); + assert!(help.contains("--timeout")); + assert!(!help.contains("--base-url")); + assert!(!help.contains("--playpen")); + } + + #[test] + fn extension_help_preview_truncates_and_mentions_describe() { + let description = format!( + "{} {}\n\n{}", + "Slack tools for chat.", + "Use this extension to search channels, read threads, and post messages.".repeat(20), + "This second paragraph should only appear in --describe output." + ); + + let preview = extension_help_preview(&description); + + assert!(preview.contains("Use `describe` to print the full extension description.")); + assert!(preview.contains("Slack tools for chat.")); + assert!(preview.contains("...")); + assert!(!preview.contains("This second paragraph should only appear in --describe output.")); + assert!(preview.ends_with("Use `describe` to print the full extension description.")); + } + + #[test] + fn build_command_uses_preview_and_describe_for_extension_help() { + let command = build_command( + &[ExtensionSummary { + name: "slack".to_string(), + about: "Slack tools for chat".to_string(), + }], + Some(&LoadedExtension { + name: "slack".to_string(), + about: "Slack tools for chat".to_string(), + description: format!( + "{} {}\n\n{}", + "Slack tools for chat.", + "Use this extension to search channels, read threads, and post messages." + .repeat(20), + "This second paragraph should only appear in --describe output." + ), + tools: vec![RuntimeTool { + extension_name: "slack".to_string(), + kgoose_name: "search_messages".to_string(), + cli_name: "search-messages".to_string(), + about: "Search Slack messages".to_string(), + description: "Search Slack messages".to_string(), + parameters: Vec::new(), + }], + }), + ); + + let mut slack = command + .get_subcommands() + .find(|subcommand| subcommand.get_name() == "slack") + .cloned() + .expect("slack subcommand"); + let help = slack.render_long_help().to_string(); + + assert!(help.contains("Slack tools for chat")); + assert!(help.contains("Commands:")); + assert!(help.contains(EXTENSION_DESCRIBE_COMMAND_NAME)); + assert!(help.contains(EXTENSION_DESCRIBE_COMMAND_ABOUT)); + assert!(help.contains("search-messages")); + assert!(help.contains("...")); + assert!(!help.contains("This second paragraph should only appear in --describe output.")); + assert!(!help.contains("--describe")); + } + + #[test] + fn build_command_accepts_extension_describe_subcommand() { + let command = build_command( + &[ExtensionSummary { + name: "slack".to_string(), + about: "Slack tools for chat".to_string(), + }], + Some(&LoadedExtension { + name: "slack".to_string(), + about: "Slack tools for chat".to_string(), + description: "Slack tools for chat".to_string(), + tools: vec![RuntimeTool { + extension_name: "slack".to_string(), + kgoose_name: "search_messages".to_string(), + cli_name: "search-messages".to_string(), + about: "Search Slack messages".to_string(), + description: "Search Slack messages".to_string(), + parameters: Vec::new(), + }], + }), + ); + + let matches = command + .try_get_matches_from(["agent-tools", "slack", EXTENSION_DESCRIBE_COMMAND_NAME]) + .expect("parse matches"); + let (_, extension_matches) = matches.subcommand().expect("extension"); + let (subcommand_name, _) = extension_matches.subcommand().expect("describe subcommand"); + + assert_eq!(subcommand_name, EXTENSION_DESCRIBE_COMMAND_NAME); + } +} diff --git a/src/kgoose.rs b/src/kgoose.rs new file mode 100644 index 0000000..0e8eead --- /dev/null +++ b/src/kgoose.rs @@ -0,0 +1,363 @@ +use std::collections::BTreeMap; +use std::env; +use std::time::Duration; + +use anyhow::{Context, Result}; +use reqwest::blocking::{Client, ClientBuilder}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, CONTENT_TYPE}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::bl::auth::SESSION_CREDENTIAL_HEADER; +use crate::bl::skills_config::normalize_kgoose_service_path; +pub use crate::proto::squareup::cash::kgoose::api::v3::{ + CallToolRequest, CallToolResponse, ExtensionInfo, ListExtensionsRequest, + ListExtensionsResponse, ListToolsRequest, ListToolsResponse, Source, ToolConfig, +}; +use crate::proto::{CALL_TOOL_PATH, LIST_EXTENSIONS_PATH, LIST_TOOLS_PATH}; + +pub const DEFAULT_KGOOSE_BASE_URL: &str = "https://kgoose.sqprod.co"; +pub const DEFAULT_KGOOSE_TIMEOUT_SECS: f64 = 600.0; +const STS_ACCESS_TOKEN_ENV_VAR: &str = "STS_ACCESS_TOKEN"; +const KGOOSE_DEBUG_ENV_VAR: &str = "KGOOSE_DEBUG"; + +#[derive(Debug, Clone, PartialEq)] +pub struct KgooseConfig { + pub base_url: String, + pub service_path: String, + pub playpen: Option, + pub goosemcp_playpen: Option, + pub timeout_secs: f64, + pub session_credential: Option, +} + +impl KgooseConfig { + pub fn timeout(&self) -> Duration { + Duration::from_secs_f64(self.timeout_secs) + } +} + +pub trait KgooseClient { + fn list_extensions(&self, config: &KgooseConfig) -> Result; + fn list_tools(&self, config: &KgooseConfig, extension_name: &str) -> Result; + fn call_tool( + &self, + config: &KgooseConfig, + extension_name: &str, + tool_name: &str, + arguments_json: &str, + headers: &BTreeMap, + ) -> Result; +} + +pub struct HttpKgooseClient; + +impl KgooseClient for HttpKgooseClient { + fn list_extensions(&self, config: &KgooseConfig) -> Result { + debug_log("ListExtensions".to_string()); + self.post_json(config, LIST_EXTENSIONS_PATH, &ListExtensionsRequest {}) + } + + fn list_tools(&self, config: &KgooseConfig, extension_name: &str) -> Result { + debug_log(format!("ListTools extension={extension_name}")); + self.post_json( + config, + LIST_TOOLS_PATH, + &ListToolsRequest { + extension_name: Some(extension_name.to_string()), + }, + ) + } + + fn call_tool( + &self, + config: &KgooseConfig, + extension_name: &str, + tool_name: &str, + arguments_json: &str, + headers: &BTreeMap, + ) -> Result { + debug_log(format!( + "CallTool extension={extension_name} tool={tool_name} arguments_bytes={} tool_header_keys=[{}]", + arguments_json.len(), + headers.keys().cloned().collect::>().join(",") + )); + self.post_json( + config, + CALL_TOOL_PATH, + &CallToolRequest { + extension_name: Some(extension_name.to_string()), + tool_name: Some(tool_name.to_string()), + arguments_json: Some(arguments_json.to_string()), + headers: headers + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + source: Some(Source::SqAgentTools.into()), + tenancy: None, + }, + ) + } +} + +impl HttpKgooseClient { + fn post_json(&self, config: &KgooseConfig, path: &str, body: &B) -> Result + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let client = build_http_client(config)?; + let service_path = normalize_kgoose_service_path(&config.service_path)?; + let request_path = format!( + "{}/{}", + service_path.trim_end_matches('/'), + path.trim_start_matches('/') + ); + let url = format!("{}{}", config.base_url.trim_end_matches('/'), request_path); + + debug_log(format!( + "POST {url} timeout_secs={} playpen={} goosemcp_playpen={}", + config.timeout_secs, + option_for_debug(config.playpen.as_deref()), + option_for_debug(config.goosemcp_playpen.as_deref()) + )); + + let response = client + .post(&url) + .json(body) + .send() + .with_context(|| format!("POST {request_path}"))?; + + let status = response.status(); + let final_url = response.url().to_string(); + let response_body = response + .text() + .with_context(|| format!("read {request_path} response"))?; + + debug_log(format!( + "POST {request_path} status={status} final_url={final_url} response_bytes={}", + response_body.len() + )); + + // Check for Cloudflare Access redirect (indicates VPN is off) + // Note: Cloudflare returns 200 OK with an HTML login page, not an error status + if final_url.contains("cloudflareaccess.com") { + anyhow::bail!( + "Cannot connect to kgoose - received Cloudflare Access redirect.\n\ + This usually means you need to connect to the corporate VPN (WARP).\n\ + Please enable WARP and try again." + ); + } + + if !status.is_success() { + let body = truncate(&response_body, 800); + anyhow::bail!("POST {request_path} failed with {status}: {body}"); + } + + serde_json::from_str(&response_body) + .with_context(|| format!("deserialize JSON response from {request_path}")) + } +} + +fn build_http_client(config: &KgooseConfig) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + if let Some(session_credential) = config.session_credential.as_deref() { + headers.insert( + SESSION_CREDENTIAL_HEADER, + HeaderValue::from_str(session_credential) + .context("build X-BB-Session-Credential header")?, + ); + } + + // Build the Baggage header from independent playpen knobs: + // * KGOOSE_PLAYPEN routes the kgoose service itself. + // * GOOSEMCP_PLAYPEN routes the downstream goosemcp Envoy. Setting it + // when no matching playpen pod exists makes every call fail with an + // opaque 5xx, so it is opt-in independent of KGOOSE_PLAYPEN. + let mut baggage_parts = Vec::new(); + if let Some(playpen) = &config.playpen { + baggage_parts.push(format!("kgoose-playpen={playpen}")); + } + if let Some(playpen) = &config.goosemcp_playpen { + baggage_parts.push(format!("envoy-route--goosemcp=playpen-{playpen}")); + } + if !baggage_parts.is_empty() { + headers.insert( + "Baggage", + HeaderValue::from_str(&baggage_parts.join(",")).context("build Baggage header")?, + ); + } + + match env::var(STS_ACCESS_TOKEN_ENV_VAR) { + Ok(access_token) => { + headers.insert( + HeaderName::from_static("x-forwarded-identity-token"), + HeaderValue::from_str(&access_token) + .context("build x-forwarded-identity-token header")?, + ); + } + Err(env::VarError::NotPresent) => {} + Err(err) => anyhow::bail!("failed to read {STS_ACCESS_TOKEN_ENV_VAR}: {err}"), + } + + debug_log(format!( + "HTTP client default_header_keys=[{}]", + headers + .keys() + .map(|name| name.as_str()) + .collect::>() + .join(",") + )); + + ClientBuilder::new() + .default_headers(headers) + .timeout(config.timeout()) + .build() + .context("build HTTP client") +} + +fn truncate(value: &str, max_len: usize) -> String { + if value.len() <= max_len { + return value.to_string(); + } + + format!("{}...", &value[..max_len]) +} + +fn debug_enabled() -> bool { + match env::var(KGOOSE_DEBUG_ENV_VAR) { + Ok(value) => !matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "0" | "false" | "off" | "no" + ), + Err(env::VarError::NotPresent) => false, + Err(_) => false, + } +} + +fn debug_log(message: String) { + if debug_enabled() { + eprintln!("{KGOOSE_DEBUG_ENV_VAR}: {message}"); + } +} + +fn option_for_debug(value: Option<&str>) -> &str { + value.filter(|value| !value.is_empty()).unwrap_or("") +} + +#[cfg(test)] +mod tests { + use super::{CallToolResponse, ListExtensionsResponse, ListToolsResponse}; + use crate::proto::squareup::cash::kgoose::api::v3::user_content; + + #[test] + fn list_tools_response_deserializes_generated_proto_shape() { + let response: ListToolsResponse = serde_json::from_str( + r#" + { + "extension_name": "developer", + "extension_description": "Developer tools", + "tools": [ + { + "tool": "shell", + "description": "Run a shell command", + "config_json": "{\"type\":\"object\",\"properties\":{}}", + "mutates_state": false + } + ] + } + "#, + ) + .expect("deserialize list tools response"); + + assert_eq!(response.extension_name.as_deref(), Some("developer")); + assert_eq!(response.tools[0].tool.as_deref(), Some("shell")); + assert_eq!(response.tools[0].mutates_state, Some(false)); + } + + #[test] + fn call_tool_response_deserializes_generated_proto_shape() { + let response: CallToolResponse = serde_json::from_str( + r#" + { + "content": [{"text":{"text":"hello"}}], + "is_error": false, + "structured_content_json": "{\"ok\":true}" + } + "#, + ) + .expect("deserialize call response"); + + assert_eq!(response.is_error, Some(false)); + assert_eq!( + response.structured_content_json.as_deref(), + Some("{\"ok\":true}") + ); + assert_eq!( + response.content[0] + .content + .as_ref() + .and_then(|content| match content { + user_content::Content::Text(text) => text.text.as_deref(), + _ => None, + }), + Some("hello") + ); + } + + #[test] + fn list_extensions_response_defaults_missing_extensions() { + let response: ListExtensionsResponse = + serde_json::from_str("{}").expect("deserialize extensions response"); + + assert!(response.extensions.is_empty()); + } + + #[test] + fn list_extensions_response_deserializes_auth_status_fields() { + let response: ListExtensionsResponse = serde_json::from_str( + r#" + { + "extensions": [ + { + "name": "slack", + "description": "Slack tools", + "tool_count": 12, + "anyToolRequiresUserAuth": true, + "authSatisfiedForCaller": true + }, + { + "name": "airtable", + "description": "Airtable tools", + "tool_count": 4, + "any_tool_requires_user_auth": false, + "auth_satisfied_for_caller": false + } + ] + } + "#, + ) + .expect("deserialize list extensions response"); + + assert_eq!(response.extensions[0].name.as_deref(), Some("slack")); + assert_eq!(response.extensions[0].tool_count, Some(12)); + assert_eq!( + response.extensions[0].any_tool_requires_user_auth, + Some(true) + ); + assert_eq!(response.extensions[0].auth_satisfied_for_caller, Some(true)); + + assert_eq!(response.extensions[1].name.as_deref(), Some("airtable")); + assert_eq!(response.extensions[1].tool_count, Some(4)); + assert_eq!( + response.extensions[1].any_tool_requires_user_auth, + Some(false) + ); + assert_eq!( + response.extensions[1].auth_satisfied_for_caller, + Some(false) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f064738 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,1394 @@ +mod appkit; +mod bl; +mod catalog; +mod cli; +mod kgoose; +mod proto; +mod runtime; + +pub use bl::agents_models; +pub use bl::skills_api::{AgentMarketplace, MarketplaceClient}; + +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; +use clap::error::ErrorKind; +use clap::{ArgMatches, Command}; +use cli::{ + bool_false_id, bool_true_id, bootstrap_args, build_tools_command, parameter_id, + ToolCommandConfig, APPKIT_COMMAND_ABOUT, APPKIT_COMMAND_NAME, BL_TOOLS_BIN_NAME, + EXTENSION_DESCRIBE_COMMAND_ABOUT, EXTENSION_DESCRIBE_COMMAND_NAME, ROOT_BIN_NAME, + ROOT_COMMAND_NAME, ROOT_SUMMARY, TOOLS_COMMAND_NAME, +}; +use runtime::{ + load_extension, load_extensions, LoadedExtension, ParameterKind, RuntimeTool, ScalarKind, + ToolParameter, +}; +use serde::Serialize; +use serde_json::{Map, Value}; + +use crate::bl::auth_storage::stored_session_credential_header_value_for_kgoose_base_url; +use crate::bl::org_routing::resolve_org_kgoose_base_url; +use crate::bl::skills_config::{ + default_bl_home, default_preferences_path, normalize_kgoose_service_path, read_optional_env, + read_preferences_file, resolve_skills_profile_context, SkillsProfileResolveOptions, + BL_HOME_ENV_VAR, DEFAULT_KGOOSE_SERVICE_PATH, +}; +use crate::catalog::{load_extensions_catalog, write_extensions_catalog}; +use crate::kgoose::{CallToolResponse, HttpKgooseClient, KgooseClient, KgooseConfig}; +use crate::proto::squareup::cash::kgoose::api::v3::user_content; + +const BL_COMMAND_NAME: &str = "bl"; +const BL_SUMMARY: &str = "BuilderLab command line tools"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ToolsCliConfig { + command: ToolCommandConfig, + describe_root_name: &'static str, + version_bin_name: &'static str, + nested_appkit_help: &'static str, + builderlab_mode: bool, +} + +fn agent_tools_config() -> ToolsCliConfig { + ToolsCliConfig { + command: ToolCommandConfig { + command_name: ROOT_COMMAND_NAME, + bin_name: ROOT_BIN_NAME, + example: "sq agent-tools utils calculate --numbers 2 3 --operation add", + }, + describe_root_name: ROOT_COMMAND_NAME, + version_bin_name: ROOT_BIN_NAME, + nested_appkit_help: "sq agent-tools appkit --help", + builderlab_mode: false, + } +} + +fn bl_tools_config() -> ToolsCliConfig { + ToolsCliConfig { + command: ToolCommandConfig { + command_name: TOOLS_COMMAND_NAME, + bin_name: BL_TOOLS_BIN_NAME, + example: "bl tools utils calculate --numbers 2 3 --operation add", + }, + describe_root_name: TOOLS_COMMAND_NAME, + version_bin_name: BL_TOOLS_BIN_NAME, + nested_appkit_help: "bl tools appkit --help", + builderlab_mode: true, + } +} + +pub fn agent_tools_main() { + if let Err(err) = run_agent_tools() { + eprintln!("error: {err:#}"); + std::process::exit(1); + } +} + +pub fn bl_main() { + if let Err(err) = run_bl() { + // `bl skills --json` failures already printed a structured error to + // stderr; exit silently with the recorded code. + if let Some(silent) = err + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + std::process::exit(silent.0); + } + eprintln!("error: {err:#}"); + let (exit_code, _) = bl::skills_api::failure_info(&err); + std::process::exit(exit_code); + } +} + +fn run_agent_tools() -> Result<()> { + let argv = std::env::args().collect::>(); + let raw_args = &argv[1..]; + run_tools_cli(&argv[0], raw_args, agent_tools_config()) +} + +fn run_bl() -> Result<()> { + let argv = std::env::args().collect::>(); + run_bl_with_argv(argv) +} + +fn run_bl_with_argv(argv: Vec) -> Result<()> { + let raw_args = &argv[1..]; + + if raw_args.first().map(String::as_str) == Some(TOOLS_COMMAND_NAME) { + return run_tools_cli(BL_TOOLS_BIN_NAME, &raw_args[1..], bl_tools_config()); + } + + // Machine-readable command tree for `sq`-style integrations, mirroring + // `--describe-commands` on the tools path. The `bl tools` namespace is + // dynamic and is described by `bl tools --describe-commands` instead. + if raw_args.first().map(String::as_str) == Some("--describe-commands") { + let description = serde_json::json!({ + "name": BL_COMMAND_NAME, + "summary": BL_SUMMARY, + "commands": [ + bl::skills::describe_auth_commands(), + bl::workspace::describe_commands(), + bl::apps::describe_commands(), + bl::skills::describe_config_commands(), + bl::skills::describe_commands(), + bl::agents::describe_commands(), + { "name": TOOLS_COMMAND_NAME, "summary": ROOT_SUMMARY }, + ], + }); + println!( + "{}", + serde_json::to_string_pretty(&description) + .context("serialize `--describe-commands` output")? + ); + return Ok(()); + } + + let command = build_bl_command(); + let matches = clap_matches(command, argv)?; + match matches.subcommand() { + Some(("agents", agents_matches)) => bl::agents::run(agents_matches), + Some(("skills", skills_matches)) => bl::skills::run(skills_matches), + Some(("auth", auth_matches)) => bl::skills::run_auth(auth_matches), + Some(("workspace", workspace_matches)) => bl::workspace::run(workspace_matches), + Some(("apps", apps_matches)) => bl::apps::run(apps_matches), + Some(("config", config_matches)) => bl::skills::run_config(config_matches), + Some(("completions", completions_matches)) => { + let shell = completions_matches + .get_one::("shell") + .copied() + .context("expected a shell")?; + let mut command = build_bl_command(); + clap_complete::generate(shell, &mut command, BL_COMMAND_NAME, &mut std::io::stdout()); + Ok(()) + } + Some((TOOLS_COMMAND_NAME, _)) => run_tools_cli(BL_TOOLS_BIN_NAME, &[], bl_tools_config()), + _ => anyhow::bail!("expected a bl subcommand"), + } +} + +fn build_bl_command() -> Command { + let command = Command::new(BL_COMMAND_NAME) + .bin_name(BL_COMMAND_NAME) + .version(env!("CARGO_PKG_VERSION")) + .about(BL_SUMMARY) + .subcommand_required(true) + .arg_required_else_help(true) + .disable_help_subcommand(true) + .subcommand(bl::skills::auth_command()) + .subcommand(bl::workspace::command()) + .subcommand(bl::apps::command()) + .subcommand(bl::skills::config_command()) + .subcommand(bl::skills::skills_command()) + .subcommand(bl::agents::agents_command()) + .subcommand( + Command::new("completions") + .about("Generate shell completions") + .long_about( + "Generate a shell completion script for bl. Example:\n \ + bl completions zsh > ~/.zfunc/_bl", + ) + .arg( + clap::Arg::new("shell") + .required(true) + .value_parser(clap::value_parser!(clap_complete::Shell)), + ), + ) + .subcommand(Command::new(TOOLS_COMMAND_NAME).about(ROOT_SUMMARY)); + bl::skills::skills_global_args(command) +} + +fn run_tools_cli(argv0: &str, raw_args: &[String], config: ToolsCliConfig) -> Result<()> { + if appkit::should_run_before_bootstrap(raw_args) { + return appkit::run(raw_args); + } + + let bootstrap = bootstrap_args(raw_args.iter().cloned())?; + + let mut kgoose_config = KgooseConfig { + base_url: bootstrap.base_url.clone(), + service_path: bootstrap.service_path.clone(), + playpen: bootstrap.playpen.clone(), + goosemcp_playpen: bootstrap.goosemcp_playpen.clone(), + timeout_secs: bootstrap.timeout_secs, + session_credential: None, + }; + + let client = HttpKgooseClient; + if let Some(path) = bootstrap.write_extensions.as_deref() { + if bootstrap.describe_commands || bootstrap.summary_only || bootstrap.version_only { + anyhow::bail!( + "`--write-extensions` cannot be combined with `--describe-commands`, `--summary`, or `--version`" + ); + } + if !bootstrap.command_tokens.is_empty() { + anyhow::bail!("`--write-extensions` does not accept command arguments"); + } + + apply_builderlab_mode_if_needed(&mut kgoose_config, config.builderlab_mode)?; + let extensions = load_extensions(&client, &kgoose_config)?; + write_extensions_catalog(path, &extensions)?; + return Ok(()); + } + + if bootstrap.describe_commands { + if !bootstrap.command_tokens.is_empty() { + apply_builderlab_mode_if_needed(&mut kgoose_config, config.builderlab_mode)?; + } + let description = + load_command_description(&client, &kgoose_config, &bootstrap.command_tokens, config)?; + println!( + "{}", + serde_json::to_string_pretty(&description) + .context("serialize `--describe-commands` output")? + ); + return Ok(()); + } + + if bootstrap.summary_only { + let summary = if bootstrap.command_tokens.is_empty() { + ROOT_SUMMARY.to_string() + } else { + apply_builderlab_mode_if_needed(&mut kgoose_config, config.builderlab_mode)?; + load_command_description(&client, &kgoose_config, &bootstrap.command_tokens, config)? + .summary + }; + println!("{summary}"); + return Ok(()); + } + + if bootstrap.version_only && bootstrap.command_tokens.is_empty() { + println!("{} {}", config.version_bin_name, env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + // The Cloudflare-backed internal App Kit deploy needs direct access to the + // local workspace so it can tar files and upload them. Keep this compatibility + // path as a local process; root `bl apps` is the separate Apps Platform + // control-plane client. + if appkit::is_appkit_command(&bootstrap.command_tokens) { + return appkit::run(raw_args); + } + + let selected_extension_name = bootstrap + .command_tokens + .first() + .filter(|token| !token.starts_with('-')) + .cloned(); + + let (extensions, loaded_extension) = if let Some(extension_name) = + selected_extension_name.as_deref() + { + apply_builderlab_mode_if_needed(&mut kgoose_config, config.builderlab_mode)?; + let known_extensions = load_extensions_catalog()?; + let loaded = load_extension(&client, &kgoose_config, extension_name, &known_extensions)?; + let extensions = vec![runtime::ExtensionSummary { + name: loaded.name.clone(), + about: loaded.about.clone(), + }]; + (extensions, Some(loaded)) + } else { + let extensions = load_extensions_catalog()?; + (extensions, None) + }; + + let command = build_tools_command(config.command, &extensions, loaded_extension.as_ref()); + let command_argv = std::iter::once(argv0.to_string()) + .chain(bootstrap.command_tokens.iter().cloned()) + .collect::>(); + let matches = clap_matches(command, command_argv)?; + + let (extension_name, extension_matches) = matches + .subcommand() + .context("expected an extension subcommand")?; + let loaded_extension = loaded_extension + .as_ref() + .filter(|extension| extension.name == extension_name) + .context("loaded extension metadata missing")?; + // TODO: Don't reserve `describe` as a built-in extension subcommand. If an + // extension exposes a real `describe` tool, pick a non-conflicting synthetic + // name and use it consistently for runtime dispatch and `--describe-commands`. + if matches!( + extension_matches.subcommand(), + Some((EXTENSION_DESCRIBE_COMMAND_NAME, _)) + ) { + println!("{}", loaded_extension.description); + return Ok(()); + } + let (tool_cli_name, tool_matches) = extension_matches + .subcommand() + .context("expected a tool subcommand")?; + let tool = loaded_extension + .tools + .iter() + .find(|tool| tool.cli_name == tool_cli_name || tool.kgoose_name == tool_cli_name) + .context("loaded tool metadata missing")?; + + let request = build_tool_request(tool, tool_matches)?; + let response = client.call_tool( + &kgoose_config, + &tool.extension_name, + &tool.kgoose_name, + &request.arguments_json, + &request.headers, + )?; + + let rendered_response = render_tool_response(&response, tool_matches.get_flag("raw"))?; + if response.is_error == Some(true) { + anyhow::bail!("{rendered_response}"); + } + + println!("{rendered_response}"); + Ok(()) +} + +fn apply_builderlab_mode_if_needed(config: &mut KgooseConfig, builderlab_mode: bool) -> Result<()> { + if !builderlab_mode { + return Ok(()); + } + let bl_home = read_optional_env(BL_HOME_ENV_VAR)? + .map(std::path::PathBuf::from) + .unwrap_or_else(default_bl_home); + let preferences = read_preferences_file(&default_preferences_path(&bl_home))?; + let org = preferences + .org + .as_deref() + .ok_or_else(bl_org_required_error)?; + config.base_url = + resolve_org_kgoose_base_url(&config.base_url, Some(org), false, &config.service_path)?; + config.session_credential = + resolve_kgoose_session_credential(&config.base_url, &config.service_path)?; + if config.service_path == DEFAULT_KGOOSE_SERVICE_PATH { + config.service_path = normalize_kgoose_service_path("api")?; + } + Ok(()) +} + +fn bl_org_required_error() -> anyhow::Error { + bl::skills_api::failure( + bl::skills_api::exit_codes::AUTH_REQUIRED, + "org_required", + "bl org is not configured; run `bl auth login` or `bl config set org `", + ) +} + +fn resolve_kgoose_session_credential(base_url: &str, service_path: &str) -> Result> { + let profile_context = resolve_skills_profile_context(SkillsProfileResolveOptions::default())?; + + stored_session_credential_header_value_for_kgoose_base_url( + &profile_context.profile, + base_url, + service_path, + profile_context.bl_home, + ) +} + +fn clap_matches(command: Command, argv: Vec) -> Result { + match command.try_get_matches_from(argv) { + Ok(matches) => Ok(matches), + Err(err) => match err.kind() { + ErrorKind::DisplayHelp + | ErrorKind::DisplayVersion + | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => { + err.print().context("print clap output")?; + std::process::exit(0); + } + _ => err.exit(), + }, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct CommandDescription { + name: String, + summary: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + commands: Vec, +} + +fn load_command_description( + client: &impl KgooseClient, + config: &KgooseConfig, + command_tokens: &[String], + tools_cli: ToolsCliConfig, +) -> Result { + let command_path = command_tokens + .iter() + .take_while(|token| !token.starts_with('-')) + .map(String::as_str) + .collect::>(); + + match command_path.as_slice() { + [] => load_root_command_description(client, config, tools_cli.describe_root_name), + [APPKIT_COMMAND_NAME] => Ok(appkit_command_description()), + [APPKIT_COMMAND_NAME, ..] => { + anyhow::bail!( + "`--describe-commands` does not inspect nested appkit commands; run `{}`", + tools_cli.nested_appkit_help + ) + } + [extension_name] => load_extension_command_description(client, config, extension_name), + [extension_name, EXTENSION_DESCRIBE_COMMAND_NAME] => { + load_extension_describe_command_description(client, config, extension_name) + } + [extension_name, tool_name] => { + load_tool_command_description(client, config, extension_name, tool_name) + } + _ => anyhow::bail!( + "`--describe-commands` only supports the root command, an extension, or a tool path" + ), + } +} + +fn load_root_command_description( + _client: &impl KgooseClient, + _config: &KgooseConfig, + root_name: &str, +) -> Result { + let extensions = load_extensions_catalog()?; + Ok(root_command_description(&extensions, root_name)) +} + +/// Loads an extension by name, using the static catalog to produce helpful error +/// messages for extensions that exist but the user hasn't connected yet. +fn load_extension_with_catalog( + client: &impl KgooseClient, + config: &KgooseConfig, + extension_name: &str, +) -> Result { + let known_extensions = load_extensions_catalog()?; + load_extension(client, config, extension_name, &known_extensions) +} + +fn load_extension_command_description( + client: &impl KgooseClient, + config: &KgooseConfig, + extension_name: &str, +) -> Result { + let loaded = load_extension_with_catalog(client, config, extension_name)?; + Ok(extension_command_description(&loaded)) +} + +fn load_tool_command_description( + client: &impl KgooseClient, + config: &KgooseConfig, + extension_name: &str, + tool_name: &str, +) -> Result { + let loaded = load_extension_with_catalog(client, config, extension_name)?; + let tool = loaded + .tools + .iter() + .find(|tool| tool.cli_name == tool_name || tool.kgoose_name == tool_name) + .with_context(|| format!("unknown tool `{tool_name}` for extension `{extension_name}`"))?; + + Ok(tool_command_description(tool)) +} + +fn load_extension_describe_command_description( + client: &impl KgooseClient, + config: &KgooseConfig, + extension_name: &str, +) -> Result { + load_extension_with_catalog(client, config, extension_name)?; + Ok(extension_describe_command_description()) +} + +fn extension_command_description(extension: &LoadedExtension) -> CommandDescription { + let mut commands = extension + .tools + .iter() + .map(tool_command_description) + .collect::>(); + commands.push(extension_describe_command_description()); + // TODO: If https://github.com/squareup/sq stops alphabetizing module submenu + // entries in its exoskeleton help path, consider pinning `describe` first here. + commands.sort_by(|left, right| left.name.cmp(&right.name)); + + CommandDescription { + name: extension.name.clone(), + summary: sq_command_summary(&extension.about), + commands, + } +} + +fn root_command_description( + extensions: &[runtime::ExtensionSummary], + root_name: &str, +) -> CommandDescription { + let mut commands = vec![appkit_command_description()]; + commands.extend(extensions.iter().filter_map(|extension| { + if extension.name == APPKIT_COMMAND_NAME { + None + } else { + Some(CommandDescription { + name: extension.name.clone(), + summary: sq_command_summary(&extension.about), + commands: Vec::new(), + }) + } + })); + + CommandDescription { + name: root_name.to_string(), + summary: ROOT_SUMMARY.to_string(), + commands, + } +} + +fn appkit_command_description() -> CommandDescription { + CommandDescription { + name: APPKIT_COMMAND_NAME.to_string(), + summary: sq_command_summary(APPKIT_COMMAND_ABOUT), + commands: Vec::new(), + } +} + +fn tool_command_description(tool: &RuntimeTool) -> CommandDescription { + CommandDescription { + name: tool.cli_name.clone(), + summary: sq_command_summary(&tool.about), + commands: Vec::new(), + } +} + +fn extension_describe_command_description() -> CommandDescription { + CommandDescription { + name: EXTENSION_DESCRIBE_COMMAND_NAME.to_string(), + summary: sq_command_summary(EXTENSION_DESCRIBE_COMMAND_ABOUT), + commands: Vec::new(), + } +} + +fn sq_command_summary(value: &str) -> String { + const MAX_LEN: usize = 79; + + let summary = value.trim().trim_end_matches('.'); + let len = summary.chars().count(); + if len <= MAX_LEN { + return summary.to_string(); + } + + let truncated = summary.chars().take(MAX_LEN).collect::(); + let shortened = truncated.trim_end(); + let candidate = shortened + .rfind(char::is_whitespace) + .filter(|index| *index >= MAX_LEN / 2) + .map(|index| shortened[..index].trim_end()) + .unwrap_or(shortened); + + candidate.trim_end_matches('.').to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ToolRequest { + arguments_json: String, + headers: BTreeMap, +} + +fn build_tool_request(tool: &RuntimeTool, matches: &ArgMatches) -> Result { + let headers = matches + .get_many::("header") + .into_iter() + .flatten() + .map(|header| parse_header(header)) + .collect::>>()?; + + let arguments = if let Some(raw_json) = matches.get_one::("json") { + match serde_json::from_str::(raw_json).context("`--json` must be valid JSON")? { + Value::Object(map) => Value::Object(map), + _ => anyhow::bail!("`--json` must be a JSON object"), + } + } else { + let mut payload = Map::new(); + + for parameter in &tool.parameters { + if let Some(value) = parameter_value_from_matches(parameter, matches)? { + payload.insert(parameter.name.clone(), value); + } + } + + Value::Object(payload) + }; + + Ok(ToolRequest { + arguments_json: serde_json::to_string(&arguments).context("serialize tool payload")?, + headers, + }) +} + +fn parameter_value_from_matches( + parameter: &ToolParameter, + matches: &ArgMatches, +) -> Result> { + match ¶meter.kind { + ParameterKind::Scalar(ScalarKind::Boolean) => { + let true_id = bool_true_id(parameter); + if matches.get_flag(&true_id) { + return Ok(Some(Value::Bool(true))); + } + + let false_id = bool_false_id(parameter); + if matches.get_flag(&false_id) { + return Ok(Some(Value::Bool(false))); + } + + Ok(None) + } + ParameterKind::Scalar(kind) => { + let id = parameter_id(parameter); + matches + .get_one::(&id) + .map(|value| parse_scalar_value(kind, value)) + .transpose() + } + ParameterKind::Array(kind) => { + let id = parameter_id(parameter); + let values = matches + .get_many::(&id) + .into_iter() + .flatten() + .map(|value| parse_scalar_value(kind, value)) + .collect::>>()?; + + if values.is_empty() { + Ok(None) + } else { + Ok(Some(Value::Array(values))) + } + } + ParameterKind::Json => { + let id = parameter_id(parameter); + matches + .get_one::(&id) + .map(|value| { + serde_json::from_str(value).with_context(|| { + format!("`--{}` expects a valid JSON value", parameter.cli_name) + }) + }) + .transpose() + } + } +} + +fn parse_scalar_value(kind: &ScalarKind, value: &str) -> Result { + let parsed = match kind { + ScalarKind::String { enum_values, .. } => { + let parsed = Value::String(value.to_string()); + validate_enum_value(enum_values, &parsed, value)?; + parsed + } + ScalarKind::Integer { enum_values } => { + let parsed = serde_json::from_str::(value) + .with_context(|| format!("`{value}` is not a valid integer"))?; + if !parsed.is_i64() && !parsed.is_u64() { + anyhow::bail!("`{value}` is not a valid integer"); + } + validate_enum_value(enum_values, &parsed, value)?; + parsed + } + ScalarKind::Number { enum_values } => { + let parsed = serde_json::from_str::(value) + .with_context(|| format!("`{value}` is not a valid number"))?; + if !parsed.is_number() { + anyhow::bail!("`{value}` is not a valid number"); + } + validate_enum_value(enum_values, &parsed, value)?; + parsed + } + ScalarKind::Boolean => Value::Bool(parse_bool_value(value)?), + }; + + Ok(parsed) +} + +fn validate_enum_value(enum_values: &[Value], parsed: &Value, raw_value: &str) -> Result<()> { + if enum_values.is_empty() || enum_values.iter().any(|value| value == parsed) { + return Ok(()); + } + + anyhow::bail!( + "`{raw_value}` must be one of: {}", + enum_values + .iter() + .map(enum_display) + .collect::>() + .join(", ") + ) +} + +fn parse_bool_value(value: &str) -> Result { + match value { + "true" | "1" | "yes" | "on" => Ok(true), + "false" | "0" | "no" | "off" => Ok(false), + _ => anyhow::bail!("`{value}` is not a valid boolean"), + } +} + +fn enum_display(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + _ => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), + } +} + +fn parse_header(value: &str) -> Result<(String, String)> { + let (key, value) = value + .split_once('=') + .with_context(|| format!("invalid header `{value}`; expected KEY=VALUE"))?; + + if key.is_empty() { + anyhow::bail!("header name cannot be empty"); + } + + Ok((key.to_string(), value.to_string())) +} + +fn render_tool_response(response: &CallToolResponse, raw: bool) -> Result { + if raw { + return serialize_call_tool_response(response); + } + + if let Some(rendered) = render_structured_output(response) { + return Ok(rendered); + } + + if let Some(rendered) = render_text_output(response) { + return Ok(rendered); + } + + serialize_call_tool_response(response) +} + +fn render_structured_output(response: &CallToolResponse) -> Option { + if let Some(pretty) = response + .structured_content_json + .as_deref() + .and_then(parse_json_string) + .and_then(|value| pretty_json(&value).ok()) + { + return Some(pretty); + } + + let values = response + .content + .iter() + .filter_map(structured_content_value) + .collect::>(); + + match values.len() { + 0 => None, + 1 => pretty_json(&values.into_iter().next().expect("single structured value")).ok(), + _ => pretty_json(&Value::Array(values)).ok(), + } +} + +fn render_text_output(response: &CallToolResponse) -> Option { + let fragments = response + .content + .iter() + .filter_map(renderable_text_fragment) + .collect::>(); + + match fragments.len() { + 0 => None, + 1 => { + let text = fragments.into_iter().next().expect("single text fragment"); + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else if let Some(value) = parse_json_string(trimmed) { + pretty_json(&value).ok() + } else { + Some(text) + } + } + _ => { + let rendered = fragments.join("\n\n"); + if rendered.trim().is_empty() { + None + } else { + Some(rendered) + } + } + } +} + +fn structured_content_value( + content: &crate::proto::squareup::cash::kgoose::api::v3::UserContent, +) -> Option { + match content.content.as_ref()? { + user_content::Content::StructuredContent(structured) => structured + .data + .as_ref() + .and_then(|data| serde_json::to_value(data).ok()), + _ => None, + } +} + +fn renderable_text_fragment( + content: &crate::proto::squareup::cash::kgoose::api::v3::UserContent, +) -> Option { + match content.content.as_ref()? { + user_content::Content::Text(text) => text.text.clone(), + user_content::Content::Resource(resource) => resource + .resource + .as_ref() + .and_then(|resource| resource.text.clone()), + _ => None, + } + .filter(|text| !text.trim().is_empty()) +} + +fn parse_json_string(value: &str) -> Option { + serde_json::from_str(value).ok() +} + +fn pretty_json(value: &Value) -> Result { + serde_json::to_string_pretty(value).context("serialize tool output JSON") +} + +fn serialize_call_tool_response(response: &CallToolResponse) -> Result { + serde_json::to_string_pretty(response).context("serialize CallTool response") +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use clap::ArgMatches; + use serde_json::json; + + use super::{ + agent_tools_config, appkit_command_description, bl_tools_config, build_tool_request, + extension_command_description, load_command_description, parse_bool_value, parse_header, + render_tool_response, root_command_description, sq_command_summary, + tool_command_description, CommandDescription, + }; + use crate::cli::{build_command, APPKIT_COMMAND_NAME, ROOT_SUMMARY}; + use crate::kgoose::{ + CallToolResponse, ExtensionInfo, KgooseClient, KgooseConfig, ListExtensionsResponse, + ListToolsResponse, ToolConfig, + }; + use crate::proto::squareup::cash::kgoose::api::v3::{ + user_content, StructuredContent, TextContent, UserContent, + }; + use crate::runtime::{ + ExtensionSummary, LoadedExtension, ParameterKind, RuntimeTool, ScalarKind, ToolParameter, + }; + + struct TestKgooseClient; + + impl KgooseClient for TestKgooseClient { + fn list_extensions( + &self, + _config: &KgooseConfig, + ) -> anyhow::Result { + Ok(ListExtensionsResponse { + extensions: vec![ExtensionInfo { + name: Some("utils".to_string()), + description: Some("Utility helpers".to_string()), + tool_count: Some(1), + any_tool_requires_user_auth: Some(false), + auth_satisfied_for_caller: Some(true), + ..Default::default() + }], + }) + } + + fn list_tools( + &self, + _config: &KgooseConfig, + extension_name: &str, + ) -> anyhow::Result { + if extension_name != "utils" { + anyhow::bail!("unknown extension `{extension_name}`"); + } + + Ok(ListToolsResponse { + extension_name: Some("utils".to_string()), + extension_description: Some("Utility helpers".to_string()), + tools: vec![ToolConfig { + tool: Some("calculate".to_string()), + description: Some("Perform math".to_string()), + config_json: Some( + r#"{"type":"object","properties":{"numbers":{"type":"array","items":{"type":"number"}},"operation":{"type":"string","enum":["add","subtract"]}},"required":["numbers","operation"]}"# + .to_string(), + ), + mutates_state: Some(false), + ..Default::default() + }], + }) + } + + fn call_tool( + &self, + _config: &KgooseConfig, + _extension_name: &str, + _tool_name: &str, + _arguments_json: &str, + _headers: &BTreeMap, + ) -> anyhow::Result { + unreachable!("call_tool is not used during command description loading") + } + } + + struct PanickingKgooseClient; + + impl KgooseClient for PanickingKgooseClient { + fn list_extensions( + &self, + _config: &KgooseConfig, + ) -> anyhow::Result { + panic!("appkit metadata should not load extensions") + } + + fn list_tools( + &self, + _config: &KgooseConfig, + _extension_name: &str, + ) -> anyhow::Result { + panic!("appkit metadata should not load tools") + } + + fn call_tool( + &self, + _config: &KgooseConfig, + _extension_name: &str, + _tool_name: &str, + _arguments_json: &str, + _headers: &BTreeMap, + ) -> anyhow::Result { + panic!("appkit metadata should not call tools") + } + } + + fn kgoose_config() -> KgooseConfig { + KgooseConfig { + base_url: "https://example.test".to_string(), + service_path: crate::bl::skills_config::DEFAULT_KGOOSE_SERVICE_PATH.to_string(), + playpen: Some("baxen".to_string()), + goosemcp_playpen: None, + timeout_secs: 600.0, + session_credential: None, + } + } + + fn tool_catalog() -> (Vec, LoadedExtension) { + let loaded = LoadedExtension { + name: "utils".to_string(), + about: "Utility helpers".to_string(), + description: "Utility helpers".to_string(), + tools: vec![RuntimeTool { + extension_name: "utils".to_string(), + kgoose_name: "calculate".to_string(), + cli_name: "calculate".to_string(), + about: "Perform math".to_string(), + description: "Perform math".to_string(), + parameters: vec![ + ToolParameter { + name: "numbers".to_string(), + cli_name: "numbers".to_string(), + required: true, + description: Some("Numbers to process".to_string()), + kind: ParameterKind::Array(ScalarKind::Number { + enum_values: Vec::new(), + }), + default: None, + }, + ToolParameter { + name: "operation".to_string(), + cli_name: "operation".to_string(), + required: true, + description: Some("Operation to apply".to_string()), + kind: ParameterKind::Scalar(ScalarKind::String { + enum_values: vec![json!("add"), json!("subtract")], + format: None, + }), + default: None, + }, + ], + }], + }; + + ( + vec![ExtensionSummary { + name: "utils".to_string(), + about: "Utility helpers".to_string(), + }], + loaded, + ) + } + + fn parse_matches(args: &[&str]) -> (LoadedExtension, ArgMatches) { + let (extensions, loaded) = tool_catalog(); + let command = build_command(&extensions, Some(&loaded)); + let matches = command.try_get_matches_from(args).expect("parse matches"); + (loaded, matches) + } + + #[test] + fn build_tool_request_uses_schema_derived_flags() { + let (loaded, matches) = parse_matches(&[ + "agent-tools", + "utils", + "calculate", + "--numbers", + "2", + "3", + "--operation", + "add", + ]); + let (_, extension_matches) = matches.subcommand().expect("extension"); + let (_, tool_matches) = extension_matches.subcommand().expect("tool"); + + let request = build_tool_request(&loaded.tools[0], tool_matches).expect("request"); + assert_eq!( + request.arguments_json, + r#"{"numbers":[2,3],"operation":"add"}"# + ); + } + + #[test] + fn build_tool_request_accepts_json_fallback() { + let (loaded, matches) = parse_matches(&[ + "agent-tools", + "utils", + "calculate", + "--json", + r#"{"numbers":[2,3],"operation":"add"}"#, + "--header", + "x-debug=true", + ]); + let (_, extension_matches) = matches.subcommand().expect("extension"); + let (_, tool_matches) = extension_matches.subcommand().expect("tool"); + + let request = build_tool_request(&loaded.tools[0], tool_matches).expect("request"); + assert_eq!( + request.arguments_json, + r#"{"numbers":[2,3],"operation":"add"}"# + ); + assert_eq!( + request.headers.get("x-debug").map(String::as_str), + Some("true") + ); + } + + #[test] + fn extension_command_description_includes_tool_summaries() { + let (_, loaded) = tool_catalog(); + + assert_eq!( + extension_command_description(&loaded), + CommandDescription { + name: "utils".to_string(), + summary: "Utility helpers".to_string(), + commands: vec![ + CommandDescription { + name: "calculate".to_string(), + summary: "Perform math".to_string(), + commands: Vec::new(), + }, + CommandDescription { + name: "describe".to_string(), + summary: "Print the full extension description/instructions".to_string(), + commands: Vec::new(), + }, + ], + } + ); + } + + #[test] + fn root_command_description_lists_extensions_without_nested_commands() { + let description = root_command_description( + &[ + ExtensionSummary { + name: "slack".to_string(), + about: "Slack tools for chat.".to_string(), + }, + ExtensionSummary { + name: "utils".to_string(), + about: "Utility helpers".to_string(), + }, + ], + "agent-tools", + ); + + assert_eq!( + description, + CommandDescription { + name: "agent-tools".to_string(), + summary: ROOT_SUMMARY.to_string(), + commands: vec![ + CommandDescription { + name: "appkit".to_string(), + summary: "Cloudflare-backed internal Block App Kit CLI (local exec)" + .to_string(), + commands: Vec::new(), + }, + CommandDescription { + name: "slack".to_string(), + summary: "Slack tools for chat".to_string(), + commands: Vec::new(), + }, + CommandDescription { + name: "utils".to_string(), + summary: "Utility helpers".to_string(), + commands: Vec::new(), + }, + ], + } + ); + } + + #[test] + fn appkit_command_description_is_static() { + assert_eq!( + appkit_command_description(), + CommandDescription { + name: APPKIT_COMMAND_NAME.to_string(), + summary: "Cloudflare-backed internal Block App Kit CLI (local exec)".to_string(), + commands: Vec::new(), + } + ); + } + + #[test] + fn appkit_description_does_not_load_extension_metadata() { + let description = load_command_description( + &PanickingKgooseClient, + &kgoose_config(), + &["appkit".to_string()], + agent_tools_config(), + ) + .expect("load appkit command description"); + + assert_eq!(description, appkit_command_description()); + } + + #[test] + fn nested_appkit_describe_commands_uses_appkit_specific_error() { + let error = load_command_description( + &PanickingKgooseClient, + &kgoose_config(), + &[ + "appkit".to_string(), + "deploy".to_string(), + "my-site".to_string(), + ], + agent_tools_config(), + ) + .expect_err("nested appkit description should fail locally"); + + let message = error.to_string(); + assert!(message.contains("does not inspect nested appkit commands")); + assert!(message.contains("sq agent-tools appkit --help")); + } + + #[test] + fn nested_appkit_describe_commands_uses_bl_tools_error_for_bl_tools() { + let error = load_command_description( + &PanickingKgooseClient, + &kgoose_config(), + &[ + "appkit".to_string(), + "deploy".to_string(), + "my-site".to_string(), + ], + bl_tools_config(), + ) + .expect_err("nested appkit description should fail locally"); + + let message = error.to_string(); + assert!(message.contains("does not inspect nested appkit commands")); + assert!(message.contains("bl tools appkit --help")); + } + + #[test] + fn load_command_description_supports_extension_describe_subcommand() { + let description = load_command_description( + &TestKgooseClient, + &kgoose_config(), + &["utils".to_string(), "describe".to_string()], + agent_tools_config(), + ) + .expect("load describe command description"); + + assert_eq!( + description, + CommandDescription { + name: "describe".to_string(), + summary: "Print the full extension description/instructions".to_string(), + commands: Vec::new(), + } + ); + } + + #[test] + fn tool_command_description_uses_cli_name() { + let tool = RuntimeTool { + extension_name: "slack".to_string(), + kgoose_name: "get_channel_messages".to_string(), + cli_name: "get-channel-messages".to_string(), + about: "Fetch Slack messages".to_string(), + description: "Fetch Slack messages".to_string(), + parameters: Vec::new(), + }; + + assert_eq!( + tool_command_description(&tool), + CommandDescription { + name: "get-channel-messages".to_string(), + summary: "Fetch Slack messages".to_string(), + commands: Vec::new(), + } + ); + } + + #[test] + fn sq_command_summary_matches_sq_menu_conventions() { + let summary = sq_command_summary( + "Use this tool to help the campaign manager to get the user's aggregated dashboard data.", + ); + + assert!(!summary.ends_with('.')); + assert!(summary.chars().count() < 80); + } + + #[test] + fn parse_bool_value_accepts_common_spellings() { + assert!(parse_bool_value("true").expect("bool")); + assert!(parse_bool_value("yes").expect("bool")); + assert!(!parse_bool_value("off").expect("bool")); + } + + #[test] + fn parse_header_requires_key_value_format() { + let error = parse_header("missing-separator").expect_err("header should fail"); + assert!(error.to_string().contains("expected KEY=VALUE")); + } + + #[test] + fn call_tool_response_serializes_as_json() { + let response = CallToolResponse { + content: Vec::new(), + is_error: Some(false), + structured_content_json: Some("{\"ok\":true}".to_string()), + }; + + let rendered = serde_json::to_string_pretty(&response).expect("serialize response"); + assert!(rendered.contains("\"is_error\": false")); + assert!(rendered.contains("\"structured_content_json\"")); + } + + #[test] + fn build_tool_request_allows_optional_boolean_flags_without_panicking() { + let loaded = LoadedExtension { + name: "slack".to_string(), + about: "Slack helpers".to_string(), + description: "Slack helpers".to_string(), + tools: vec![RuntimeTool { + extension_name: "slack".to_string(), + kgoose_name: "post_message".to_string(), + cli_name: "post-message".to_string(), + about: "Post a message".to_string(), + description: "Post a message".to_string(), + parameters: vec![ + ToolParameter { + name: "channel_id".to_string(), + cli_name: "channel-id".to_string(), + required: true, + description: Some("Slack channel ID".to_string()), + kind: ParameterKind::Scalar(ScalarKind::String { + enum_values: Vec::new(), + format: None, + }), + default: None, + }, + ToolParameter { + name: "dm_myself".to_string(), + cli_name: "dm-myself".to_string(), + required: false, + description: Some("Send the message to yourself".to_string()), + kind: ParameterKind::Scalar(ScalarKind::Boolean), + default: Some(json!(false)), + }, + ], + }], + }; + let extensions = vec![ExtensionSummary { + name: "slack".to_string(), + about: "Slack helpers".to_string(), + }]; + let command = build_command(&extensions, Some(&loaded)); + let matches = command + .try_get_matches_from([ + "agent-tools", + "slack", + "post-message", + "--channel-id", + "C123", + ]) + .expect("parse matches"); + let (_, extension_matches) = matches.subcommand().expect("extension"); + let (_, tool_matches) = extension_matches.subcommand().expect("tool"); + + let request = build_tool_request(&loaded.tools[0], tool_matches).expect("request"); + assert_eq!(request.arguments_json, r#"{"channel_id":"C123"}"#); + } + + #[test] + fn render_tool_response_prefers_structured_json() { + let response = CallToolResponse { + content: vec![ + UserContent { + content: Some(user_content::Content::Text(TextContent { + text: Some("# Messages".to_string()), + })), + }, + UserContent { + content: Some(user_content::Content::StructuredContent( + StructuredContent { + data: Some(pbjson_types::Struct { + fields: std::collections::HashMap::from([( + "result".to_string(), + pbjson_types::Value { + kind: Some(pbjson_types::value::Kind::BoolValue(true)), + }, + )]), + }), + }, + )), + }, + ], + is_error: Some(false), + structured_content_json: Some(r#"{"result":true}"#.to_string()), + }; + + let rendered = render_tool_response(&response, false).expect("render response"); + assert_eq!( + serde_json::from_str::(&rendered).expect("parse rendered JSON"), + json!({"result": true}) + ); + } + + #[test] + fn render_tool_response_parses_json_text_when_no_structured_output_exists() { + let response = CallToolResponse { + content: vec![UserContent { + content: Some(user_content::Content::Text(TextContent { + text: Some(r#"{"sum":5}"#.to_string()), + })), + }], + is_error: Some(false), + structured_content_json: None, + }; + + let rendered = render_tool_response(&response, false).expect("render response"); + assert_eq!( + serde_json::from_str::(&rendered).expect("parse rendered JSON"), + json!({"sum": 5}) + ); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4b061e8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + sq_kgoose::agent_tools_main(); +} diff --git a/src/proto.rs b/src/proto.rs new file mode 100644 index 0000000..e27a5be --- /dev/null +++ b/src/proto.rs @@ -0,0 +1,180 @@ +mod generated { + #![allow(dead_code)] + #![allow(deprecated)] + #![allow(clippy::all)] + + include!(concat!(env!("OUT_DIR"), "/proto.rs")); +} + +#[allow(unused_imports)] +pub use generated::*; + +mod generated_serde { + #![allow(deprecated)] + #![allow(unused_imports)] + + use super::generated::squareup::cash::kgoose::api::v3::*; + + include!(concat!( + env!("OUT_DIR"), + "/squareup.cash.kgoose.api.v3.serde.rs" + )); +} + +pub const LIST_EXTENSIONS_PATH: &str = "/v3/list-extensions"; +pub const LIST_TOOLS_PATH: &str = "/v3/list-tools"; +pub const CALL_TOOL_PATH: &str = "/v3/call-tool"; + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use prost::Message; + use tonic::{Request, Response, Status}; + + use super::squareup::cash::kgoose::api::v3::{ + self as kgoose_api_v3, tool_endpoint_service_server, user_content, CallToolRequest, + CallToolResponse, ExecuteToolRequest, ExecuteToolResponse, ListExtensionsRequest, + ListExtensionsResponse, ListToolsRequest, ListToolsResponse, Source, TextContent, + ToolConfig, UserContent, + }; + + #[test] + fn call_tool_request_round_trips_optional_fields_and_headers() { + let request = CallToolRequest { + extension_name: Some("jira".to_string()), + tool_name: Some("add_comment".to_string()), + arguments_json: Some(r#"{"text":"howdy"}"#.to_string()), + headers: HashMap::from([("x-request-id".to_string(), "req-123".to_string())]), + source: Some(Source::SqAgentTools.into()), + tenancy: None, + }; + + let decoded = + CallToolRequest::decode(request.encode_to_vec().as_slice()).expect("decode request"); + + assert_eq!(decoded.extension_name.as_deref(), Some("jira")); + assert_eq!(decoded.tool_name.as_deref(), Some("add_comment")); + assert_eq!( + decoded.arguments_json.as_deref(), + Some(r#"{"text":"howdy"}"#) + ); + assert_eq!( + decoded.headers.get("x-request-id").map(String::as_str), + Some("req-123") + ); + assert_eq!(decoded.source(), Source::SqAgentTools); + } + + #[test] + fn generated_messages_include_imported_types() { + let response = ListToolsResponse { + extension_name: Some("jira".to_string()), + extension_description: Some("Issue tracking tools".to_string()), + tools: vec![ToolConfig { + tool: Some("add_comment".to_string()), + description: Some("Add a comment to an issue".to_string()), + config_json: Some( + r#"{"type":"object","properties":{"text":{"type":"string"}}}"#.to_string(), + ), + meta_json: Some(r#"{"com.squareup.kgoose/mutates_state":true}"#.to_string()), + mutates_state: Some(true), + ..Default::default() + }], + }; + + let decoded = ListToolsResponse::decode(response.encode_to_vec().as_slice()) + .expect("decode response"); + + assert_eq!(decoded.extension_name.as_deref(), Some("jira")); + assert_eq!(decoded.tools.len(), 1); + assert_eq!(decoded.tools[0].tool.as_deref(), Some("add_comment")); + assert_eq!(decoded.tools[0].mutates_state, Some(true)); + } + + #[test] + fn call_tool_response_round_trips_imported_user_content() { + let response = CallToolResponse { + content: vec![UserContent { + content: Some(user_content::Content::Text(TextContent { + text: Some("tool output".to_string()), + })), + }], + is_error: Some(false), + structured_content_json: Some(r#"{"ok":true}"#.to_string()), + }; + + let decoded = + CallToolResponse::decode(response.encode_to_vec().as_slice()).expect("decode response"); + + assert_eq!(decoded.is_error, Some(false)); + assert_eq!( + decoded.structured_content_json.as_deref(), + Some(r#"{"ok":true}"#) + ); + + match decoded + .content + .first() + .and_then(|content| content.content.as_ref()) + { + Some(user_content::Content::Text(text)) => { + assert_eq!(text.text.as_deref(), Some("tool output")); + } + other => panic!("expected text user content, got {other:?}"), + } + } + + #[test] + fn generated_service_uses_expected_name() { + let _server = + tool_endpoint_service_server::ToolEndpointServiceServer::new(TestToolEndpointService); + + assert_eq!( + as tonic::server::NamedService>::NAME, + "squareup.cash.kgoose.api.v3.ToolEndpointService" + ); + } + + struct TestToolEndpointService; + + #[tonic::async_trait] + impl tool_endpoint_service_server::ToolEndpointService for TestToolEndpointService { + async fn list_extensions( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(ListExtensionsResponse::default())) + } + + async fn list_tools( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(ListToolsResponse::default())) + } + + async fn call_tool( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(CallToolResponse::default())) + } + + async fn execute_tool( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(ExecuteToolResponse::default())) + } + } + + #[test] + fn generated_client_module_is_available() { + let _client: Option< + kgoose_api_v3::tool_endpoint_service_client::ToolEndpointServiceClient< + tonic::transport::Channel, + >, + > = None; + } +} diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..10f6d1f --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,758 @@ +use anyhow::{Context, Result}; +use serde::Serialize; +use serde_json::Value; + +use crate::kgoose::{ + ExtensionInfo, KgooseClient, KgooseConfig, ListExtensionsResponse, ToolConfig, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExtensionSummary { + pub name: String, + pub about: String, +} + +#[derive(Debug, Clone)] +pub struct LoadedExtension { + pub name: String, + pub about: String, + pub description: String, + pub tools: Vec, +} + +#[derive(Debug, Clone)] +pub struct RuntimeTool { + pub extension_name: String, + pub kgoose_name: String, + pub cli_name: String, + pub about: String, + pub description: String, + pub parameters: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ToolParameter { + pub name: String, + pub cli_name: String, + pub required: bool, + pub description: Option, + pub kind: ParameterKind, + pub default: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ParameterKind { + Scalar(ScalarKind), + Array(ScalarKind), + Json, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ScalarKind { + String { + enum_values: Vec, + format: Option, + }, + Integer { + enum_values: Vec, + }, + Number { + enum_values: Vec, + }, + Boolean, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SchemaShape { + Null, + Scalar(ScalarShape), + Array(ScalarShape), + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ScalarShape { + String, + Integer, + Number, + Boolean, +} + +pub fn load_extensions( + client: &impl KgooseClient, + config: &KgooseConfig, +) -> Result> { + let response = client.list_extensions(config)?; + Ok(sort_extensions(response) + .into_iter() + .filter_map(|extension| { + let name = extension.name?; + Some(ExtensionSummary { + about: compact_text( + extension + .description + .as_deref() + .unwrap_or(&format!("{} tools", extension.tool_count.unwrap_or(0))), + ), + name, + }) + }) + .collect()) +} + +pub fn load_extension( + client: &impl KgooseClient, + config: &KgooseConfig, + extension_name: &str, + known_extensions: &[ExtensionSummary], +) -> Result { + let response = match client.list_tools(config, extension_name) { + Ok(response) => response, + Err(err) => { + // The static catalog covers all known extensions (both live API and late-init + // OAuth extensions like `asana` or `notion`). Using it here lets us distinguish + // "unknown extension" (typo) from "known but not yet connected" and surface a + // helpful G2 Connections hint for the latter. + let is_known = known_extensions + .iter() + .any(|extension| extension.name == extension_name); + + if is_known || known_extensions.is_empty() { + return Err(humanize_list_tools_error( + extension_name, + config.playpen.as_deref(), + &err, + )); + } + + anyhow::bail!( + "unknown extension `{extension_name}` (available: {})", + known_extensions + .iter() + .map(|extension| extension.name.as_str()) + .collect::>() + .join(", ") + ); + } + }; + + let mut tools = response + .tools + .iter() + .map(|tool| build_runtime_tool(extension_name, tool)) + .collect::>>()?; + tools.sort_by(|left, right| left.cli_name.cmp(&right.cli_name)); + let description = + extension_help_text(response.extension_description.as_deref(), "Extension tools"); + + Ok(LoadedExtension { + name: response + .extension_name + .clone() + .unwrap_or_else(|| extension_name.to_string()), + about: compact_text(&description), + description, + tools, + }) +} + +fn humanize_list_tools_error( + extension_name: &str, + playpen: Option<&str>, + err: &anyhow::Error, +) -> anyhow::Error { + // `{err:#}` prints the full context chain (e.g. "POST : error sending + // request: dns error"), not just the outermost context. Without the chain, a + // request that never left the machine looks like a backend response. + let raw = format!("{err:#}"); + let raw_lower = raw.to_ascii_lowercase(); + let playpen_suffix = playpen + .map(|playpen| format!(" in playpen `{playpen}`")) + .unwrap_or_default(); + + let reason = if raw_lower.contains("404 not found") + || raw_lower.contains("not authorized") + || raw_lower.contains("403 forbidden") + { + format!( + "Can't inspect `{extension_name}`{playpen_suffix}.\n\ + `{extension_name}` is visible in the extension list, but the backend service wouldn't return its tools.\n\ + This usually means the extension is not connected in your account.\n\ + Check your G2 Connections settings to verify the extension is connected: https://g2.sqprod.co/settings\n\ + Server response: {raw}" + ) + } else if raw_lower.contains("error sending request") { + format!( + "Can't inspect `{extension_name}`{playpen_suffix}.\n\ + The request never reached the backend service — this is a local network failure, not a server error.\n\ + This usually means the command ran without network access (for example inside a coding agent's sandbox, like Codex's default sandbox) or the corporate VPN (WARP) is off.\n\ + Error: {raw}" + ) + } else { + format!( + "Can't inspect `{extension_name}`{playpen_suffix}.\n\ + The backend service couldn't load the tool list for that extension.\n\ + Server response: {raw}" + ) + }; + + anyhow::anyhow!(reason) +} + +pub fn normalize_cli_name(name: &str) -> String { + name.replace('_', "-") +} + +pub fn compact_text(value: &str) -> String { + compact_text_with_limit(value, 88) +} + +pub fn compact_text_with_limit(value: &str, max_len: usize) -> String { + let summary = value + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("") + .trim(); + + truncate(summary, max_len) +} + +fn extension_help_text(value: Option<&str>, default: &str) -> String { + value + .map(str::trim) + .filter(|text| !text.is_empty()) + .unwrap_or(default) + .to_string() +} + +fn truncate(value: &str, max_len: usize) -> String { + let len = value.chars().count(); + if len <= max_len { + return value.to_string(); + } + + let truncated = value.chars().take(max_len).collect::(); + format!("{}...", truncated.trim_end()) +} + +fn sort_extensions(response: ListExtensionsResponse) -> Vec { + let mut extensions = response.extensions; + extensions.sort_by(|left, right| left.name.cmp(&right.name)); + extensions +} + +fn build_runtime_tool(extension_name: &str, tool: &ToolConfig) -> Result { + let raw_description = tool + .description + .as_deref() + .unwrap_or("No description provided."); + Ok(RuntimeTool { + extension_name: extension_name.to_string(), + kgoose_name: tool_name(tool).to_string(), + cli_name: normalize_cli_name(tool_name(tool)), + about: compact_text(raw_description), + description: raw_description.trim().to_string(), + parameters: extract_tool_parameters(tool.config_json.as_deref())?, + }) +} + +fn tool_name(tool: &ToolConfig) -> &str { + tool.tool.as_deref().unwrap_or("?") +} + +fn extract_tool_parameters(schema_json: Option<&str>) -> Result> { + let Some(schema_json) = schema_json else { + return Ok(Vec::new()); + }; + + let root = serde_json::from_str::(schema_json).context("parse tool input schema")?; + let schema = resolve_schema(&root, &root); + let Some(properties) = schema.get("properties").and_then(Value::as_object) else { + return Ok(Vec::new()); + }; + + let required = schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + + let mut parameters = properties + .iter() + .map(|(name, property)| { + let resolved = resolve_schema(property, &root); + Ok(ToolParameter { + name: name.clone(), + cli_name: normalize_cli_name(name), + required: required.iter().any(|required_name| required_name == name), + description: resolved + .get("description") + .and_then(Value::as_str) + .map(str::to_string), + kind: classify_parameter_kind(&root, resolved), + default: resolved.get("default").cloned(), + }) + }) + .collect::>>()?; + + parameters.sort_by(|left, right| { + right + .required + .cmp(&left.required) + .then_with(|| left.cli_name.cmp(&right.cli_name)) + }); + + Ok(parameters) +} + +fn classify_parameter_kind(root: &Value, schema: &Value) -> ParameterKind { + match classify_schema_shape(root, schema) { + SchemaShape::Scalar(shape) => ParameterKind::Scalar(scalar_kind(schema, &shape)), + SchemaShape::Array(shape) => array_item_schema(root, schema) + .map(|items| ParameterKind::Array(scalar_kind(items, &shape))) + .unwrap_or(ParameterKind::Json), + SchemaShape::Json | SchemaShape::Null => ParameterKind::Json, + } +} + +fn array_item_schema<'a>(root: &'a Value, schema: &'a Value) -> Option<&'a Value> { + let schema = resolve_schema(schema, root); + + if let Some(items) = schema.get("items") { + return Some(resolve_schema(items, root)); + } + + schema + .get("anyOf") + .and_then(Value::as_array)? + .iter() + .find_map(|variant| match classify_schema_shape(root, variant) { + SchemaShape::Array(_) => array_item_schema(root, variant), + _ => None, + }) +} + +fn scalar_kind(schema: &Value, shape: &ScalarShape) -> ScalarKind { + let enum_values = extract_enum_values(schema); + match shape { + ScalarShape::String => ScalarKind::String { + enum_values, + format: schema + .get("format") + .and_then(Value::as_str) + .map(str::to_string), + }, + ScalarShape::Integer => ScalarKind::Integer { enum_values }, + ScalarShape::Number => ScalarKind::Number { enum_values }, + ScalarShape::Boolean => ScalarKind::Boolean, + } +} + +fn classify_schema_shape(root: &Value, schema: &Value) -> SchemaShape { + let schema = resolve_schema(schema, root); + + if let Some(any_of) = schema.get("anyOf").and_then(Value::as_array) { + return classify_any_of(root, any_of); + } + + if schema + .get("properties") + .and_then(Value::as_object) + .is_some() + { + return SchemaShape::Json; + } + + match schema.get("type").and_then(Value::as_str) { + Some("null") => SchemaShape::Null, + Some("boolean") => SchemaShape::Scalar(ScalarShape::Boolean), + Some("string") => SchemaShape::Scalar(ScalarShape::String), + Some("integer") => SchemaShape::Scalar(ScalarShape::Integer), + Some("number") => SchemaShape::Scalar(ScalarShape::Number), + Some("array") => schema + .get("items") + .map(|items| classify_schema_shape(root, items)) + .and_then(|shape| match shape { + SchemaShape::Scalar(shape) => Some(SchemaShape::Array(shape)), + _ => None, + }) + .unwrap_or(SchemaShape::Json), + Some("object") => SchemaShape::Json, + _ => { + if schema.get("enum").and_then(Value::as_array).is_some() { + infer_enum_shape(schema).map_or(SchemaShape::Json, SchemaShape::Scalar) + } else { + SchemaShape::Json + } + } + } +} + +fn classify_any_of(root: &Value, any_of: &[Value]) -> SchemaShape { + let mut shapes = any_of + .iter() + .map(|schema| classify_schema_shape(root, schema)) + .filter(|shape| *shape != SchemaShape::Null) + .collect::>(); + + if shapes.is_empty() { + return SchemaShape::Null; + } + + let first = shapes.remove(0); + if shapes.iter().all(|shape| *shape == first) { + return first; + } + + if let Some(shape) = merge_any_of_scalars(&first, &shapes) { + return shape; + } + + SchemaShape::Json +} + +fn merge_any_of_scalars(first: &SchemaShape, rest: &[SchemaShape]) -> Option { + let SchemaShape::Scalar(first_scalar) = first else { + return None; + }; + + if rest.iter().all(|shape| { + matches!( + shape, + SchemaShape::Scalar(other) if other == first_scalar + ) + }) { + return Some(SchemaShape::Scalar(first_scalar.clone())); + } + + if *first_scalar == ScalarShape::String + && rest + .iter() + .all(|shape| matches!(shape, SchemaShape::Scalar(ScalarShape::String))) + { + return Some(SchemaShape::Scalar(ScalarShape::String)); + } + + None +} + +fn infer_enum_shape(schema: &Value) -> Option { + let mut values = schema.get("enum")?.as_array()?.iter(); + let first = values.next()?; + + let first_shape = match first { + Value::String(_) => ScalarShape::String, + Value::Number(number) if number.is_i64() || number.is_u64() => ScalarShape::Integer, + Value::Number(_) => ScalarShape::Number, + Value::Bool(_) => ScalarShape::Boolean, + _ => return None, + }; + + if values.all(|value| matches_enum_shape(value, &first_shape)) { + Some(first_shape) + } else { + None + } +} + +fn matches_enum_shape(value: &Value, shape: &ScalarShape) -> bool { + match shape { + ScalarShape::String => value.is_string(), + ScalarShape::Integer => value.as_i64().is_some() || value.as_u64().is_some(), + ScalarShape::Number => value.is_number(), + ScalarShape::Boolean => value.is_boolean(), + } +} + +fn extract_enum_values(schema: &Value) -> Vec { + schema + .get("enum") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +fn resolve_schema<'a>(schema: &'a Value, root: &'a Value) -> &'a Value { + let mut current = schema; + + while let Some(reference) = current.get("$ref").and_then(Value::as_str) { + let Some(path) = reference.strip_prefix("#/") else { + break; + }; + + let Some(resolved) = path + .split('/') + .try_fold(root, |value, segment| value.get(segment)) + else { + break; + }; + + current = resolved; + } + + current +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{ + load_extension, load_extensions, normalize_cli_name, ExtensionSummary, ParameterKind, + ScalarKind, ToolParameter, + }; + use crate::kgoose::{ + CallToolResponse, ExtensionInfo, KgooseClient, KgooseConfig, ListExtensionsResponse, + ListToolsResponse, ToolConfig, + }; + + struct TestKgooseClient; + + impl KgooseClient for TestKgooseClient { + fn list_extensions( + &self, + _config: &KgooseConfig, + ) -> anyhow::Result { + Ok(ListExtensionsResponse { + extensions: vec![ExtensionInfo { + name: Some("utils".to_string()), + description: Some("Utility helpers".to_string()), + tool_count: Some(1), + any_tool_requires_user_auth: Some(false), + auth_satisfied_for_caller: Some(true), + ..Default::default() + }], + }) + } + + fn list_tools( + &self, + _config: &KgooseConfig, + extension_name: &str, + ) -> anyhow::Result { + if extension_name != "utils" { + anyhow::bail!("unknown extension"); + } + + Ok(ListToolsResponse { + extension_name: Some("utils".to_string()), + extension_description: Some("Utility helpers".to_string()), + tools: vec![ToolConfig { + tool: Some("calculate".to_string()), + description: Some("Perform math".to_string()), + config_json: Some( + r#"{"type":"object","properties":{"numbers":{"type":"array","items":{"type":"number"}},"operation":{"type":"string","enum":["add","subtract"]}},"required":["numbers","operation"]}"# + .to_string(), + ), + mutates_state: Some(false), + ..Default::default() + }], + }) + } + + fn call_tool( + &self, + _config: &KgooseConfig, + _extension_name: &str, + _tool_name: &str, + _arguments_json: &str, + _headers: &BTreeMap, + ) -> anyhow::Result { + unreachable!("call_tool is not used during metadata loading") + } + } + + struct UnauthorizedKgooseClient; + + impl KgooseClient for UnauthorizedKgooseClient { + fn list_extensions( + &self, + _config: &KgooseConfig, + ) -> anyhow::Result { + Ok(ListExtensionsResponse { + extensions: vec![ExtensionInfo { + name: Some("airtable".to_string()), + description: Some("Airtable tools".to_string()), + tool_count: Some(4), + any_tool_requires_user_auth: Some(false), + auth_satisfied_for_caller: Some(false), + ..Default::default() + }], + }) + } + + fn list_tools( + &self, + _config: &KgooseConfig, + extension_name: &str, + ) -> anyhow::Result { + anyhow::bail!( + "POST /squareup.cash.kgoose.api.v3.ToolEndpointService/ListTools failed with 404 Not Found: Extension '{extension_name}' not found or not authorized" + ) + } + + fn call_tool( + &self, + _config: &KgooseConfig, + _extension_name: &str, + _tool_name: &str, + _arguments_json: &str, + _headers: &BTreeMap, + ) -> anyhow::Result { + unreachable!("call_tool is not used during metadata loading") + } + } + + struct ConnectFailureKgooseClient; + + impl KgooseClient for ConnectFailureKgooseClient { + fn list_extensions( + &self, + _config: &KgooseConfig, + ) -> anyhow::Result { + unreachable!("list_extensions is not used during metadata loading") + } + + fn list_tools( + &self, + _config: &KgooseConfig, + _extension_name: &str, + ) -> anyhow::Result { + Err(anyhow::anyhow!( + "error sending request for url (https://example.test/cash-app/goose/v3/list-tools): dns error: failed to lookup address information" + ) + .context("POST /cash-app/goose/v3/list-tools")) + } + + fn call_tool( + &self, + _config: &KgooseConfig, + _extension_name: &str, + _tool_name: &str, + _arguments_json: &str, + _headers: &BTreeMap, + ) -> anyhow::Result { + unreachable!("call_tool is not used during metadata loading") + } + } + + fn kgoose_config() -> KgooseConfig { + KgooseConfig { + base_url: "https://example.test".to_string(), + service_path: crate::bl::skills_config::DEFAULT_KGOOSE_SERVICE_PATH.to_string(), + playpen: Some("baxen".to_string()), + goosemcp_playpen: None, + timeout_secs: 600.0, + session_credential: None, + } + } + + #[test] + fn normalize_cli_name_replaces_underscores() { + assert_eq!( + normalize_cli_name("get_channel_messages"), + "get-channel-messages" + ); + } + + #[test] + fn load_extensions_builds_sorted_extension_summaries() { + let extensions = + load_extensions(&TestKgooseClient, &kgoose_config()).expect("load extensions"); + assert_eq!(extensions.len(), 1); + assert_eq!(extensions[0].name, "utils"); + assert_eq!(extensions[0].about, "Utility helpers"); + } + + #[test] + fn load_extension_discovers_tools_and_schema_parameters() { + let extension = + load_extension(&TestKgooseClient, &kgoose_config(), "utils", &[]).expect("extension"); + assert_eq!(extension.name, "utils"); + assert_eq!(extension.about, "Utility helpers"); + assert_eq!(extension.description, "Utility helpers"); + assert_eq!(extension.tools.len(), 1); + assert_eq!(extension.tools[0].cli_name, "calculate"); + assert_eq!( + extension.tools[0].parameters[0], + ToolParameter { + name: "numbers".to_string(), + cli_name: "numbers".to_string(), + required: true, + description: None, + kind: ParameterKind::Array(ScalarKind::Number { + enum_values: Vec::new(), + }), + default: None, + } + ); + } + + #[test] + fn load_extension_rewrites_inaccessible_errors_for_humans() { + let known = vec![ExtensionSummary { + name: "airtable".to_string(), + about: "Airtable tools".to_string(), + }]; + let error = load_extension( + &UnauthorizedKgooseClient, + &kgoose_config(), + "airtable", + &known, + ) + .expect_err("expected inaccessible extension"); + + let message = error.to_string(); + assert!(message.contains("Can't inspect `airtable`")); + assert!(message.contains("wouldn't return its tools")); + assert!(message.contains("G2 Connections settings")); + assert!(message.contains("Server response:")); + } + + #[test] + fn load_extension_rewrites_send_failures_as_local_network_errors() { + let known = vec![ExtensionSummary { + name: "sourcegraph".to_string(), + about: "Sourcegraph tools".to_string(), + }]; + let error = load_extension( + &ConnectFailureKgooseClient, + &kgoose_config(), + "sourcegraph", + &known, + ) + .expect_err("expected connect failure"); + + let message = error.to_string(); + assert!(message.contains("Can't inspect `sourcegraph`")); + assert!(message.contains("never reached the backend service")); + assert!(message.contains("network access")); + assert!(message.contains("dns error: failed to lookup address information")); + assert!( + !message.contains("Server response:"), + "a request that never sent must not claim a server response: {message}" + ); + } + + #[test] + fn load_extension_rewrites_catalog_known_inaccessible_errors_for_humans() { + let error = load_extension( + &UnauthorizedKgooseClient, + &kgoose_config(), + "asana", + &[ExtensionSummary { + name: "asana".to_string(), + about: "Asana tools".to_string(), + }], + ) + .expect_err("expected inaccessible extension"); + + let message = error.to_string(); + assert!(message.contains("Can't inspect `asana`")); + assert!(message.contains("wouldn't return its tools")); + assert!(message.contains("G2 Connections settings")); + } +} diff --git a/tests/bl_e2e.rs b/tests/bl_e2e.rs new file mode 100644 index 0000000..060f2f1 --- /dev/null +++ b/tests/bl_e2e.rs @@ -0,0 +1,4373 @@ +//! End-to-end tests for the `bl` binary (skills marketplace + bl-specific +//! surfaces). The sq/agent-tools CLI suite lives in `cli_e2e.rs`; shared mock +//! server infrastructure lives in `common/`. +mod common; + +use std::fs; +use std::io::{Cursor, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::thread; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use zip::write::SimpleFileOptions; + +use common::{ + bl_command, calculate_tool_schema, list_tools_response, output_text, temp_test_dir, + write_bl_org_config, write_extensions_catalog, MockResponse, MockServer, + BL_TOOLS_CALL_TOOL_PATH, BL_TOOLS_LIST_TOOLS_PATH, +}; + +// --------------------------------------------------------------------------- +// fixtures + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn skill_zip(entries: &[(&str, &str)]) -> Vec { + let cursor = Cursor::new(Vec::new()); + let mut zip = zip::ZipWriter::new(cursor); + let options = SimpleFileOptions::default(); + for (path, contents) in entries { + zip.start_file(path, options).expect("start zip file"); + zip.write_all(contents.as_bytes()).expect("write zip file"); + } + zip.finish().expect("finish zip").into_inner() +} + +fn marketplace_skill_summary() -> Value { + json!({ + "slug": "builderlab-tools", + "name": "BuilderLab Tools", + "description": "Use BuilderLab CLI tool wrappers from agent workflows.", + "status": "stable", + "visibility": "builtin", + "enabled": true, + "latest_version_id": "ver_builtin_builderlab_tools_0_1_0", + "latest_content_sha256": "content-sha", + "source_id": "src_builtin_builderlab", + "source_revision": "builtin:builderlab-tools:0.1.0", + "source_path": "builtin-skills/builderlab-tools", + "tags": ["builderlab", "tools"], + "teams": ["builderlab"], + "updated_at": "2026-06-08T00:00:00Z" + }) +} + +fn skill_page_response() -> MockResponse { + MockResponse::json(json!({ + "items": [marketplace_skill_summary()], + "next_cursor": null + })) +} + +/// `list`/`search` follow the skills page with a best-effort bundles fetch, +/// so most tests queue this empty bundles page right after the skills page. +fn empty_bundles_response() -> MockResponse { + MockResponse::json(json!({ "items": [], "next_cursor": null })) +} + +/// One bundle (`starter-pack`) that contains `builderlab-tools`, for tests +/// asserting bundle-membership annotations. +fn starter_pack_bundles_response() -> MockResponse { + MockResponse::json(json!({ + "items": [{ + "slug": "starter-pack", + "name": "Starter Pack", + "description": "Everything you need to get going.", + "status": "stable", + "enabled": true, + "skills": ["builderlab-tools"], + "resolved_skills_count": 1 + }], + "next_cursor": null + })) +} + +fn skill_detail_response() -> MockResponse { + MockResponse::json(json!({ + "slug": "builderlab-tools", + "name": "BuilderLab Tools", + "description": "Use BuilderLab CLI tool wrappers from agent workflows.", + "status": "stable", + "enabled": true, + "latest_version_id": "ver_builtin_builderlab_tools_0_1_0", + "latest_content_sha256": "content-sha", + "source_id": "src_builtin_builderlab", + "source_revision": "builtin:builderlab-tools:0.1.0", + "tags": ["builderlab", "tools"], + "dependencies": [], + "latest_version": null + })) +} + +fn agent_document(name: &str, body: &str) -> String { + format!("---\nname: {name}\ndescription: Writes release notes.\n---\n{body}\n") +} + +fn marketplace_agent_version(slug: &str, version_id: &str, content_sha256: &str) -> Value { + json!({ + "id": version_id, + "slug": slug, + "name": "Release Notes", + "status": "stable", + "content_sha256": content_sha256, + "artifact": { + "id": format!("art_{version_id}"), + "sha256": "read-artifact-sha", + "size_bytes": 1, + "media_type": "application/zip" + }, + "source": { + "source_id": "src_builtin_agents", + "snapshot_id": "snap_123", + "revision": "main@abc123", + "path": "agents/release-notes.md" + }, + "created_at": "2026-07-29T00:00:00Z" + }) +} + +fn marketplace_agent_detail(slug: &str, version_id: &str, content_sha256: &str) -> Value { + json!({ + "slug": slug, + "name": "Release Notes", + "description": "Writes release notes.", + "status": "stable", + "enabled": true, + "latest_version_id": version_id, + "latest_content_sha256": content_sha256, + "source_id": "src_builtin_agents", + "source_revision": "main@abc123", + "source_path": "agents/release-notes.md", + "tags": ["release"], + "latest_version": marketplace_agent_version(slug, version_id, content_sha256), + "versions": [{ + "id": version_id, + "status": "stable", + "content_sha256": content_sha256, + "created_at": "2026-07-29T00:00:00Z" + }] + }) +} + +fn marketplace_agent_summary(slug: &str, version_id: &str, content_sha256: &str) -> Value { + let mut summary = marketplace_agent_detail(slug, version_id, content_sha256); + let fields = summary.as_object_mut().expect("agent detail object"); + fields.remove("latest_version"); + fields.remove("versions"); + summary +} + +fn agent_install_plan( + slug: &str, + version_id: &str, + content_sha256: &str, + action: &str, + artifact: Option, +) -> MockResponse { + MockResponse::json(json!({ + "operations": [{ + "action": action, + "reason": if action == "noop" { "Already at the requested version." } else { "Install marketplace agent." }, + "kind": "agent", + "skill": { + "slug": slug, + "version_id": version_id, + "content_sha256": content_sha256 + }, + "artifact": artifact, + "installed_via": "explicit" + }] + })) +} + +fn agent_artifact(slug: &str, version_id: &str, bytes: Vec) -> (Value, MockResponse) { + let sha256 = sha256_hex(&bytes); + ( + json!({ + "id": format!("art_{version_id}"), + "download_url": format!("/v1/marketplace/artifacts/{slug}-{version_id}/download"), + "sha256": sha256, + "size_bytes": bytes.len(), + "media_type": "application/zip" + }), + MockResponse::bytes(200, bytes, &[]), + ) +} + +fn agent_target(home: &Path, slug: &str) -> PathBuf { + home.join(".agents") + .join("agents") + .join(format!("{slug}.md")) +} + +fn agent_state(bl_home: &Path, slug: &str) -> PathBuf { + bl_home + .join("agents") + .join("installed") + .join(format!("{slug}.json")) +} + +fn managed_agent_metadata(slug: &str, document: &[u8]) -> Value { + json!({ + "schema_version": "bl-agent-install/v1", + "kind": "agent", + "slug": slug, + "version_id": "agent-v1", + "content_sha256": "content-v1", + "installed_file_sha256": sha256_hex(document), + "artifact_id": "art_agent-v1", + "artifact_sha256": "artifact-sha", + "artifact_size_bytes": 42, + "artifact_media_type": "application/zip", + "source_id": "src_builtin_agents", + "source_snapshot_id": "snap_123", + "source_revision": "main@abc123", + "source_path": format!("agents/{slug}.md"), + "server_url": "http://example.test/api/goose", + "installed_at": "2026-07-29T00:00:00Z", + "installed_via": "explicit" + }) +} + +fn write_managed_agent(bl_home: &Path, home: &Path, slug: &str, document: &[u8]) { + let target = agent_target(home, slug); + let state = agent_state(bl_home, slug); + fs::create_dir_all(target.parent().expect("target parent")).expect("create target parent"); + fs::create_dir_all(state.parent().expect("state parent")).expect("create state parent"); + fs::write(&target, document).expect("write managed target"); + fs::write( + &state, + serde_json::to_vec(&managed_agent_metadata(slug, document)).expect("serialize state"), + ) + .expect("write managed state"); +} + +fn snapshot_agent_target(path: &Path) -> (bool, bool, Option>) { + let metadata = fs::symlink_metadata(path).expect("stat agent target"); + let file_type = metadata.file_type(); + let bytes = (file_type.is_file() || file_type.is_symlink()) + .then(|| fs::read(path).expect("read agent target")); + (file_type.is_dir(), file_type.is_symlink(), bytes) +} + +fn assert_agent_pair_unchanged( + target: &Path, + state: &Path, + target_before: &(bool, bool, Option>), + state_before: &Option>, +) { + assert_eq!(snapshot_agent_target(target), *target_before); + let state_after = fs::read(state).ok(); + assert_eq!(state_after, *state_before); +} + +fn assert_agent_failure( + output: &std::process::Output, + target: &Path, + state: &Path, + target_before: &(bool, bool, Option>), + state_before: &Option>, + exit_code: i32, + error_code: &str, +) { + let (stdout, stderr) = output_text(output); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert_eq!( + output.status.code(), + Some(exit_code), + "stderr was: {stderr}" + ); + let error = parse_stderr_error(&stderr); + assert_eq!(error["error"]["code"], error_code); + assert_eq!(error["error"]["exit_code"], exit_code); + assert_agent_pair_unchanged(target, state, target_before, state_before); +} + +#[test] +fn bl_agents_are_discoverable_and_install_requires_a_slug_without_network() { + let output = bl_command().arg("--help").output().expect("run bl help"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("agents"), "stdout was: {stdout}"); + + let server = MockServer::start(vec![]); + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .arg("--describe-commands") + .output() + .expect("run bl describe-commands"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let description = serde_json::from_str::(&stdout).expect("parse describe output"); + let agents = description["commands"] + .as_array() + .expect("root commands array") + .iter() + .find(|command| command["name"] == "agents") + .expect("agents command in public description"); + assert_eq!( + agents["commands"] + .as_array() + .expect("agents commands array") + .iter() + .map(|command| command["name"].as_str().expect("command name")) + .collect::>(), + [ + "list", + "search", + "show", + "install", + "update", + "installed", + "which", + "remove", + ] + ); + assert!(requests.is_empty(), "requests were: {requests:#?}"); + + let server = MockServer::start(vec![]); + let bl_home = temp_test_dir("bl-agents-install-missing-slug"); + write_bl_org_config(&bl_home, "test"); + let mut before = fs::read_dir(&bl_home) + .expect("read bl home before parsing") + .map(|entry| entry.expect("read bl home entry").file_name()) + .collect::>(); + before.sort(); + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["agents", "install"]) + .output() + .expect("run bl agents install without slug"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert_eq!(output.status.code(), Some(2), "stderr was: {stderr}"); + assert!(stderr.contains(""), "stderr was: {stderr}"); + assert!(requests.is_empty(), "requests were: {requests:#?}"); + let mut after = fs::read_dir(&bl_home) + .expect("read bl home after parsing") + .map(|entry| entry.expect("read bl home entry").file_name()) + .collect::>(); + after.sort(); + assert_eq!( + after, before, + "missing-slug parsing must not mutate BL_HOME" + ); + fs::remove_dir_all(bl_home).expect("remove bl home"); +} + +#[test] +fn bl_agents_update_requires_a_managed_install() { + let sandbox = temp_test_dir("bl-agents-update-absent"); + let bl_home = sandbox.join("bl-home"); + let home = sandbox.join("home"); + write_bl_org_config(&bl_home, "test"); + let server = MockServer::start(vec![]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("HOME", &home) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["agents", "update", "release-notes", "--json"]) + .output() + .expect("run bl agents update"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert_eq!(output.status.code(), Some(1), "stderr was: {stderr}"); + let error = parse_stderr_error(&stderr); + assert_eq!(error["error"]["code"], "not_installed"); + assert_eq!(error["error"]["exit_code"], 1); + assert!(requests.is_empty(), "absent update must stay local"); + assert!(!agent_target(&home, "release-notes").exists()); + assert!(!agent_state(&bl_home, "release-notes").exists()); + + fs::remove_dir_all(sandbox).expect("remove absent update sandbox"); +} + +#[test] +fn bl_agents_use_agent_routes_and_stable_catalog_output() { + let server = MockServer::start(vec![MockResponse::json(json!({ + "items": [marketplace_agent_summary("release-notes", "agent-v1", "content-v1")], + "next_cursor": null + }))]); + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["agents", "list"]) + .output() + .expect("run bl agents list"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!( + stdout.contains("release-notes Release Notes"), + "stdout was: {stdout}" + ); + assert!(stdout.contains("status: stable"), "stdout was: {stdout}"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "GET"); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/agents?limit=5000" + ); + + let server = MockServer::start(vec![MockResponse::json(json!({ + "items": [marketplace_agent_summary("release-notes", "agent-v1", "content-v1")], + "next_cursor": null + }))]); + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["agents", "search", "release notes", "--json"]) + .output() + .expect("run bl agents search"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse search JSON"); + assert_eq!(response["items"][0]["slug"], "release-notes"); + assert_eq!(response["items"][0]["source"]["id"], "src_builtin_agents"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "GET"); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/agents?limit=5000&query=release%20notes" + ); + + let server = MockServer::start(vec![MockResponse::json(marketplace_agent_detail( + "release-notes", + "agent-v1", + "content-v1", + ))]); + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["agents", "show", "release-notes", "--json"]) + .output() + .expect("run bl agents show"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse show JSON"); + assert_eq!(response["latest_version"]["id"], "agent-v1"); + assert_eq!(response["versions"][0]["content_sha256"], "content-v1"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "GET"); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/agents/release-notes" + ); +} + +#[test] +fn bl_agents_lifecycle_is_idempotent_and_local_queries_stay_offline() { + let sandbox = temp_test_dir("bl-agents-lifecycle"); + let bl_home = sandbox.join("bl-home"); + let home = sandbox.join("home"); + write_bl_org_config(&bl_home, "test"); + + let v1_document = agent_document("Release Notes", "Write version one."); + let v2_document = agent_document("Release Notes", "Write version two."); + let (v1_artifact, v1_response) = agent_artifact( + "release-notes", + "agent-v1", + skill_zip(&[("agent.md", &v1_document)]), + ); + let (v2_artifact, v2_response) = agent_artifact( + "release-notes", + "agent-v2", + skill_zip(&[("agent.md", &v2_document)]), + ); + let server = MockServer::start(vec![ + MockResponse::json(marketplace_agent_detail( + "release-notes", + "agent-v1", + "content-v1", + )), + agent_install_plan( + "release-notes", + "agent-v1", + "content-v1", + "install", + Some(v1_artifact), + ), + MockResponse::json(marketplace_agent_version( + "release-notes", + "agent-v1", + "content-v1", + )), + v1_response, + MockResponse::json(marketplace_agent_detail( + "release-notes", + "agent-v2", + "content-v2", + )), + agent_install_plan( + "release-notes", + "agent-v2", + "content-v2", + "update", + Some(v2_artifact), + ), + MockResponse::json(marketplace_agent_version( + "release-notes", + "agent-v2", + "content-v2", + )), + v2_response, + MockResponse::json(marketplace_agent_detail( + "release-notes", + "agent-v2", + "content-v2", + )), + agent_install_plan("release-notes", "agent-v2", "content-v2", "noop", None), + MockResponse::json(marketplace_agent_version( + "release-notes", + "agent-v2", + "content-v2", + )), + ]); + + let run = |arguments: &[&str]| { + bl_command() + .env("BL_HOME", &bl_home) + .env("HOME", &home) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(arguments) + .output() + .expect("run bl agents lifecycle command") + }; + + let install = run(&["agents", "install", "release-notes", "--json"]); + let (stdout, stderr) = output_text(&install); + assert!(install.status.success(), "stderr was: {stderr}"); + let install = serde_json::from_str::(&stdout).expect("parse install JSON"); + assert_eq!(install["status"], "installed"); + assert_eq!(install["version_id"], "agent-v1"); + assert_eq!(install["source"]["snapshot_id"], "snap_123"); + + let update = run(&["agents", "update", "release-notes", "--json"]); + let (stdout, stderr) = output_text(&update); + assert!(update.status.success(), "stderr was: {stderr}"); + let update = serde_json::from_str::(&stdout).expect("parse update JSON"); + assert_eq!(update["status"], "updated"); + assert_eq!(update["version_id"], "agent-v2"); + + let target = agent_target(&home, "release-notes"); + let state = agent_state(&bl_home, "release-notes"); + assert_eq!( + fs::read(&target).expect("read installed agent"), + v2_document.as_bytes() + ); + let persisted = serde_json::from_slice::(&fs::read(&state).expect("read state")) + .expect("parse persisted state"); + assert_eq!(persisted["schema_version"], "bl-agent-install/v1"); + assert_eq!(persisted["kind"], "agent"); + assert_eq!(persisted["slug"], "release-notes"); + assert_eq!(persisted["version_id"], "agent-v2"); + assert_eq!(persisted["content_sha256"], "content-v2"); + assert_eq!( + persisted["installed_file_sha256"], + sha256_hex(v2_document.as_bytes()) + ); + assert_eq!(persisted["artifact_id"], "art_agent-v2"); + assert_eq!(persisted["source_id"], "src_builtin_agents"); + assert_eq!(persisted["source_snapshot_id"], "snap_123"); + assert_eq!(persisted["source_revision"], "main@abc123"); + assert_eq!(persisted["source_path"], "agents/release-notes.md"); + let target_before_noop = fs::read(&target).expect("snapshot target before noop"); + let state_before_noop = fs::read(&state).expect("snapshot state before noop"); + let target_mtime_before_noop = fs::metadata(&target) + .expect("stat target before noop") + .modified() + .expect("read target mtime before noop"); + let state_mtime_before_noop = fs::metadata(&state) + .expect("stat state before noop") + .modified() + .expect("read state mtime before noop"); + + let noop = run(&["agents", "update", "release-notes", "--json"]); + let (stdout, stderr) = output_text(&noop); + assert!(noop.status.success(), "stderr was: {stderr}"); + let noop = serde_json::from_str::(&stdout).expect("parse noop JSON"); + assert_eq!(noop["status"], "up_to_date"); + assert_eq!( + fs::read(&target).expect("read target after noop"), + target_before_noop, + "up-to-date must not rewrite the managed document" + ); + assert_eq!( + fs::read(&state).expect("read state after noop"), + state_before_noop, + "up-to-date must not rewrite the managed state" + ); + assert_eq!( + fs::metadata(&target) + .expect("stat target after noop") + .modified() + .expect("read target mtime after noop"), + target_mtime_before_noop, + "up-to-date must preserve the managed document modification time" + ); + assert_eq!( + fs::metadata(&state) + .expect("stat state after noop") + .modified() + .expect("read state mtime after noop"), + state_mtime_before_noop, + "up-to-date must preserve the managed state modification time" + ); + + let requests = server.finish(); + assert_eq!(requests.len(), 11, "noop must not download an artifact"); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/agents/release-notes" + ); + assert_eq!(requests[1].method, "POST"); + assert_eq!(requests[1].path, "/api/goose/v1/marketplace/install-plan"); + assert_eq!( + requests[1].body["targets"], + json!([{"type": "agent", "slug": "release-notes"}]) + ); + assert_eq!( + requests[2].path, + "/api/goose/v1/marketplace/agents/release-notes/versions/agent-v1" + ); + assert_eq!( + requests[3].path, + "/api/goose/v1/marketplace/artifacts/release-notes-agent-v1/download" + ); + assert_eq!(requests[5].body["installed"][0]["version_id"], "agent-v1"); + assert_eq!(requests[9].body["installed"][0]["version_id"], "agent-v2"); + + let server = MockServer::start(vec![]); + let run_offline = |arguments: &[&str]| { + bl_command() + .env("BL_HOME", &bl_home) + .env("HOME", &home) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(arguments) + .output() + .expect("run local bl agents command") + }; + let installed = run_offline(&["agents", "installed", "--json"]); + let (stdout, stderr) = output_text(&installed); + assert!(installed.status.success(), "stderr was: {stderr}"); + let installed = serde_json::from_str::(&stdout).expect("parse installed JSON"); + assert_eq!(installed["items"][0]["status"], "installed"); + assert_eq!( + installed["items"][0]["path"], + target.to_string_lossy().as_ref() + ); + + let installed_human = run_offline(&["agents", "installed"]); + let (stdout, stderr) = output_text(&installed_human); + assert!(installed_human.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("release-notes"), "stdout was: {stdout}"); + assert!(stdout.contains("version: agent-v2"), "stdout was: {stdout}"); + + let which = run_offline(&["agents", "which", "release-notes", "--json"]); + let (stdout, stderr) = output_text(&which); + assert!(which.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(&stdout).expect("parse which JSON")["version_id"], + "agent-v2" + ); + + let removed = run_offline(&["agents", "remove", "release-notes", "--json"]); + let (stdout, stderr) = output_text(&removed); + assert!(removed.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(&stdout).expect("parse remove JSON")["status"], + "removed" + ); + assert!(!target.exists()); + assert!(!state.exists()); + + let absent = run_offline(&["agents", "remove", "release-notes", "--json"]); + let (stdout, stderr) = output_text(&absent); + assert!(absent.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(&stdout).expect("parse absent JSON")["status"], + "already_absent" + ); + let requests = server.finish(); + assert!( + requests.is_empty(), + "local ownership queries must not use the network" + ); + fs::remove_dir_all(sandbox).expect("remove lifecycle sandbox"); +} + +#[test] +fn bl_agents_report_local_ownership_conflicts_without_modifying_user_content() { + let sandbox = temp_test_dir("bl-agents-conflicts"); + let bl_home = sandbox.join("bl-home"); + let home = sandbox.join("home"); + write_bl_org_config(&bl_home, "test"); + let document = agent_document("Release Notes", "Managed content."); + + write_managed_agent(&bl_home, &home, "alpha", document.as_bytes()); + write_managed_agent(&bl_home, &home, "bravo", document.as_bytes()); + fs::remove_file(agent_target(&home, "bravo")).expect("remove managed target for missing case"); + + let malformed_state = agent_state(&bl_home, "invalid!"); + fs::create_dir_all(malformed_state.parent().expect("state parent")) + .expect("create state parent"); + fs::write(&malformed_state, "not json").expect("write malformed state"); + + let target_only = agent_target(&home, "target-only"); + fs::create_dir_all(target_only.parent().expect("target parent")).expect("create target parent"); + fs::write(&target_only, "local target only").expect("write target-only agent"); + + write_managed_agent(&bl_home, &home, "changed", document.as_bytes()); + let changed_target = agent_target(&home, "changed"); + fs::write(&changed_target, "local changes").expect("change managed target"); + + let mismatched_target = agent_target(&home, "mismatched"); + let mismatched_state = agent_state(&bl_home, "mismatched"); + fs::create_dir_all(mismatched_target.parent().expect("target parent")) + .expect("create target parent"); + fs::create_dir_all(mismatched_state.parent().expect("state parent")) + .expect("create state parent"); + fs::write(&mismatched_target, document.as_bytes()).expect("write mismatched target"); + let mut mismatched_metadata = managed_agent_metadata("another-agent", document.as_bytes()); + mismatched_metadata["slug"] = json!("another-agent"); + fs::write( + &mismatched_state, + serde_json::to_vec(&mismatched_metadata).expect("serialize mismatched state"), + ) + .expect("write mismatched state"); + + let directory_target = agent_target(&home, "directory"); + fs::create_dir_all(&directory_target).expect("create directory target"); + let directory_state = agent_state(&bl_home, "directory"); + fs::create_dir_all(directory_state.parent().expect("state parent")) + .expect("create state parent"); + fs::write( + &directory_state, + serde_json::to_vec(&managed_agent_metadata("directory", document.as_bytes())) + .expect("serialize directory state"), + ) + .expect("write directory state"); + + #[cfg(unix)] + let symlink_target = { + let target = agent_target(&home, "symlink"); + let destination = sandbox.join("local-agent.md"); + fs::write(&destination, "linked local content").expect("write symlink destination"); + std::os::unix::fs::symlink(&destination, &target).expect("create agent symlink"); + let state = agent_state(&bl_home, "symlink"); + fs::create_dir_all(state.parent().expect("state parent")).expect("create state parent"); + fs::write( + &state, + serde_json::to_vec(&managed_agent_metadata("symlink", document.as_bytes())) + .expect("serialize symlink state"), + ) + .expect("write symlink state"); + target + }; + + let server = MockServer::start(vec![]); + let run = |arguments: &[&str]| { + bl_command() + .env("BL_HOME", &bl_home) + .env("HOME", &home) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(arguments) + .output() + .expect("run local ownership command") + }; + + let installed = run(&["agents", "installed", "--json"]); + let (stdout, stderr) = output_text(&installed); + assert!(installed.status.success(), "stderr was: {stderr}"); + let installed = serde_json::from_str::(&stdout).expect("parse installed JSON"); + let items = installed["items"].as_array().expect("installed items"); + assert_eq!( + items + .iter() + .map(|item| item["slug"].as_str().expect("slug")) + .collect::>(), + [ + "alpha", + "bravo", + "changed", + "directory", + "invalid!", + "mismatched", + "symlink" + ], + "installed records must be sorted by slug" + ); + assert_eq!(items[0]["status"], "installed"); + assert_eq!(items[1]["status"], "missing"); + assert!(items + .iter() + .any(|item| item["slug"] == "invalid!" && item["status"] == "conflict")); + + let missing = run(&["agents", "which", "bravo", "--json"]); + let (stdout, stderr) = output_text(&missing); + assert!(missing.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(&stdout).expect("parse missing JSON")["status"], + "missing" + ); + + for (slug, preserved_path) in [ + ("target-only", &target_only), + ("changed", &changed_target), + ("mismatched", &mismatched_target), + ("directory", &directory_target), + #[cfg(unix)] + ("symlink", &symlink_target), + ] { + let state = agent_state(&bl_home, slug); + let target_before = snapshot_agent_target(preserved_path); + let state_before = fs::read(&state).ok(); + for command in ["install", "update", "remove"] { + let output = run(&["agents", command, slug, "--json"]); + let (_stdout, stderr) = output_text(&output); + assert_eq!(output.status.code(), Some(7), "stderr was: {stderr}"); + let error = parse_stderr_error(&stderr); + assert_eq!(error["error"]["code"], "agent_conflict"); + assert_eq!(error["error"]["details"]["slug"], slug); + assert_agent_pair_unchanged(preserved_path, &state, &target_before, &state_before); + } + } + + let requests = server.finish(); + assert!( + requests.is_empty(), + "protected local content must not trigger marketplace requests" + ); + fs::remove_dir_all(sandbox).expect("remove conflict sandbox"); +} + +#[test] +fn bl_agents_preserve_managed_pairs_for_failure_envelopes() { + let sandbox = temp_test_dir("bl-agents-failure-envelopes"); + let bl_home = sandbox.join("bl-home"); + let home = sandbox.join("home"); + let document = agent_document("Release Notes", "Managed content."); + write_bl_org_config(&bl_home, "test"); + write_managed_agent(&bl_home, &home, "release-notes", document.as_bytes()); + let target = agent_target(&home, "release-notes"); + let state = agent_state(&bl_home, "release-notes"); + let target_before = snapshot_agent_target(&target); + let state_before = fs::read(&state).ok(); + let run = |server: &MockServer, arguments: &[&str]| { + bl_command() + .env("BL_HOME", &bl_home) + .env("HOME", &home) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(arguments) + .output() + .expect("run bl agents failure command") + }; + + let server = MockServer::start(vec![marketplace_error_response( + 404, + "agent_not_found", + "Agent was not found.", + "req_agent_marketplace", + )]); + let output = run(&server, &["agents", "list", "--json"]); + let requests = server.finish(); + assert_agent_failure( + &output, + &target, + &state, + &target_before, + &state_before, + 1, + "agent_not_found", + ); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/agents?limit=5000" + ); + + let server = MockServer::start(vec![marketplace_error_response( + 401, + "authentication_required", + "Sign in before using marketplace agents.", + "req_agent_auth", + )]); + let output = run(&server, &["agents", "list", "--json"]); + let requests = server.finish(); + assert_agent_failure( + &output, + &target, + &state, + &target_before, + &state_before, + 3, + "authentication_required", + ); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/agents?limit=5000" + ); + + let server = MockServer::start(vec![ + MockResponse::json(marketplace_agent_detail( + "release-notes", + "agent-v2", + "content-v2", + )), + marketplace_error_response( + 422, + "agent_plan_blocked", + "Agent install plan is blocked.", + "req_agent_plan", + ), + ]); + let output = run(&server, &["agents", "update", "release-notes", "--json"]); + let requests = server.finish(); + assert_agent_failure( + &output, + &target, + &state, + &target_before, + &state_before, + 6, + "agent_plan_blocked", + ); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].path, "/api/goose/v1/marketplace/install-plan"); + + let server = MockServer::start(vec![ + MockResponse::json(marketplace_agent_detail( + "release-notes", + "agent-v2", + "content-v2", + )), + MockResponse::json(json!({ + "operations": [{ + "action": "update", + "reason": "Invalid operation kind.", + "kind": "skill", + "skill": { + "slug": "release-notes", + "version_id": "agent-v2", + "content_sha256": "content-v2" + }, + "artifact": null, + "installed_via": "explicit" + }] + })), + ]); + let output = run(&server, &["agents", "update", "release-notes", "--json"]); + let _requests = server.finish(); + assert_agent_failure( + &output, + &target, + &state, + &target_before, + &state_before, + 8, + "invalid_agent_operation_kind", + ); + + let zip = skill_zip(&[("agent.md", &document)]); + let (artifact, _response) = agent_artifact("release-notes", "agent-v2", zip); + let server = MockServer::start(vec![ + MockResponse::json(marketplace_agent_detail( + "release-notes", + "agent-v2", + "content-v2", + )), + agent_install_plan( + "release-notes", + "agent-v2", + "content-v2", + "update", + Some(artifact), + ), + MockResponse::json(marketplace_agent_version( + "release-notes", + "agent-v2", + "content-v2", + )), + marketplace_error_response( + 403, + "agent_artifact_forbidden", + "Agent artifact is not authorized.", + "req_agent_artifact", + ), + ]); + let output = run(&server, &["agents", "update", "release-notes", "--json"]); + let requests = server.finish(); + assert_agent_failure( + &output, + &target, + &state, + &target_before, + &state_before, + 4, + "agent_artifact_forbidden", + ); + assert_eq!(requests.len(), 4); + + let lock = bl_home + .join("agents") + .join("locks") + .join("release-notes.lock"); + fs::create_dir_all(lock.parent().expect("lock parent")).expect("create lock parent"); + fs::write(&lock, "locked").expect("write active lock"); + let server = MockServer::start(vec![]); + let output = run(&server, &["agents", "update", "release-notes", "--json"]); + let requests = server.finish(); + assert_agent_failure( + &output, + &target, + &state, + &target_before, + &state_before, + 7, + "agent_locked", + ); + assert!(requests.is_empty(), "filesystem failure must stay local"); + + fs::remove_dir_all(sandbox).expect("remove failure sandbox"); +} + +/// Server capabilities pointing the `agents` target at a directory we control, +/// so installs link into the test sandbox instead of the real home directory. +fn capabilities_response(agents_dir: &Path) -> MockResponse { + MockResponse::json(json!({ + "target_registry": { + "agents": { + "enabled": true, + "global_paths": [format!("{}", agents_dir.display())], + "project_paths": ["./.agents/skills"], + "link_strategies": ["symlink"] + } + } + })) +} + +fn capabilities_response_for_target(target: &str, target_dir: &Path) -> MockResponse { + MockResponse::json(json!({ + "target_registry": { + target: { + "enabled": true, + "global_paths": [format!("{}", target_dir.display())], + "project_paths": ["./.agents/skills"], + "link_strategies": ["symlink"] + } + } + })) +} + +fn marketplace_install_plan(zip_bytes: &[u8], artifact_sha: &str, artifact_size: usize) -> Value { + json!({ + "plan_id": "plan_phase1_builderlab_tools", + "expires_at": "2026-06-08T01:00:00Z", + "operations": [{ + "action": "install", + "reason": "Install latest stable built-in skill artifact.", + "skill": { + "slug": "builderlab-tools", + "version_id": "ver_builtin_builderlab_tools_0_1_0", + "content_sha256": sha256_hex(zip_bytes) + }, + "artifact": { + "id": "art_builderlab_tools", + "download_url": "/v1/marketplace/artifacts/art_builderlab_tools/download", + "sha256": artifact_sha, + "size_bytes": artifact_size, + "media_type": "application/zip" + }, + "installed_via": "explicit", + "requires_setup": false + }], + "warnings": [] + }) +} + +fn noop_plan_response() -> MockResponse { + MockResponse::json(json!({ + "plan_id": "plan_noop", + "operations": [{ + "action": "noop", + "reason": "Already at the latest version.", + "skill": { + "slug": "builderlab-tools", + "version_id": "ver_builtin_builderlab_tools_0_1_0", + "content_sha256": "content-sha" + }, + "artifact": null, + "installed_via": "explicit" + }], + "warnings": [] + })) +} + +fn artifact_response(zip_bytes: Vec, sha: &str) -> MockResponse { + MockResponse::bytes( + 200, + zip_bytes.clone(), + &[ + ("Content-Type", "application/zip".to_string()), + ("X-Artifact-SHA256", sha.to_string()), + ("X-Artifact-Size", zip_bytes.len().to_string()), + ], + ) +} + +fn marketplace_error_response( + status: u16, + code: &str, + message: &str, + request_id: &str, +) -> MockResponse { + MockResponse::bytes( + status, + serde_json::to_vec(&json!({ + "error": { + "code": code, + "message": message, + "request_id": request_id, + "retryable": false, + "details": [{ + "path": "skills/builderlab-tools/SKILL.md", + "field": "description", + "message": "description is required" + }] + } + })) + .expect("serialize marketplace error"), + &[("Content-Type", "application/json".to_string())], + ) +} + +/// Seeds `/packages/` with a SKILL.md and install metadata +/// as if a previous `bl skills install` had completed. +fn write_installed_package(skills_home: &Path, slug: &str, content_sha: &str, targets: &[&str]) { + let package = skills_home.join("packages").join(slug); + fs::create_dir_all(&package).expect("create package dir"); + fs::write(package.join("SKILL.md"), "# BuilderLab Tools\n").expect("write SKILL.md"); + fs::write( + package.join(".bl-skills-meta.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": "bl-skills-install/v1", + "server_url": "http://marketplace.local", + "slug": slug, + "version_id": "ver_builtin_builderlab_tools_0_1_0", + "content_sha256": content_sha, + "artifact_sha256": "artifact-sha", + "artifact_size_bytes": 123, + "installed_at": "2026-06-10T00:00:00Z", + "installed_via": "explicit", + "source_id": null, + "source_revision": null, + "scope": "global", + "targets": targets, + "local_source": false, + "pinned": false + })) + .expect("serialize metadata"), + ) + .expect("write metadata"); +} + +/// Seeds the offline capabilities cache so commands that never reach the +/// server (`remove`, `which`) resolve targets to a sandboxed directory. +fn write_capabilities_cache(skills_home: &Path, agents_dir: &Path) { + let cache_dir = skills_home.join("cache"); + fs::create_dir_all(&cache_dir).expect("create cache dir"); + fs::write( + cache_dir.join("capabilities.json"), + serde_json::to_vec(&json!({ + "target_registry": { + "agents": { + "enabled": true, + "global_paths": [format!("{}", agents_dir.display())], + "project_paths": ["./.agents/skills"], + "link_strategies": ["symlink"] + } + } + })) + .expect("serialize capabilities cache"), + ) + .expect("write capabilities cache"); +} + +fn parse_stderr_error(stderr: &str) -> Value { + serde_json::from_str::(stderr.trim()) + .unwrap_or_else(|err| panic!("stderr should be one JSON error object ({err}): {stderr}")) +} + +// --------------------------------------------------------------------------- +// bl root surfaces + +#[test] +fn bl_root_help_lists_apps_skills_and_tools() { + let output = bl_command().arg("--help").output().expect("run bl help"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("apps")); + assert!(!stdout.contains("appkit")); + assert!(stdout.contains("auth")); + assert!(stdout.contains("config")); + assert!(stdout.contains("skills")); + assert!(stdout.contains("tools")); + assert!(stdout.contains("BuilderLab command line tools")); + assert!(!stdout.contains("--local-dev")); +} + +#[test] +fn bl_root_description_lists_apps_not_appkit() { + let output = bl_command() + .arg("--describe-commands") + .output() + .expect("describe bl commands"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let description = serde_json::from_str::(&stdout).expect("parse command description"); + let command_names = description["commands"] + .as_array() + .expect("commands array") + .iter() + .filter_map(|command| command["name"].as_str()) + .collect::>(); + assert!(command_names.contains(&"apps")); + assert!(!command_names.contains(&"appkit")); +} + +#[test] +fn bl_tools_root_help_does_not_require_org() { + let temp = temp_test_dir("bl-tools-help-no-org"); + let bl_home = temp.join("bl-home"); + fs::create_dir_all(&bl_home).expect("create bl home"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .args(["tools", "--help"]) + .output() + .expect("run bl tools help"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("Discover auth-backed tool extensions")); + assert!(!stderr.contains("org_required"), "stderr was: {stderr}"); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_completions_emit_shell_script() { + let output = bl_command() + .args(["completions", "bash"]) + .output() + .expect("run bl completions"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("bl"), "stdout was: {stdout}"); + assert!( + stdout.contains("bl,agents)"), + "completion script missing agents root transition: {stdout}" + ); + for subcommand in [ + "list", + "search", + "show", + "install", + "update", + "installed", + "which", + "remove", + ] { + assert!( + stdout.contains(&format!("bl__subcmd__agents,{subcommand})")), + "completion script missing agents transition for {subcommand}: {stdout}" + ); + } + assert!( + stdout.len() > 100, + "completion script looks empty: {stdout}" + ); +} + +// --------------------------------------------------------------------------- +// discovery: list / search / show / bundles + +#[test] +fn bl_skills_list_fetches_marketplace_skills_and_bundle_membership() { + let server = MockServer::start(vec![skill_page_response(), starter_pack_bundles_response()]); + + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "list", "--json"]) + .output() + .expect("run bl skills list"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse list output"); + assert_eq!(response["items"][0]["slug"], json!("builderlab-tools")); + assert_eq!(response["items"][0]["installed"], json!(false)); + assert_eq!(response["items"][0]["update_available"], Value::Null); + assert_eq!(response["items"][0]["bundles"], json!(["starter-pack"])); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].method, "GET"); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/skills?limit=5000" + ); + assert_eq!(requests[1].method, "GET"); + assert_eq!( + requests[1].path, + "/api/goose/v1/marketplace/bundles?limit=5000" + ); +} + +#[test] +fn bl_skills_list_uses_custom_kgoose_service_path() { + let server = MockServer::start(vec![skill_page_response(), starter_pack_bundles_response()]); + + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .env("KGOOSE_SERVICE_PATH", "/cash-app/goose-square") + .args(["skills", "list", "--json"]) + .output() + .expect("run bl skills list"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].path, + "/cash-app/goose-square/v1/marketplace/skills?limit=5000" + ); + assert_eq!( + requests[1].path, + "/cash-app/goose-square/v1/marketplace/bundles?limit=5000" + ); +} + +#[test] +fn bl_skills_list_formats_marketplace_skills_for_humans() { + let server = MockServer::start(vec![skill_page_response(), starter_pack_bundles_response()]); + + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "list"]) + .output() + .expect("run bl skills list"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stderr.is_empty(), "stderr was: {stderr}"); + assert!(stdout.contains("Available (1):"), "stdout was: {stdout}"); + assert!( + stdout.contains(" builderlab-tools [stable]"), + "stdout was: {stdout}" + ); + assert!( + stdout.contains(" Use BuilderLab CLI tool wrappers from agent workflows."), + "stdout was: {stdout}" + ); + assert!( + stdout.contains(" name: BuilderLab Tools"), + "stdout was: {stdout}" + ); + assert!( + stdout.contains(" tags: builderlab, tools"), + "stdout was: {stdout}" + ); + assert!( + stdout.contains(" bundles: starter-pack"), + "stdout was: {stdout}" + ); + assert!( + stdout.contains("Install one with: bl skills install "), + "stdout was: {stdout}" + ); +} + +#[test] +fn bl_skills_list_groups_installed_and_available_skills() { + let temp = temp_test_dir("bl-skills-list-grouped"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + write_installed_package(&skills_home, "builderlab-tools", "content-sha", &["agents"]); + let server = MockServer::start(vec![ + MockResponse::json(json!({ + "items": [ + marketplace_skill_summary(), + { + "slug": "git-fixture", + "name": "Git Fixture", + "description": "Git fixture skill.", + "status": "stable", + "enabled": true, + "latest_version_id": "ver_git_fixture_0_1_0", + "latest_content_sha256": "git-fixture-sha", + "tags": ["git"] + } + ], + "next_cursor": null + })), + empty_bundles_response(), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "list"]) + .output() + .expect("run bl skills list"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("Installed (1):"), "stdout was: {stdout}"); + assert!(stdout.contains("Available (1):"), "stdout was: {stdout}"); + // The installed skill carries its local version and freshness marker... + assert!( + stdout.contains(" builderlab-tools [stable] (up to date)"), + "stdout was: {stdout}" + ); + assert!( + stdout.contains(" version: ver_builtin_builderlab_tools_0_1_0"), + "stdout was: {stdout}" + ); + assert!( + stdout.contains(" targets: agents"), + "stdout was: {stdout}" + ); + // ...and the installed section comes before the available one. + let installed_at = stdout.find("Installed (1):").expect("installed section"); + let available_at = stdout.find("Available (1):").expect("available section"); + assert!(installed_at < available_at, "stdout was: {stdout}"); + assert!( + stdout.contains(" git-fixture [stable]"), + "stdout was: {stdout}" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_search_passes_query_filter() { + let server = MockServer::start(vec![skill_page_response(), empty_bundles_response()]); + + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "search", "builder", "--json"]) + .output() + .expect("run bl skills search"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse search output"); + assert_eq!(response["items"][0]["slug"], json!("builderlab-tools")); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/skills?limit=5000&query=builder" + ); + assert_eq!( + requests[1].path, + "/api/goose/v1/marketplace/bundles?limit=5000" + ); +} + +#[test] +fn bl_skills_show_prints_skill_detail() { + let server = MockServer::start(vec![skill_detail_response()]); + + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "show", "builderlab-tools", "--json"]) + .output() + .expect("run bl skills show"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse show output"); + assert_eq!(response["slug"], json!("builderlab-tools")); + assert_eq!( + response["latest_version_id"], + json!("ver_builtin_builderlab_tools_0_1_0") + ); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/skills/builderlab-tools" + ); +} + +#[test] +fn bl_skills_bundles_lists_bundles() { + let server = MockServer::start(vec![MockResponse::json(json!({ + "items": [{ + "slug": "starter-pack", + "name": "Starter Pack", + "description": "Everything you need to get going.", + "status": "stable", + "enabled": true, + "skills": ["builderlab-tools"], + "resolved_skills_count": 1 + }], + "next_cursor": null + }))]); + + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "bundles", "--json"]) + .output() + .expect("run bl skills bundles"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse bundles output"); + assert_eq!(response["items"][0]["slug"], json!("starter-pack")); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/bundles?limit=5000" + ); +} + +// --------------------------------------------------------------------------- +// config & auth resolution + +#[test] +fn bl_skills_ignores_legacy_profile_server_url_and_auth() { + let server = MockServer::start(vec![skill_page_response(), empty_bundles_response()]); + let temp = temp_test_dir("bl-skills-profile-config"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + fs::create_dir_all(&bl_home).expect("create bl home"); + fs::write( + bl_home.join("skills.yaml"), + format!( + "current_profile: local\nprofiles:\n local:\n server_url: {}\n auth:\n token: profile-token\n", + server.base_url + ), + ) + .expect("write skills config"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "list", "--json"]) + .output() + .expect("run bl skills list"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse list output"); + assert_eq!(response["items"][0]["slug"], json!("builderlab-tools")); + assert_eq!(requests.len(), 2); + assert!(!requests[0].headers.contains_key("authorization")); + assert!(!requests[0].headers.contains_key("x-bb-session-credential")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_list_uses_stored_session_credential_despite_legacy_profile_auth() { + let server = MockServer::start(vec![skill_page_response(), empty_bundles_response()]); + let temp = temp_test_dir("bl-skills-list-session"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let storage_path = temp.join("auth-sessions.json"); + fs::create_dir_all(&bl_home).expect("create bl home"); + fs::write( + bl_home.join("skills.yaml"), + format!( + "current_profile: local\nprofiles:\n local:\n server_url: {}\n auth:\n token: profile-token\n", + server.base_url + ), + ) + .expect("write skills config"); + let storage_key = browser_auth_storage_key("local", &format!("{}/api/goose", server.base_url)); + fs::write( + &storage_path, + serde_json::to_string_pretty(&json!({ + storage_key: { + "sessionCredential": "stored-marketplace-session", + "expiresAt": "2026-06-15T00:00:00Z" + } + })) + .expect("serialize storage"), + ) + .expect("write auth storage"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "list", "--json"]) + .output() + .expect("run bl skills list"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 2); + for request in requests { + assert_eq!( + request + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some("stored-marketplace-session") + ); + assert!(!request.headers.contains_key("authorization")); + } + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_local_dev_discovers_checked_in_config_from_ancestor() { + let server = MockServer::start(vec![MockResponse::json(json!({ + "target_registry": { + "agents": {"kind": "filesystem"} + } + }))]); + let server_base_url = server.base_url.clone(); + let temp = temp_test_dir("bl-local-dev-config"); + let child = temp.join("nested/project"); + fs::create_dir_all(&child).expect("create nested current dir"); + fs::write( + temp.join("bl-local-dev-config.yaml"), + "current_profile: local-dev\nprofiles:\n local-dev:\n skills_home: .bl/local-dev/skills\n", + ) + .expect("write local dev config"); + + let output = bl_command() + .current_dir(&child) + .env("KGOOSE_BASE_URL", &server_base_url) + .args(["--local-dev", "skills", "doctor", "--json"]) + .output() + .expect("run bl local dev doctor"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse doctor output"); + assert_eq!(response["local_dev"], json!(true)); + assert_eq!(response["profile"], json!("local-dev")); + assert_eq!(response["kgoose_base_url"], json!(server_base_url)); + assert!(response["bl_skills_home"] + .as_str() + .expect("bl_skills_home string") + .ends_with(".bl/local-dev/skills")); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "GET"); + assert_eq!( + requests[0].path, + "/cash-app/goose/v1/marketplace/capabilities" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_env_playpen_adds_baggage_header() { + let server = MockServer::start(vec![skill_page_response(), empty_bundles_response()]); + + let output = bl_command() + .env("BL_KGOOSE_PLAYPEN", "baxen") + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "list", "--json"]) + .output() + .expect("run bl skills list"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].headers.get("baggage").map(String::as_str), + Some("kgoose-builderlab-playpen=baxen") + ); + assert_eq!( + requests[1].headers.get("baggage").map(String::as_str), + Some("kgoose-builderlab-playpen=baxen") + ); +} + +#[test] +fn bl_auth_status_without_token_is_local_and_unauthenticated() { + let temp = temp_test_dir("bl-auth-status"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .args(["auth", "status", "--json"]) + .output() + .expect("run bl auth status"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse status output"); + assert_eq!(response["authenticated"], json!(false)); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_auth_status_requires_org_in_json_mode() { + let temp = temp_test_dir("bl-auth-status-missing-org"); + let bl_home = temp.join("bl-home"); + fs::create_dir_all(&bl_home).expect("create bl home"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .args(["auth", "status", "--json"]) + .output() + .expect("run bl auth status"); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + let payload = parse_stderr_error(&stderr); + assert_eq!(payload["error"]["code"], json!("org_required")); + assert_eq!(payload["error"]["exit_code"], json!(3)); + assert!( + payload["error"]["message"] + .as_str() + .expect("error message string") + .contains("bl config set org "), + "stderr was: {stderr}" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_auth_status_uses_org_routed_custom_base_url() { + let temp = temp_test_dir("bl-auth-status-org-base"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", "blockstaging.build") + .args(["auth", "status", "--json"]) + .output() + .expect("run bl auth status"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse status output"); + assert_eq!(response["authenticated"], json!(false)); + assert_eq!( + response["kgoose_base_url"], + json!("https://test.blockstaging.build") + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_auth_status_uses_auth_me_for_stored_file_session() { + let temp = temp_test_dir("bl-auth-status-stored"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let storage_path = temp.join("auth-sessions.json"); + let server = MockServer::start(vec![MockResponse::json(json!({ + "subject": "auth0|user_123", + "email": "test@example.com", + "name": "Test User", + "expires_at": "2026-06-15T00:00:00Z", + "roles": ["ROLE_USER"], + "workspaces": {"active": [ + {"name": "Test \u{202e}Workspace"}, + {"name": "Other Workspace"} + ]} + }))]); + let storage_key = + browser_auth_storage_key("default", &format!("{}/api/goose", server.base_url)); + fs::write( + &storage_path, + serde_json::to_string_pretty(&json!({ + storage_key: { + "sessionCredential": "stored-cli-session", + "expiresAt": "2026-06-15T00:00:00Z" + } + })) + .expect("serialize storage"), + ) + .expect("write auth storage"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["auth", "status", "--json"]) + .output() + .expect("run bl auth status"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains(r#""workspace_name": "Test \u202eWorkspace""#)); + let response = serde_json::from_str::(&stdout).expect("parse status output"); + assert_eq!(response["authenticated"], json!(true)); + assert_eq!(response["expires_at"], json!("2026-06-15T00:00:00Z")); + assert_eq!(response["workspace_name"], json!("Test \u{202e}Workspace")); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "GET"); + assert_eq!(requests[0].path, "/api/goose/v1/auth/me"); + assert_eq!( + requests[0] + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some("stored-cli-session") + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_auth_status_errors_never_echo_a_reflected_session() { + let secret = "reflected_session_credential_123456"; + let server = MockServer::start(vec![ + MockResponse::text(500, secret), + MockResponse::text(500, secret), + ]); + let temp = temp_test_dir("bl-auth-status-redaction"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + write_browser_auth_session( + &storage_path, + &server.base_url, + secret, + "2099-01-01T00:00:00Z", + ); + + for args in [vec!["auth", "status"], vec!["auth", "status", "--json"]] { + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(args) + .output() + .expect("run failing bl auth status"); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(!stdout.contains(secret)); + assert!(!stderr.contains(secret)); + assert!(stderr.contains("/v1/auth/me failed with 500")); + } + + assert_eq!(server.finish().len(), 2); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_auth_login_uses_valid_stored_file_session() { + let temp = temp_test_dir("bl-auth-login-stored"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let storage_path = temp.join("auth-sessions.json"); + let server = MockServer::start(vec![MockResponse::json(json!({ + "subject": "auth0|user_123", + "email": "test@example.com", + "name": "Test User", + "expires_at": "2026-06-15T00:00:00Z", + "roles": ["ROLE_USER"], + "workspaces": {"active": [ + {"name": "Test \u{202e}Workspace"}, + {"name": "Other Workspace"} + ]} + }))]); + let storage_key = + browser_auth_storage_key("default", &format!("{}/api/goose", server.base_url)); + fs::write( + &storage_path, + serde_json::to_string_pretty(&json!({ + storage_key: { + "sessionCredential": "stored-cli-session", + "expiresAt": "2026-06-15T00:00:00Z" + } + })) + .expect("serialize storage"), + ) + .expect("write auth storage"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["auth", "login", "--json"]) + .output() + .expect("run bl auth login"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains(r#""workspace_name": "Test \u202eWorkspace""#)); + let response = serde_json::from_str::(&stdout).expect("parse login output"); + assert_eq!(response["source"], json!("stored")); + assert_eq!(response["storage"], json!("file")); + assert_eq!(response["workspace_name"], json!("Test \u{202e}Workspace")); + assert_eq!(response["credentialPrefix"], Value::Null); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "GET"); + assert_eq!(requests[0].path, "/api/goose/v1/auth/me"); + assert_eq!( + requests[0] + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some("stored-cli-session") + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_auth_login_env_playpen_adds_baggage_to_stored_session_check() { + let temp = temp_test_dir("bl-auth-login-playpen"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let storage_path = temp.join("auth-sessions.json"); + let server = MockServer::start(vec![MockResponse::json(json!({ + "subject": "auth0|user_123", + "email": "test@example.com", + "name": "Test User", + "expires_at": "2026-06-15T00:00:00Z", + "roles": ["ROLE_USER"], + "workspaces": {"active": [{"name": "Test Workspace"}]} + }))]); + let storage_key = + browser_auth_storage_key("default", &format!("{}/api/goose", server.base_url)); + fs::write( + &storage_path, + serde_json::to_string_pretty(&json!({ + storage_key: { + "sessionCredential": "stored-cli-session", + "expiresAt": "2026-06-15T00:00:00Z" + } + })) + .expect("serialize storage"), + ) + .expect("write auth storage"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("BL_KGOOSE_PLAYPEN", "baxen") + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["auth", "login", "--json"]) + .output() + .expect("run bl auth login"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/api/goose/v1/auth/me"); + assert_eq!( + requests[0].headers.get("baggage").map(String::as_str), + Some("kgoose-builderlab-playpen=baxen") + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_auth_logout_removes_stored_file_session() { + let temp = temp_test_dir("bl-auth-logout"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let storage_path = temp.join("auth-sessions.json"); + let server = MockServer::start(vec![MockResponse::json(json!({}))]); + let server_url = format!("{}/api/goose", server.base_url); + let default_key = browser_auth_storage_key("default", &server_url); + let other_key = browser_auth_storage_key("other", &server_url); + let purpose_storage_path = PathBuf::from(format!("{}.purpose-tokens", storage_path.display())); + fs::write( + &storage_path, + serde_json::to_string_pretty(&json!({ + default_key: { + "sessionCredential": "default-session", + "expiresAt": "2026-06-15T00:00:00Z" + }, + other_key: { + "sessionCredential": "other-session", + "expiresAt": "2026-06-15T00:00:00Z" + } + })) + .expect("serialize storage"), + ) + .expect("write auth storage"); + fs::write( + &purpose_storage_path, + serde_json::to_string_pretty(&json!({ + "obsolete-purpose-token": { "accessToken": "legacy-secret" } + })) + .expect("serialize legacy purpose token storage"), + ) + .expect("write legacy purpose token storage"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["auth", "logout", "--json"]) + .output() + .expect("run bl auth logout"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse logout output"); + assert_eq!(response["removed"], json!(true)); + assert_eq!(response["server_revoked"], json!(true)); + assert_eq!(response["storage"], json!("file")); + assert_eq!(response["purpose_token_removed"], json!(true)); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/api/goose/v1/auth/logout"); + assert_eq!( + requests[0] + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some("default-session") + ); + + let storage = fs::read_to_string(&storage_path).expect("read storage"); + assert!(!storage.contains("default-session")); + assert!(storage.contains("other-session")); + assert!(!purpose_storage_path.exists()); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", "http://127.0.0.1:9") + .args(["auth", "logout", "--json"]) + .output() + .expect("run bl auth logout again"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse logout output"); + assert_eq!(response["removed"], json!(false)); + assert_eq!(response["server_revoked"], json!(false)); + assert_eq!(response["purpose_token_removed"], json!(false)); + + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_workspace_list_prints_accessible_workspaces_as_json() { + let temp = temp_test_dir("bl-workspace-list"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + let server = MockServer::start(vec![MockResponse::json(json!({ + "workspaces": [ + { + "workspace_identifier": "workspace-one", + "display_name": "Workspace One", + "roles": ["ROLE_USER"] + }, + { + "workspace_identifier": "workspace-two", + "display_name": "Workspace Two", + "roles": ["ROLE_USER", "ROLE_ADMIN"] + } + ], + "active_workspace_identifier": "workspace-one" + }))]); + write_browser_auth_session( + &storage_path, + &server.base_url, + "stored-cli-session", + "2026-06-15T00:00:00Z", + ); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["workspace", "list", "--json"]) + .output() + .expect("run bl workspace list"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse workspace list output"); + assert_eq!( + response["active_workspace_identifier"], + json!("workspace-one") + ); + assert_eq!(response["workspaces"][1]["display_name"], "Workspace Two"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/api/goose/v1/workspaces/list"); + assert_eq!(requests[0].body, json!({})); + assert_eq!( + requests[0] + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some("stored-cli-session") + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_workspace_switch_flag_skips_list_and_stores_rotated_credential() { + let temp = temp_test_dir("bl-workspace-switch"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + let server = MockServer::start(vec![MockResponse::json(json!({ + "workspace": { + "workspace_identifier": "workspace-two", + "display_name": "Workspace Two", + "roles": ["ROLE_USER"] + }, + "session_credential": "rotated-cli-session" + }))]); + write_browser_auth_session( + &storage_path, + &server.base_url, + "stored-cli-session", + "2026-06-15T00:00:00Z", + ); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "workspace", + "switch", + "--workspace", + "workspace-two", + "--json", + ]) + .output() + .expect("run bl workspace switch"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse workspace switch output"); + assert_eq!( + response["workspace"]["workspace_identifier"], + "workspace-two" + ); + assert_eq!(response["switched"], true); + assert!( + !stdout.contains("rotated-cli-session"), + "credential leaked to stdout: {stdout}" + ); + assert_eq!( + requests.len(), + 1, + "direct switch should skip workspace list" + ); + assert_eq!(requests[0].path, "/api/goose/v1/workspaces/switch"); + assert_eq!( + requests[0].body, + json!({"workspace_identifier": "workspace-two"}) + ); + assert_eq!( + requests[0] + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some("stored-cli-session") + ); + let storage: Value = serde_json::from_str( + &fs::read_to_string(&storage_path).expect("read rotated auth storage"), + ) + .expect("parse rotated auth storage"); + let stored = storage + .as_object() + .expect("storage object") + .values() + .next() + .expect("stored session"); + assert_eq!(stored["sessionCredential"], "rotated-cli-session"); + assert_eq!(stored["expiresAt"], "2026-06-15T00:00:00Z"); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_workspace_switch_current_workspace_keeps_stored_credential() { + let temp = temp_test_dir("bl-workspace-switch-current"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + let server = MockServer::start(vec![MockResponse::json(json!({ + "workspace": { + "workspace_identifier": "workspace-one", + "display_name": "Workspace One", + "roles": ["ROLE_USER"] + } + }))]); + write_browser_auth_session( + &storage_path, + &server.base_url, + "stored-cli-session", + "2026-06-15T00:00:00Z", + ); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "workspace", + "switch", + "--workspace", + "workspace-one", + "--json", + ]) + .output() + .expect("run bl workspace switch current"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse workspace switch output"); + assert_eq!(response["switched"], false); + let storage = fs::read_to_string(&storage_path).expect("read auth storage"); + assert!(storage.contains("stored-cli-session")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_workspace_switch_persists_rotated_credential_before_validating_workspace() { + let temp = temp_test_dir("bl-workspace-switch-missing-workspace"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + let server = MockServer::start(vec![MockResponse::json(json!({ + "session_credential": "rotated-cli-session" + }))]); + write_browser_auth_session( + &storage_path, + &server.base_url, + "stored-cli-session", + "2026-06-15T00:00:00Z", + ); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "workspace", + "switch", + "--workspace", + "workspace-two", + "--json", + ]) + .output() + .expect("run bl workspace switch with malformed response"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("returned no workspace")); + let storage = fs::read_to_string(&storage_path).expect("read auth storage"); + assert!(storage.contains("rotated-cli-session")); + assert!(!storage.contains("stored-cli-session")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_workspace_switch_rejects_invalid_rotated_credential_before_storage() { + let temp = temp_test_dir("bl-workspace-switch-invalid-credential"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + let server = MockServer::start(vec![MockResponse::json(json!({ + "workspace": { + "workspace_identifier": "workspace-two", + "display_name": "Workspace Two", + "roles": ["ROLE_USER"] + }, + "session_credential": "invalid\ncredential" + }))]); + write_browser_auth_session( + &storage_path, + &server.base_url, + "stored-cli-session", + "2026-06-15T00:00:00Z", + ); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "workspace", + "switch", + "--workspace", + "workspace-two", + "--json", + ]) + .output() + .expect("run bl workspace switch with invalid credential"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("invalid replacement credential")); + let storage = fs::read_to_string(&storage_path).expect("read auth storage"); + assert!(storage.contains("stored-cli-session")); + assert!(!storage.contains("invalid")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_workspace_errors_escape_untrusted_server_text() { + let temp = temp_test_dir("bl-workspace-error-safety"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + let server = MockServer::start(vec![MockResponse::text( + 403, + "denied\u{1b}[2J\u{202e}spoofed", + )]); + write_browser_auth_session( + &storage_path, + &server.base_url, + "stored-cli-session", + "2026-06-15T00:00:00Z", + ); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["workspace", "list", "--json"]) + .output() + .expect("run forbidden bl workspace list"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(!stderr.contains('\u{1b}'), "raw escape leaked: {stderr:?}"); + assert!( + !stderr.contains('\u{202e}'), + "raw bidi control leaked: {stderr:?}" + ); + assert!( + stderr.contains(r"\\u{1b}"), + "escaped control missing: {stderr}" + ); + assert!( + stderr.contains(r"\\u{202e}"), + "escaped bidi control missing: {stderr}" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_workspace_switch_requires_flag_outside_a_tty() { + let temp = temp_test_dir("bl-workspace-switch-noninteractive"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + write_browser_auth_session( + &storage_path, + "http://127.0.0.1:9", + "stored-cli-session", + "2026-06-15T00:00:00Z", + ); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", "http://127.0.0.1:9") + .args(["workspace", "switch", "--json"]) + .output() + .expect("run non-interactive bl workspace switch"); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + let payload = parse_stderr_error(&stderr); + assert_eq!(payload["error"]["code"], "workspace_required"); + assert!(payload["error"]["message"] + .as_str() + .expect("error message") + .contains("--workspace ")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +fn write_browser_auth_session( + storage_path: &Path, + base_url: &str, + session_credential: &str, + expires_at: &str, +) { + let storage_key = browser_auth_storage_key("default", &format!("{}/api/goose", base_url)); + fs::write( + storage_path, + serde_json::to_string_pretty(&json!({ + storage_key: { + "sessionCredential": session_credential, + "expiresAt": expires_at + } + })) + .expect("serialize auth storage"), + ) + .expect("write auth storage"); +} + +fn browser_auth_storage_key(profile: &str, server_url: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(profile.as_bytes()); + hasher.update([0]); + hasher.update(server_url.trim_end_matches('/').as_bytes()); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[test] +fn bl_config_set_and_get_roundtrip() { + let temp = temp_test_dir("bl-config-prefs"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + + let set_org = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .args(["config", "set", "org", " Test-Org ", "--json"]) + .output() + .expect("run bl config set org"); + let (_set_org_stdout, set_org_stderr) = output_text(&set_org); + assert!(set_org.status.success(), "stderr was: {set_org_stderr}"); + + let get_org = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .args(["config", "get", "org", "--json"]) + .output() + .expect("run bl config get org"); + let (get_org_stdout, get_org_stderr) = output_text(&get_org); + assert!(get_org.status.success(), "stderr was: {get_org_stderr}"); + let get_org_response = + serde_json::from_str::(&get_org_stdout).expect("parse get org output"); + assert_eq!(get_org_response["org"], json!("test-org")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_config_set_org_repairs_invalid_existing_org() { + let temp = temp_test_dir("bl-config-repair-org"); + let bl_home = temp.join("bl-home"); + fs::create_dir_all(&bl_home).expect("create bl home"); + fs::write(bl_home.join("config.yaml"), "org: bad_org\n").expect("write invalid config"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .args(["config", "set", "org", "Test-Org", "--json"]) + .output() + .expect("run bl config set org"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse set org output"); + assert_eq!(response["updated"], json!("org")); + + let saved = fs::read_to_string(bl_home.join("config.yaml")).expect("read repaired config"); + assert!(saved.contains("org: test-org"), "saved config was: {saved}"); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_auth_is_removed_but_config_alias_still_works() { + let temp = temp_test_dir("bl-skills-alias"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + + let status = bl_command() + .env("BL_HOME", &bl_home) + .args(["skills", "auth", "status", "--json"]) + .output() + .expect("run bl skills auth status"); + let (_, status_stderr) = output_text(&status); + assert!(!status.status.success()); + assert!( + status_stderr.contains("unrecognized subcommand 'auth'"), + "stderr was: {status_stderr}" + ); + + let get = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .args(["skills", "config", "get", "org", "--json"]) + .output() + .expect("run bl skills config get"); + let (get_stdout, get_stderr) = output_text(&get); + assert!(get.status.success(), "stderr was: {get_stderr}"); + let get_response = serde_json::from_str::(&get_stdout).expect("parse get output"); + assert_eq!(get_response["org"], json!("test")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +// --------------------------------------------------------------------------- +// error envelopes + +#[test] +fn bl_skills_list_surfaces_marketplace_error_envelope() { + let server = MockServer::start(vec![marketplace_error_response( + 404, + "skill_not_found", + "Skill was not found.", + "req_list_123", + )]); + + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "list"]) + .output() + .expect("run bl skills list"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!( + stderr.contains("Skill was not found. (skill_not_found)"), + "stderr was: {stderr}" + ); + assert!( + stderr.contains("request_id: req_list_123"), + "stderr was: {stderr}" + ); + assert!( + stderr.contains( + "details: skills/builderlab-tools/SKILL.md.description: description is required" + ), + "stderr was: {stderr}" + ); +} + +#[test] +fn bl_skills_list_json_errors_are_structured() { + let server = MockServer::start(vec![marketplace_error_response( + 404, + "skill_not_found", + "Skill was not found.", + "req_list_123", + )]); + + let output = bl_command() + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "list", "--json"]) + .output() + .expect("run bl skills list"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + let payload = parse_stderr_error(&stderr); + assert_eq!(payload["error"]["code"], json!("skill_not_found")); + assert!( + payload["error"]["message"] + .as_str() + .expect("error message string") + .contains("Skill was not found."), + "stderr was: {stderr}" + ); + assert_eq!(payload["error"]["exit_code"], json!(1)); + assert_eq!(output.status.code(), Some(1)); +} + +// --------------------------------------------------------------------------- +// install + +#[test] +fn bl_skills_install_downloads_verifies_and_installs_into_isolated_home() { + let zip_bytes = skill_zip(&[ + ("SKILL.md", "# BuilderLab Tools\n"), + ("SETUP.md", "No setup.\n"), + ]); + let artifact_sha = sha256_hex(&zip_bytes); + let temp = temp_test_dir("bl-skills-install"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + let agents_dir = temp.join("agents-skills"); + let server = MockServer::start(vec![ + capabilities_response(&agents_dir), + MockResponse::json(marketplace_install_plan( + &zip_bytes, + &artifact_sha, + zip_bytes.len(), + )), + skill_detail_response(), + artifact_response(zip_bytes, &artifact_sha), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "builderlab-tools", + "--target", + "agents", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse install output"); + assert_eq!(response["installed"][0]["slug"], json!("builderlab-tools")); + assert_eq!( + response["installed"][0]["targets"], + json!(["agents"]), + "stdout was: {stdout}" + ); + + // Canonical package + metadata. + let package = skills_home.join("packages/builderlab-tools"); + assert!(package.join("SKILL.md").is_file()); + let metadata = serde_json::from_slice::( + &fs::read(package.join(".bl-skills-meta.json")).expect("read metadata"), + ) + .expect("parse metadata"); + assert_eq!(metadata["slug"], json!("builderlab-tools")); + assert_eq!(metadata["local_source"], json!(false)); + assert_eq!(metadata["source_id"], json!("src_builtin_builderlab")); + + // Link into the registry-provided agents directory. + let link = agents_dir.join("builderlab-tools"); + assert!( + link.join("SKILL.md").is_file(), + "expected link at {}", + link.display() + ); + #[cfg(unix)] + assert!(fs::symlink_metadata(&link) + .expect("link metadata") + .file_type() + .is_symlink()); + + // Downloaded artifact is kept for provenance. + let downloads = fs::read_dir(skills_home.join("downloads")) + .expect("read downloads dir") + .filter_map(|entry| entry.ok()) + .collect::>(); + assert_eq!(downloads.len(), 1, "expected one persisted artifact"); + + assert_eq!(requests.len(), 4); + assert_eq!(requests[0].method, "GET"); + assert_eq!(requests[0].path, "/api/goose/v1/marketplace/capabilities"); + assert_eq!(requests[1].method, "POST"); + assert_eq!(requests[1].path, "/api/goose/v1/marketplace/install-plan"); + assert_eq!( + requests[1].body["targets"][0]["slug"], + json!("builderlab-tools") + ); + assert_eq!( + requests[1].body["client"]["install_targets"], + json!(["agents"]) + ); + assert!( + requests[1].body.get("channel").is_none(), + "install-plan requests must not expose a channel selector" + ); + assert_eq!(requests[2].method, "GET"); + assert_eq!( + requests[2].path, + "/api/goose/v1/marketplace/skills/builderlab-tools" + ); + assert_eq!(requests[3].method, "GET"); + assert_eq!( + requests[3].path, + "/api/goose/v1/marketplace/artifacts/art_builderlab_tools/download" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_install_rejects_unsafe_plan_before_artifact_fetch_or_root_escape() { + let temp = temp_test_dir("bl-skills-unsafe-plan-slug"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + let agents_dir = temp.join("agents-skills"); + let outside = temp.join("outside-sentinel"); + fs::create_dir_all(&outside).expect("create outside sentinel"); + fs::write(outside.join("keep"), "untouched").expect("write outside sentinel"); + + let malicious_plan = json!({ + "plan_id": "malicious", + "operations": [{ + "action": "install", + "skill": { + "slug": "../outside-sentinel", + "version_id": "version-1", + "content_sha256": "content-sha" + }, + "artifact": { + "id": "artifact-1", + "download_url": "/must-not-fetch", + "sha256": "unused", + "size_bytes": 1 + }, + "installed_via": "explicit" + }], + "warnings": [] + }); + let server = MockServer::start(vec![ + capabilities_response(&agents_dir), + MockResponse::json(malicious_plan), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "builderlab-tools", + "--target", + "agents", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!( + stderr.contains("invalid skill name"), + "stderr was: {stderr}" + ); + assert_eq!( + requests.len(), + 2, + "artifact or detail fetch escaped validation" + ); + assert_eq!( + fs::read_to_string(outside.join("keep")).unwrap(), + "untouched" + ); + assert!(!skills_home.join("outside-sentinel").exists()); + assert!(!agents_dir.join("outside-sentinel").exists()); + assert!( + !skills_home.join("downloads").exists() + || fs::read_dir(skills_home.join("downloads")) + .unwrap() + .next() + .is_none() + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_install_and_update_restore_package_when_target_linking_fails() { + for action in ["install", "update"] { + let zip_bytes = skill_zip(&[("SKILL.md", "# New BuilderLab Tools\n")]); + let artifact_sha = sha256_hex(&zip_bytes); + let temp = temp_test_dir(&format!("bl-skills-{action}-target-recovery")); + let bl_home = temp.join("bl-home"); + let skills_home = temp.join("skills-home"); + let invalid_target = temp.join("target-is-a-file"); + write_bl_org_config(&bl_home, "test"); + write_installed_package(&skills_home, "builderlab-tools", "old-content", &["claude"]); + fs::write(&invalid_target, "not a directory").expect("create invalid target"); + + let mut plan = marketplace_install_plan(&zip_bytes, &artifact_sha, zip_bytes.len()); + plan["operations"][0]["action"] = json!(action); + let server = MockServer::start(vec![ + capabilities_response_for_target("claude", &invalid_target), + MockResponse::json(plan), + skill_detail_response(), + artifact_response(zip_bytes, &artifact_sha), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "builderlab-tools", + "--target", + "claude", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!( + !output.status.success(), + "stdout: {stdout}; stderr: {stderr}" + ); + assert!( + format!("{stdout}\n{stderr}").contains("restored the previous package"), + "stdout: {stdout}; stderr: {stderr}" + ); + assert_eq!( + fs::read_to_string(skills_home.join("packages/builderlab-tools/SKILL.md")) + .expect("read restored package"), + "# BuilderLab Tools\n" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); + } +} + +/// The default layout: the canonical packages dir IS the agents target dir, +/// so the agents entry is the real package (no self-link) and other flows +/// (remove) treat it as the package, not a link. +#[test] +fn bl_skills_install_canonical_agents_dir_holds_real_package() { + let zip_bytes = skill_zip(&[("SKILL.md", "# BuilderLab Tools\n")]); + let artifact_sha = sha256_hex(&zip_bytes); + let temp = temp_test_dir("bl-skills-install-canonical"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + let agents_dir = temp.join("agents-skills"); + let server = MockServer::start(vec![ + capabilities_response(&agents_dir), + MockResponse::json(marketplace_install_plan( + &zip_bytes, + &artifact_sha, + zip_bytes.len(), + )), + skill_detail_response(), + artifact_response(zip_bytes, &artifact_sha), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + // Canonical packages dir == the registry's agents directory, like the + // real default (`~/.agents/skills`). + .env("BL_SKILLS_PACKAGES_DIR", &agents_dir) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "builderlab-tools", + "--target", + "agents", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse install output"); + assert_eq!( + response["installed"][0]["links"][0]["strategy"], + json!("existing"), + "stdout was: {stdout}" + ); + + // The agents entry is the real package directory, not a symlink. + let package = agents_dir.join("builderlab-tools"); + assert!(package.join("SKILL.md").is_file()); + assert!(package.join(".bl-skills-meta.json").is_file()); + assert!(!fs::symlink_metadata(&package) + .expect("package metadata") + .file_type() + .is_symlink()); + + // Remove treats the entry as the package (offline via cached registry). + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", &agents_dir) + .env("KGOOSE_BASE_URL", "http://127.0.0.1:9") + .args(["skills", "remove", "builderlab-tools", "--yes", "--json"]) + .output() + .expect("run bl skills remove"); + let (stdout, stderr) = output_text(&output); + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse remove output"); + assert_eq!(response["removed_package"], json!(true)); + assert!(!package.exists()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_install_surfaces_install_plan_error_envelope() { + let temp = temp_test_dir("bl-skills-install-plan-error"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let agents_dir = temp.join("agents-skills"); + let server = MockServer::start(vec![ + capabilities_response(&agents_dir), + marketplace_error_response( + 422, + "validation_failed", + "Install plan could not be created.", + "req_plan_123", + ), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", temp.join("skills-home")) + .env("BL_SKILLS_PACKAGES_DIR", temp.join("skills-home/packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "builderlab-tools", + "--target", + "agents", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + let payload = parse_stderr_error(&stderr); + assert_eq!(payload["error"]["code"], json!("validation_failed")); + assert_eq!(payload["error"]["exit_code"], json!(6)); + assert_eq!(output.status.code(), Some(6)); + assert_eq!( + requests.len(), + 2, + "should not request artifact after plan failure" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_install_surfaces_artifact_error_envelope() { + let zip_bytes = skill_zip(&[("SKILL.md", "# BuilderLab Tools\n")]); + let artifact_sha = sha256_hex(&zip_bytes); + let temp = temp_test_dir("bl-skills-artifact-error"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let agents_dir = temp.join("agents-skills"); + let server = MockServer::start(vec![ + capabilities_response(&agents_dir), + MockResponse::json(marketplace_install_plan( + &zip_bytes, + &artifact_sha, + zip_bytes.len(), + )), + skill_detail_response(), + marketplace_error_response( + 403, + "artifact_plan_forbidden", + "Artifact is not authorized by this install plan.", + "req_artifact_123", + ), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", temp.join("skills-home")) + .env("BL_SKILLS_PACKAGES_DIR", temp.join("skills-home/packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "builderlab-tools", + "--target", + "agents", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + let payload = parse_stderr_error(&stderr); + assert_eq!(payload["error"]["code"], json!("artifact_plan_forbidden")); + assert_eq!(payload["error"]["exit_code"], json!(4)); + assert_eq!(output.status.code(), Some(4)); + assert_eq!(requests.len(), 4); + assert!(!temp.join("skills-home/packages/builderlab-tools").exists()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_install_refuses_checksum_mismatch() { + let good_zip = skill_zip(&[("SKILL.md", "# BuilderLab Tools\n")]); + let bad_zip = skill_zip(&[("SKILL.md", "# Tampered\n")]); + let artifact_sha = sha256_hex(&good_zip); + let temp = temp_test_dir("bl-skills-checksum"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let agents_dir = temp.join("agents-skills"); + let server = MockServer::start(vec![ + capabilities_response(&agents_dir), + MockResponse::json(marketplace_install_plan( + &good_zip, + &artifact_sha, + bad_zip.len(), + )), + skill_detail_response(), + artifact_response(bad_zip, &artifact_sha), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", temp.join("skills-home")) + .env("BL_SKILLS_PACKAGES_DIR", temp.join("skills-home/packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "builderlab-tools", + "--target", + "agents", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + let payload = parse_stderr_error(&stderr); + assert_eq!( + payload["error"]["code"], + json!("artifact_checksum_mismatch") + ); + assert_eq!(payload["error"]["exit_code"], json!(8)); + assert_eq!(output.status.code(), Some(8)); + assert!(!temp.join("skills-home/packages/builderlab-tools").exists()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_install_refuses_unsafe_zip_paths() { + let zip_bytes = skill_zip(&[ + ("SKILL.md", "# BuilderLab Tools\n"), + ("../escape.md", "nope\n"), + ]); + let artifact_sha = sha256_hex(&zip_bytes); + let temp = temp_test_dir("bl-skills-path-safety"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let agents_dir = temp.join("agents-skills"); + let server = MockServer::start(vec![ + capabilities_response(&agents_dir), + MockResponse::json(marketplace_install_plan( + &zip_bytes, + &artifact_sha, + zip_bytes.len(), + )), + skill_detail_response(), + artifact_response(zip_bytes, &artifact_sha), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", temp.join("skills-home")) + .env("BL_SKILLS_PACKAGES_DIR", temp.join("skills-home/packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "builderlab-tools", + "--target", + "agents", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + let payload = parse_stderr_error(&stderr); + assert!( + payload["error"]["message"] + .as_str() + .expect("error message string") + .contains("unsafe zip entry"), + "stderr was: {stderr}" + ); + assert!(!temp.join("escape.md").exists()); + assert!(!temp.join("skills-home/packages/builderlab-tools").exists()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_install_backs_up_unmanaged_package_before_replacing_it() { + let zip_bytes = skill_zip(&[("SKILL.md", "# BuilderLab Tools\n")]); + let artifact_sha = sha256_hex(&zip_bytes); + let temp = temp_test_dir("bl-skills-unmanaged"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let agents_dir = temp.join("agents-skills"); + let server = MockServer::start(vec![ + capabilities_response(&agents_dir), + MockResponse::json(marketplace_install_plan( + &zip_bytes, + &artifact_sha, + zip_bytes.len(), + )), + skill_detail_response(), + artifact_response(zip_bytes, &artifact_sha), + ]); + let unmanaged = temp.join("skills-home/packages/builderlab-tools"); + fs::create_dir_all(&unmanaged).expect("create unmanaged package"); + fs::write(unmanaged.join("SKILL.md"), "user file").expect("write unmanaged skill"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", temp.join("skills-home")) + .env("BL_SKILLS_PACKAGES_DIR", temp.join("skills-home/packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "builderlab-tools", + "--target", + "agents", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse install output"); + assert_eq!(response["installed"][0]["slug"], json!("builderlab-tools")); + assert_eq!( + fs::read_to_string(unmanaged.join("SKILL.md")).expect("read unmanaged skill"), + "# BuilderLab Tools\n" + ); + assert!(unmanaged.join(".bl-skills-meta.json").is_file()); + let backup_root = unmanaged + .parent() + .expect("packages directory") + .join(".backups"); + let backups = fs::read_dir(&backup_root) + .expect("read backup directory") + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("builderlab-tools-")) + }) + .collect::>(); + assert_eq!(backups.len(), 1); + assert_eq!( + fs::read_to_string(backups[0].join("SKILL.md")).expect("read backup skill"), + "user file" + ); + let backup_output = &response["installed"][0]["backups"][0]; + assert_eq!(backup_output["source_path"], json!(unmanaged)); + assert_eq!(backup_output["backup_path"], json!(backups[0])); + assert!(backup_output["created_at"] + .as_str() + .is_some_and(|created_at| created_at.ends_with('Z') + && created_at.contains('T') + && created_at.matches(':').count() == 1)); + assert_eq!(requests.len(), 4); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_install_human_output_reports_real_folder_backup() { + let temp = temp_test_dir("bl-skills-backup-output"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + let packages_dir = skills_home.join("packages"); + let agents_dir = temp.join("agents-skills"); + let source = temp.join("source/builderlab-tools"); + fs::create_dir_all(&source).expect("create local skill source"); + fs::write(source.join("SKILL.md"), "# Marketplace replacement\n") + .expect("write local skill source"); + let existing = packages_dir.join("builderlab-tools"); + fs::create_dir_all(&existing).expect("create conflicting skill"); + fs::write(existing.join("SKILL.md"), "# User-owned skill\n").expect("write conflicting skill"); + let server = MockServer::start(vec![capabilities_response(&agents_dir)]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", &packages_dir) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + source.to_str().expect("UTF-8 source path"), + "--target", + "agents", + "--yes", + ]) + .output() + .expect("run bl skills install"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let backups = fs::read_dir(packages_dir.join(".backups")) + .expect("read backup directory") + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .collect::>(); + assert_eq!(backups.len(), 1); + assert!( + stdout.contains(&format!( + "conflicting skill at {} was replaced. Backup created on ", + existing.display() + )), + "stdout was: {stdout}" + ); + assert!( + stdout.contains(&format!("Z at {}", backups[0].display())), + "stdout was: {stdout}" + ); + assert_eq!( + fs::read_to_string(backups[0].join("SKILL.md")).expect("read backup skill"), + "# User-owned skill\n" + ); + assert_eq!( + fs::read_to_string(existing.join("SKILL.md")).expect("read installed skill"), + "# Marketplace replacement\n" + ); + assert_eq!(requests.len(), 1); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_install_rejects_unresolvable_version_pin() { + let zip_bytes = skill_zip(&[("SKILL.md", "# BuilderLab Tools\n")]); + let artifact_sha = sha256_hex(&zip_bytes); + let temp = temp_test_dir("bl-skills-version-pin"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let agents_dir = temp.join("agents-skills"); + let server = MockServer::start(vec![ + capabilities_response(&agents_dir), + MockResponse::json(marketplace_install_plan( + &zip_bytes, + &artifact_sha, + zip_bytes.len(), + )), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", temp.join("skills-home")) + .env("BL_SKILLS_PACKAGES_DIR", temp.join("skills-home/packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "builderlab-tools", + "--version", + "ver_older_pin", + "--target", + "agents", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + let payload = parse_stderr_error(&stderr); + assert_eq!(payload["error"]["code"], json!("version_pin_unresolved")); + assert_eq!(payload["error"]["exit_code"], json!(6)); + assert_eq!(output.status.code(), Some(6)); + assert_eq!(requests.len(), 2, "should stop after plan resolution"); + assert!(!temp.join("skills-home/packages/builderlab-tools").exists()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_install_local_path_installs_without_marketplace() { + let temp = temp_test_dir("bl-skills-local-path"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let agents_dir = temp.join("agents-skills"); + let server = MockServer::start(vec![capabilities_response(&agents_dir)]); + let source = temp.join("local-skill"); + fs::create_dir_all(&source).expect("create local skill dir"); + fs::write(source.join("SKILL.md"), "# Local Skill\n").expect("write local SKILL.md"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", temp.join("skills-home")) + .env("BL_SKILLS_PACKAGES_DIR", temp.join("skills-home/packages")) + .current_dir(&temp) + .env("KGOOSE_BASE_URL", &server.base_url) + .args([ + "skills", + "install", + "./local-skill", + "--target", + "agents", + "--yes", + "--json", + ]) + .output() + .expect("run bl skills install local path"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse install output"); + assert_eq!(response["installed"][0]["slug"], json!("local-skill")); + + let package = temp.join("skills-home/packages/local-skill"); + assert!(package.join("SKILL.md").is_file()); + let metadata = serde_json::from_slice::( + &fs::read(package.join(".bl-skills-meta.json")).expect("read metadata"), + ) + .expect("parse metadata"); + assert_eq!(metadata["local_source"], json!(true)); + assert!(agents_dir.join("local-skill/SKILL.md").is_file()); + assert_eq!( + requests.len(), + 1, + "local installs should only fetch capabilities" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +// --------------------------------------------------------------------------- +// update / installed / which / remove + +#[test] +fn bl_skills_update_reports_up_to_date_skills() { + let temp = temp_test_dir("bl-skills-update-noop"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + let agents_dir = temp.join("agents-skills"); + write_installed_package(&skills_home, "builderlab-tools", "content-sha", &["agents"]); + let server = MockServer::start(vec![ + capabilities_response(&agents_dir), + noop_plan_response(), + ]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "update", "--yes", "--json"]) + .output() + .expect("run bl skills update"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse update output"); + assert_eq!(response["up_to_date"], json!(["builderlab-tools"])); + assert_eq!(response["installed"], json!([])); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].path, "/api/goose/v1/marketplace/install-plan"); + assert_eq!( + requests[1].body["installed"][0]["slug"], + json!("builderlab-tools") + ); + assert!( + requests[1].body.get("channel").is_none(), + "update requests must not expose a channel selector" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_skills_installed_reports_update_availability() { + let temp = temp_test_dir("bl-skills-installed"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + write_installed_package(&skills_home, "builderlab-tools", "content-sha", &["agents"]); + let server = MockServer::start(vec![skill_page_response()]); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .env("KGOOSE_BASE_URL", &server.base_url) + .args(["skills", "installed", "--json"]) + .output() + .expect("run bl skills installed"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse installed output"); + assert_eq!(response["items"][0]["slug"], json!("builderlab-tools")); + // Local content sha matches the marketplace's latest -> no update pending. + assert_eq!(response["items"][0]["update_available"], json!(false)); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].path, + "/api/goose/v1/marketplace/skills?limit=5000" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[cfg(unix)] +#[test] +fn bl_skills_which_reports_link_state() { + let temp = temp_test_dir("bl-skills-which"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + let agents_dir = temp.join("agents-skills"); + write_installed_package(&skills_home, "builderlab-tools", "content-sha", &["agents"]); + write_capabilities_cache(&skills_home, &agents_dir); + fs::create_dir_all(&agents_dir).expect("create agents dir"); + std::os::unix::fs::symlink( + skills_home.join("packages/builderlab-tools"), + agents_dir.join("builderlab-tools"), + ) + .expect("create target link"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .env("KGOOSE_BASE_URL", "http://127.0.0.1:9") + .args(["skills", "which", "builderlab-tools", "--json"]) + .output() + .expect("run bl skills which"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse which output"); + assert_eq!(response["slug"], json!("builderlab-tools")); + assert_eq!(response["links"][0]["target"], json!("agents")); + assert_eq!(response["links"][0]["state"], json!("ok")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[cfg(unix)] +#[test] +fn bl_skills_remove_deletes_links_and_package() { + let temp = temp_test_dir("bl-skills-remove"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + let skills_home = temp.join("skills-home"); + let agents_dir = temp.join("agents-skills"); + write_installed_package(&skills_home, "builderlab-tools", "content-sha", &["agents"]); + write_capabilities_cache(&skills_home, &agents_dir); + fs::create_dir_all(&agents_dir).expect("create agents dir"); + std::os::unix::fs::symlink( + skills_home.join("packages/builderlab-tools"), + agents_dir.join("builderlab-tools"), + ) + .expect("create target link"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", &skills_home) + .env("BL_SKILLS_PACKAGES_DIR", skills_home.join("packages")) + .env("KGOOSE_BASE_URL", "http://127.0.0.1:9") + .args(["skills", "remove", "builderlab-tools", "--yes", "--json"]) + .output() + .expect("run bl skills remove"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse remove output"); + assert_eq!(response["removed_package"], json!(true)); + assert!(!skills_home.join("packages/builderlab-tools").exists()); + assert!( + fs::symlink_metadata(agents_dir.join("builderlab-tools")).is_err(), + "target link should be removed" + ); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +// --------------------------------------------------------------------------- +// doctor + +#[test] +fn bl_skills_doctor_offline_reports_server_failure() { + let temp = temp_test_dir("bl-skills-doctor-offline"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_SKILLS_HOME", temp.join("skills-home")) + .env("BL_SKILLS_PACKAGES_DIR", temp.join("skills-home/packages")) + .env("KGOOSE_BASE_URL", "http://127.0.0.1:9") + .args(["skills", "doctor", "--json"]) + .output() + .expect("run bl skills doctor"); + let (stdout, stderr) = output_text(&output); + + // Doctor reports problems instead of failing outright. + assert!(output.status.success(), "stderr was: {stderr}"); + let response = serde_json::from_str::(&stdout).expect("parse doctor output"); + assert_eq!(response["ok"], json!(false)); + let checks = response["checks"].as_array().expect("checks array"); + let server_check = checks + .iter() + .find(|check| check["name"] == json!("server")) + .expect("server check present"); + assert_eq!(server_check["status"], json!("fail")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +// --------------------------------------------------------------------------- +// External Apps Platform control plane + +const APPROVED_APPS_BASE_URL: &str = "https://compose-ctrl.test.blockstaging.build"; + +#[test] +fn bl_shipped_artifact_contains_no_apps_test_transport() { + let binary = fs::read(env!("CARGO_BIN_EXE_bl")).expect("read shipped bl test artifact"); + for forbidden in [ + "BL_APPS_E2E_CONTROL_PLANE_URL", + "BL_APPS_E2E_AUTH_URL", + "BL_APPS_E2E_CREDENTIAL", + "BL_APPS_E2E_RESOLVE_ADDR", + "Berd Apps E2E Test CA", + ] { + assert!( + !binary + .windows(forbidden.len()) + .any(|window| window == forbidden.as_bytes()), + "shipped bl artifact contained Apps test-only material {forbidden:?}" + ); + } +} + +#[test] +fn bl_apps_help_distinguishes_external_and_internal_paths() { + let output = bl_command() + .args(["apps", "--help"]) + .output() + .expect("run bl apps help"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + for expected in [ + "Apps Platform", + "bl-block", + "bl-public", + "Cloudflare-backed internal App Kit", + "bl tools appkit", + "separate internal Compose workflow", + "list", + "get", + "versions", + "rollback", + "delete", + "ready", + "debug", + ] { + assert!( + stdout.contains(expected), + "help did not explain {expected:?}: {stdout}" + ); + } +} + +#[test] +fn bl_apps_inspection_help_exposes_filters_and_app_ids() { + let list = bl_command() + .args(["apps", "list", "--help"]) + .output() + .expect("run bl apps list help"); + let (list_stdout, list_stderr) = output_text(&list); + assert!(list.status.success(), "stderr was: {list_stderr}"); + for expected in [ + "--scope ", + "manageable", + "owned", + "publisher", + "--include-deleted", + "--base-url ", + ] { + assert!( + list_stdout.contains(expected), + "list help omitted {expected:?}: {list_stdout}" + ); + } + + for subcommand in ["get", "versions"] { + let output = bl_command() + .args(["apps", subcommand, "--help"]) + .output() + .unwrap_or_else(|error| panic!("run bl apps {subcommand} help: {error}")); + let (stdout, stderr) = output_text(&output); + assert!(output.status.success(), "stderr was: {stderr}"); + for expected in [ + "", + "--environment ", + "--base-url ", + ] { + assert!( + stdout.contains(expected), + "{subcommand} help omitted {expected:?}: {stdout}" + ); + } + } +} + +#[test] +fn bl_apps_ready_and_debug_help_expose_their_arguments() { + let ready = bl_command() + .args(["apps", "ready", "--help"]) + .output() + .expect("run bl apps ready help"); + let (ready_stdout, ready_stderr) = output_text(&ready); + assert!(ready.status.success(), "stderr was: {ready_stderr}"); + for expected in [ + "", + "--version-id ", + "--environment ", + "--base-url ", + ] { + assert!( + ready_stdout.contains(expected), + "ready help omitted {expected:?}: {ready_stdout}" + ); + } + + let debug = bl_command() + .args(["apps", "debug", "--help"]) + .output() + .expect("run bl apps debug help"); + let (debug_stdout, debug_stderr) = output_text(&debug); + assert!(debug.status.success(), "stderr was: {debug_stderr}"); + for expected in [ + "", + "--version-id ", + "--environment ", + "--tail-lines ", + "1-1000", + "control-plane default: 200", + ] { + assert!( + debug_stdout.contains(expected), + "debug help omitted {expected:?}: {debug_stdout}" + ); + } +} + +#[test] +fn bl_apps_rollback_help_exposes_optional_target_and_environment() { + let output = bl_command() + .args(["apps", "rollback", "--help"]) + .output() + .expect("run bl apps rollback help"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + for expected in [ + "", + "--version-id ", + "--environment ", + "previous version", + "--base-url ", + ] { + assert!( + stdout.contains(expected), + "rollback help omitted {expected:?}: {stdout}" + ); + } +} + +#[test] +fn bl_apps_delete_help_exposes_exact_confirmation_and_retention_behavior() { + let output = bl_command() + .args(["apps", "delete", "--help"]) + .output() + .expect("run bl apps delete help"); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + for expected in [ + "", + "--confirm-app-id ", + "--environment ", + "--confirm-environment ", + "owner-only", + "retained", + "--base-url ", + ] { + assert!( + stdout.contains(expected), + "delete help omitted {expected:?}: {stdout}" + ); + } +} + +#[test] +fn bl_apps_delete_requires_confirmation_before_auth_or_network() { + let output = bl_command() + .args([ + "apps", + "delete", + "merchant-lookup", + "--base-url", + "https://compose-ctrl.test.blockstaging.build", + ]) + .output() + .expect("run bl apps delete without confirmation"); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("--confirm-app-id ")); + assert!(stderr.contains("--environment ")); + assert!(stderr.contains("--confirm-environment ")); + assert!(stderr.contains("required")); +} + +#[test] +fn bl_apps_delete_requires_an_explicit_environment() { + let output = bl_command() + .args([ + "apps", + "delete", + "merchant-lookup", + "--confirm-app-id", + "merchant-lookup", + "--confirm-environment", + "production", + "--base-url", + "https://compose-ctrl.test.blockstaging.build", + ]) + .output() + .expect("run bl apps delete without environment"); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("--environment ")); + assert!(stderr.contains("required")); +} + +#[test] +fn bl_apps_access_help_exposes_get_and_full_policy_replacement() { + let access_output = bl_command() + .args(["apps", "access", "--help"]) + .output() + .expect("run bl apps access help"); + let (access_stdout, access_stderr) = output_text(&access_output); + assert!( + access_output.status.success(), + "stderr was: {access_stderr}" + ); + for expected in ["get", "set", "visibility and explicit viewer list"] { + assert!( + access_stdout.contains(expected), + "access help omitted {expected:?}: {access_stdout}" + ); + } + + let get_output = bl_command() + .args(["apps", "access", "get", "--help"]) + .output() + .expect("run bl apps access get help"); + let (get_stdout, get_stderr) = output_text(&get_output); + assert!(get_output.status.success(), "stderr was: {get_stderr}"); + for expected in [ + "", + "--environment ", + "--base-url ", + ] { + assert!( + get_stdout.contains(expected), + "access get help omitted {expected:?}: {get_stdout}" + ); + } + + let set_output = bl_command() + .args(["apps", "access", "set", "--help"]) + .output() + .expect("run bl apps access set help"); + let (set_stdout, set_stderr) = output_text(&set_output); + assert!(set_output.status.success(), "stderr was: {set_stderr}"); + for expected in [ + "", + "--visibility ", + "organization", + "restricted", + "--viewer ", + "--clear-viewers", + "--environment ", + "complete access policy", + "exact caller value", + "bl apps list --json", + "owner", + "--base-url ", + ] { + assert!( + set_stdout.contains(expected), + "access set help omitted {expected:?}: {set_stdout}" + ); + } +} + +#[test] +fn bl_apps_access_set_requires_explicit_restricted_viewer_clearing_before_auth_or_network() { + let output = bl_command() + .args([ + "apps", + "access", + "set", + "merchant-lookup", + "--visibility", + "restricted", + "--base-url", + "https://compose-ctrl.test.blockstaging.build", + ]) + .output() + .expect("run bl apps access set without a viewer or clearing confirmation"); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("requires at least one --viewer")); + assert!(stderr.contains("--clear-viewers")); +} + +#[test] +fn bl_apps_access_set_requires_visibility_before_auth_or_network() { + let output = bl_command() + .args([ + "apps", + "access", + "set", + "merchant-lookup", + "--base-url", + "https://compose-ctrl.test.blockstaging.build", + ]) + .output() + .expect("run bl apps access set without visibility"); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("--visibility ")); + assert!(stderr.contains("required")); +} + +#[test] +fn bl_apps_ready_requires_version_before_auth_or_network() { + let output = bl_command() + .args([ + "apps", + "ready", + "merchant-lookup", + "--base-url", + "https://compose-ctrl.test.blockstaging.build", + ]) + .output() + .expect("run bl apps ready without version id"); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("--version-id ")); + assert!(stderr.contains("required")); +} + +#[test] +fn bl_apps_contract_rejects_loopback_before_reading_or_sending_the_session() { + let kgoose = MockServer::start(vec![]); + let control_plane = MockServer::start(vec![]); + let temp = temp_test_dir("bl-apps-loopback-origin"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("auth-sessions.json"); + let session_credential = "stored_session_credential_1234567890"; + write_bl_org_config(&bl_home, "test"); + write_browser_auth_session( + &storage_path, + &kgoose.base_url, + session_credential, + "2099-01-01T00:00:00Z", + ); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .env("KGOOSE_BASE_URL", &kgoose.base_url) + .args(["apps", "contract", "--base-url", &control_plane.base_url]) + .output() + .expect("run bl apps contract with loopback origin"); + let kgoose_requests = kgoose.finish(); + let control_plane_requests = control_plane.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("approved BuilderLab ingress")); + assert!(!stderr.contains(session_credential)); + assert!(kgoose_requests.is_empty()); + assert!(control_plane_requests.is_empty()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_apps_contract_rejects_arbitrary_https_origin() { + let temp = temp_test_dir("bl-apps-arbitrary-origin"); + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + + let output = bl_command() + .env("BL_HOME", &bl_home) + .args(["apps", "contract", "--base-url", "https://attacker.example"]) + .output() + .expect("run bl apps contract with arbitrary origin"); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("approved BuilderLab ingress")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_apps_json_without_a_session_exits_promptly_with_auth_required() { + let temp = temp_test_dir("bl-apps-json-auth-required"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("missing-auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + + let mut child = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .args([ + "apps", + "contract", + "--base-url", + "https://compose-ctrl.test.blockstaging.build", + "--json", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start noninteractive bl apps contract"); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait().expect("poll bl apps contract") { + break status; + } + if Instant::now() >= deadline { + child.kill().expect("stop hung bl apps contract"); + child.wait().expect("reap hung bl apps contract"); + panic!("bl apps contract did not fail promptly without a session"); + } + thread::sleep(Duration::from_millis(10)); + }; + let mut stdout = String::new(); + let mut stderr = String::new(); + child + .stdout + .take() + .expect("capture stdout") + .read_to_string(&mut stdout) + .expect("read stdout"); + child + .stderr + .take() + .expect("capture stderr") + .read_to_string(&mut stderr) + .expect("read stderr"); + + assert_eq!(status.code(), Some(3), "stderr was: {stderr}"); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + let payload = parse_stderr_error(&stderr); + assert_eq!(payload["error"]["code"], json!("auth_required")); + assert_eq!(payload["error"]["exit_code"], json!(3)); + assert!(!storage_path.exists()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_apps_pipeline_without_a_session_never_starts_browser_login() { + let temp = temp_test_dir("bl-apps-pipeline-auth-required"); + let bl_home = temp.join("bl-home"); + let storage_path = temp.join("missing-auth-sessions.json"); + write_bl_org_config(&bl_home, "test"); + + let mut child = bl_command() + .env("BL_HOME", &bl_home) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .args(["apps", "contract", "--base-url", APPROVED_APPS_BASE_URL]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start piped bl apps contract"); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait().expect("poll bl apps contract") { + break status; + } + if Instant::now() >= deadline { + child.kill().expect("stop hung bl apps contract"); + child.wait().expect("reap hung bl apps contract"); + panic!("piped bl apps contract did not fail promptly without a session"); + } + thread::sleep(Duration::from_millis(10)); + }; + let mut stdout = String::new(); + let mut stderr = String::new(); + child + .stdout + .take() + .expect("capture stdout") + .read_to_string(&mut stdout) + .expect("read stdout"); + child + .stderr + .take() + .expect("capture stderr") + .read_to_string(&mut stderr) + .expect("read stderr"); + + assert_eq!(status.code(), Some(3), "stderr was: {stderr}"); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("BuilderLab CLI auth is required")); + assert!(!stderr.contains("Opening BuilderLab auth login")); + assert!(!stderr.contains("127.0.0.1")); + assert!(!storage_path.exists()); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +// --------------------------------------------------------------------------- +// bl tools passthrough + +#[test] +fn bl_tools_help_surfaces_schema_derived_flags() { + let server = MockServer::start(vec![list_tools_response( + "utils", + calculate_tool_schema(true), + )]); + + let output = server + .bl_tools_command() + .args(["utils", "calculate", "--help"]) + .output() + .expect("run bl tools help"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("Usage: bl tools utils calculate")); + assert!(stdout.contains("--numbers ")); + assert!(stdout.contains("--operation ")); + assert!(stdout.contains("--round-up")); + assert!(stdout.contains("--no-round-up")); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, BL_TOOLS_LIST_TOOLS_PATH); + assert_eq!(requests[0].body["extension_name"], json!("utils")); +} + +#[test] +fn bl_tools_forwards_stored_session_credential_to_kgoose_calls() { + assert_bl_tools_session_credential( + "bl-tools-session", + "default", + "stored-bl-tools-session", + |_temp, _command| {}, + ); +} + +fn assert_bl_tools_session_credential( + temp_name: &str, + storage_profile: &str, + storage_credential: &str, + configure_command: impl FnOnce(&Path, &mut std::process::Command), +) { + let server = MockServer::start(vec![ + list_tools_response("utils", calculate_tool_schema(false)), + MockResponse::json(json!({ + "content": [{"text": {"text": "{\"sum\":5}"}}], + "is_error": false + })), + ]); + let temp = temp_test_dir(temp_name); + let storage_path = temp.join("auth-sessions.json"); + let storage_key = browser_auth_storage_key( + storage_profile, + &format!("{}/cash-app/goose", server.base_url), + ); + fs::write( + &storage_path, + serde_json::to_string_pretty(&json!({ + storage_key: { + "sessionCredential": storage_credential, + "expiresAt": "2026-06-15T00:00:00Z" + } + })) + .expect("serialize storage"), + ) + .expect("write auth storage"); + + let mut command = server.bl_tools_command(); + configure_command(&temp, &mut command); + let output = command + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .args([ + "utils", + "calculate", + "--numbers", + "2", + "3", + "--operation", + "add", + ]) + .output() + .expect("run bl tools tool"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0] + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some(storage_credential) + ); + assert_eq!( + requests[1] + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some(storage_credential) + ); + assert_eq!(requests[0].path, BL_TOOLS_LIST_TOOLS_PATH); + assert_eq!(requests[1].path, BL_TOOLS_CALL_TOOL_PATH); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_resolves_session_credential_from_current_profile() { + assert_bl_tools_session_credential( + "bl-tools-current-profile-session", + "local", + "stored-current-profile-session", + |temp, command| { + let bl_home = temp.join("bl-home"); + write_bl_org_config(&bl_home, "test"); + fs::write( + bl_home.join("skills.yaml"), + "current_profile: local\nprofiles:\n local: {}\n", + ) + .expect("write skills config"); + command.env("BL_HOME", &bl_home); + }, + ); +} + +#[test] +fn bl_tools_env_profile_does_not_require_readable_skills_config() { + assert_bl_tools_session_credential( + "bl-tools-env-profile-session", + "env-profile", + "stored-env-profile-session", + |temp, command| { + let malformed_config = temp.join("malformed-skills.yaml"); + fs::write(&malformed_config, "current_profile: [").expect("write malformed config"); + command + .env("BL_SKILLS_CONFIG", &malformed_config) + .env("BL_SKILLS_PROFILE", "env-profile"); + }, + ); +} + +#[test] +fn bl_tools_malformed_skills_config_falls_back_to_default_profile() { + assert_bl_tools_session_credential( + "bl-tools-default-profile-session", + "default", + "stored-default-profile-session", + |temp, command| { + let malformed_config = temp.join("malformed-skills.yaml"); + fs::write(&malformed_config, "current_profile: [").expect("write malformed config"); + command.env("BL_SKILLS_CONFIG", &malformed_config); + }, + ); +} + +#[cfg(unix)] +#[test] +fn bl_tools_root_metadata_commands_do_not_read_auth_storage() { + let temp = temp_test_dir("bl-tools-metadata-auth-storage"); + let malformed_storage = temp.join("auth-sessions.json"); + fs::write(&malformed_storage, "not json").expect("write malformed auth storage"); + + for args in [ + vec!["--version"], + vec!["--summary"], + vec!["--describe-commands"], + ] { + let server = MockServer::start(vec![]); + let output = server + .bl_tools_command() + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &malformed_storage) + .args(args) + .output() + .expect("run bl tools metadata command"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(requests.is_empty(), "requests were: {requests:#?}"); + } + + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_describe_commands_uses_static_catalog_without_network() { + let server = MockServer::start(vec![]); + let catalog_path = write_extensions_catalog( + "bl-tools-describe-commands", + r#" +- name: secret + about: Needs more auth +- name: utils + about: Utility helpers +"#, + ); + + let output = server + .bl_tools_command() + .env("KGOOSE_EXTENSIONS_CATALOG", &catalog_path) + .arg("--describe-commands") + .output() + .expect("run bl tools describe-commands"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + fs::remove_file(&catalog_path).expect("remove extensions catalog"); + + assert!(output.status.success(), "stderr was: {stderr}"); + let description = serde_json::from_str::(&stdout).expect("parse describe output"); + assert_eq!(description["name"], json!("tools")); + assert_eq!( + description["commands"], + json!([ + { + "name": "appkit", + "summary": "Cloudflare-backed internal Block App Kit CLI (local exec)" + }, + { + "name": "secret", + "summary": "Needs more auth" + }, + { + "name": "utils", + "summary": "Utility helpers" + } + ]) + ); + assert!(stderr.is_empty(), "stderr was: {stderr}"); + assert!(requests.is_empty(), "requests were: {requests:#?}"); +} diff --git a/tests/cli_e2e.rs b/tests/cli_e2e.rs new file mode 100644 index 0000000..780dab0 --- /dev/null +++ b/tests/cli_e2e.rs @@ -0,0 +1,830 @@ +//! End-to-end tests for the sq `agent-tools` binary. The `bl` binary suite +//! lives in `bl_e2e.rs`; shared mock server infrastructure lives in `common/`. + +mod common; + +use std::fs; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +#[cfg(unix)] +use common::temp_test_dir; +#[cfg(unix)] +use common::write_fake_executable; +use common::{ + calculate_tool_schema, list_tools_response, output_text, post_message_tool_schema, + tool_response, tool_response_with_extension_description, unique_suffix, + write_extensions_catalog, MockResponse, MockServer, CALL_TOOL_PATH, LIST_EXTENSIONS_PATH, + LIST_TOOLS_PATH, +}; + +fn browser_auth_storage_key(profile: &str, server_url: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(profile.as_bytes()); + hasher.update([0]); + hasher.update(server_url.trim_end_matches('/').as_bytes()); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[test] +fn tool_help_surfaces_schema_derived_flags() { + let server = MockServer::start(vec![list_tools_response( + "utils", + calculate_tool_schema(true), + )]); + + let output = server + .command() + .args(["utils", "calculate", "--help"]) + .output() + .expect("run agent-tools help"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("--numbers ")); + assert!(stdout.contains("--operation ")); + assert!(stdout.contains("--round-up")); + assert!(stdout.contains("--no-round-up")); + assert!(stdout.contains("--json ")); + assert!(stdout.contains("--raw")); + assert!(stdout.contains("--header ")); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, LIST_TOOLS_PATH); + assert_eq!(requests[0].body["extension_name"], json!("utils")); +} + +#[test] +fn tool_help_uses_custom_kgoose_service_path() { + let server = MockServer::start(vec![list_tools_response( + "utils", + calculate_tool_schema(false), + )]); + + let output = server + .command() + .args([ + "--kgoose-service-path", + "/cash-app/goose-square", + "utils", + "calculate", + "--help", + ]) + .output() + .expect("run agent-tools help"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/cash-app/goose-square/v3/list-tools"); +} + +#[test] +fn tool_help_prints_full_untruncated_description() { + let long_description = "Look up table metadata to inform downstream queries.\n\n\ + IMPORTANT: Table Meta Data tells you about the structure of a data table, \ + what it is used for, what tables it joins with, who owns it and uses it frequently. \ + Table names are returned in order of search relevance, with the total users recently \ + active and table verification status weighted into the ranking signal so that \ + well-trafficked, human-verified tables surface ahead of stale or unverified ones.\n\n\ + Workflow guidance the agent must follow:\n\ + 1. If the table name is unknown, use the user question as search text to find \ + relevant tables that would best answer the analysis question. Enrich the search \ + text with relevant business keywords, brand context, and any known column \ + synonyms so the semantic search can ground its match against the catalog.\n\ + 2. If the table name is known - either from the user or the output of \ + query_expert_search - include the table_name argument. The table name MUST be \ + provided in the canonical DATABASE.SCHEMA.TABLE_NAME format. Lowercase input is \ + accepted and will be normalized server-side.\n\ + 3. Use the table_owner argument to filter on tables owned by a specific LDAP \ + username such as PAZAR. This is useful for tracing table provenance during \ + on-call investigations or when you need to escalate a verification request.\n\ + 4. Read the entire TABLE DESCRIPTION and COLUMN SCHEMA returned to understand \ + what the table contains, how it is partitioned, and which columns are safe to \ + join against without producing fan-out results in your analytics query.\n\n\ + Notes on verification status: VERIFIED tables have been reviewed by a human \ + steward; UNVERIFIED tables may still be useful but should be cross-checked. \ + Brands cover Block, Square, Cash App, Afterpay, Tidal, and Bitkey.\n\ + UNIQUE-MARKER-FOR-FULL-DESCRIPTION-BODY"; + let server = MockServer::start(vec![tool_response( + "query-expert", + "find_table_meta_data", + long_description, + json!({"type": "object", "properties": {}}).to_string(), + )]); + + let output = server + .command() + .args(["query-expert", "find-table-meta-data", "--help"]) + .output() + .expect("run tool help"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!( + stdout.contains("UNIQUE-MARKER-FOR-FULL-DESCRIPTION-BODY"), + "expected full tool description in --help output, got: {stdout}" + ); + assert!(stdout.contains("Workflow guidance the agent must follow")); + assert!(stdout.contains("DATABASE.SCHEMA.TABLE_NAME")); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, LIST_TOOLS_PATH); +} + +#[test] +fn extension_help_truncates_long_description_and_advertises_describe() { + let description = format!( + "{} {}\n\n{}", + "Slack tools for chat.", + "Use this extension to search channels, read threads, and post messages.".repeat(20), + "This second paragraph should only appear in --describe output." + ); + let server = MockServer::start(vec![tool_response_with_extension_description( + "slack", + &description, + "search_messages", + "Search Slack messages", + json!({ + "type": "object", + "properties": {} + }) + .to_string(), + )]); + + let output = server + .command() + .args(["slack", "--help"]) + .output() + .expect("run extension help"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("Slack tools for chat.")); + assert!(stdout.contains("describe")); + assert!(stdout.contains("Print the full extension description/instructions.")); + assert!(stdout.contains("search-messages")); + assert!(stdout.contains("...")); + assert!(!stdout.contains("This second paragraph should only appear in --describe output.")); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, LIST_TOOLS_PATH); + assert_eq!(requests[0].body["extension_name"], json!("slack")); +} + +#[test] +fn extension_describe_prints_full_extension_description() { + let description = "Slack tools for chat.\n\nUse this extension to search channels,\nread threads, and post messages on behalf of the connected account."; + let server = MockServer::start(vec![tool_response_with_extension_description( + "slack", + description, + "search_messages", + "Search Slack messages", + json!({ + "type": "object", + "properties": {} + }) + .to_string(), + )]); + + let output = server + .command() + .args(["slack", "describe"]) + .output() + .expect("run extension describe"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(stdout.trim_end(), description); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, LIST_TOOLS_PATH); + assert_eq!(requests[0].body["extension_name"], json!("slack")); +} + +#[test] +fn write_extensions_accepts_new_list_extensions_auth_fields() { + let server = MockServer::start(vec![MockResponse::json(json!({ + "extensions": [ + { + "name": "slack", + "description": "Slack tools", + "tool_count": 12, + "anyToolRequiresUserAuth": true, + "authSatisfiedForCaller": true + }, + { + "name": "airtable", + "description": "Airtable tools", + "tool_count": 4, + "any_tool_requires_user_auth": false, + "auth_satisfied_for_caller": false + } + ] + }))]); + let output_path = std::env::temp_dir().join(format!( + "write-extensions-{}-{}.yaml", + std::process::id(), + unique_suffix() + )); + + let output = server + .command() + .arg("--write-extensions") + .arg(&output_path) + .output() + .expect("run write extensions"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + let catalog = fs::read_to_string(&output_path).expect("read generated catalog"); + fs::remove_file(&output_path).expect("remove generated catalog"); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.is_empty(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, LIST_EXTENSIONS_PATH); + assert!(catalog.contains("name: airtable")); + assert!(catalog.contains("about: Airtable tools")); + assert!(catalog.contains("name: slack")); + assert!(catalog.contains("about: Slack tools")); +} + +#[test] +fn tool_invocation_posts_expected_payload_and_headers() { + let server = MockServer::start(vec![ + list_tools_response("utils", calculate_tool_schema(false)), + MockResponse::json(json!({ + "content": [{"text": {"text": "{\"sum\":5}"}}], + "is_error": false + })), + ]); + + let output = server + .command() + .args([ + "--playpen", + "baxen", + "--goosemcp-playpen", + "smohammed", + "utils", + "calculate", + "--numbers", + "2", + "3", + "--operation", + "add", + "--header", + "x-debug=true", + ]) + .output() + .expect("run agent-tools tool"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(stdout.trim()).expect("parse response JSON"), + json!({"sum": 5}) + ); + + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].path, LIST_TOOLS_PATH); + assert_eq!(requests[1].path, CALL_TOOL_PATH); + assert_eq!( + requests[1].headers.get("baggage").map(String::as_str), + Some("kgoose-playpen=baxen,envoy-route--goosemcp=playpen-smohammed") + ); + assert_eq!( + requests[1].headers.get("content-type").map(String::as_str), + Some("application/json") + ); + assert_eq!(requests[1].body["extension_name"], json!("utils")); + assert_eq!(requests[1].body["tool_name"], json!("calculate")); + assert_eq!(requests[1].body["headers"]["x-debug"], json!("true")); + assert_eq!( + serde_json::from_str::( + requests[1].body["arguments_json"] + .as_str() + .expect("tool arguments_json"), + ) + .expect("parse arguments_json"), + json!({"numbers": [2, 3], "operation": "add"}) + ); +} + +#[test] +fn tool_invocation_forwards_sts_access_token_as_identity_token() { + let server = MockServer::start(vec![ + list_tools_response("utils", calculate_tool_schema(false)), + MockResponse::json(json!({ + "content": [{"text": {"text": "{\"sum\":5}"}}], + "is_error": false + })), + ]); + + let output = server + .command() + .env("STS_ACCESS_TOKEN", "test-sidecar-token") + .args([ + "utils", + "calculate", + "--numbers", + "2", + "3", + "--operation", + "add", + ]) + .output() + .expect("run agent-tools tool"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0] + .headers + .get("x-forwarded-identity-token") + .map(String::as_str), + Some("test-sidecar-token") + ); + assert_eq!( + requests[1] + .headers + .get("x-forwarded-identity-token") + .map(String::as_str), + Some("test-sidecar-token") + ); +} + +#[test] +fn tool_invocation_ignores_bl_auth_storage() { + let server = MockServer::start(vec![ + list_tools_response("utils", calculate_tool_schema(false)), + MockResponse::json(json!({ + "content": [{"text": {"text": "{\"sum\":5}"}}], + "is_error": false + })), + ]); + let temp = std::env::temp_dir().join(format!("sq-kgoose-cli-session-{}", unique_suffix())); + fs::create_dir_all(&temp).expect("create temp dir"); + let storage_path = temp.join("auth-sessions.json"); + let storage_key = + browser_auth_storage_key("default", &format!("{}/cash-app/goose", server.base_url)); + fs::write( + &storage_path, + serde_json::to_string_pretty(&json!({ + storage_key: { + "sessionCredential": "stored-kgoose-session", + "expiresAt": "2026-06-15T00:00:00Z" + } + })) + .expect("serialize storage"), + ) + .expect("write auth storage"); + + let output = server + .command() + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) + .args([ + "utils", + "calculate", + "--numbers", + "2", + "3", + "--operation", + "add", + ]) + .output() + .expect("run agent-tools tool"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 2); + assert!(!requests[0].headers.contains_key("x-bb-session-credential")); + assert!(!requests[1].headers.contains_key("x-bb-session-credential")); + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn tool_invocation_prefers_structured_output_over_duplicate_content() { + let server = MockServer::start(vec![ + list_tools_response("utils", calculate_tool_schema(false)), + MockResponse::json(json!({ + "content": [ + {"text": {"text": "# Messages\n- hello"}}, + {"structured_content": {"data": {"result": {"messages": [{"text": "hello"}]}}}} + ], + "is_error": false, + "structured_content_json": "{\"result\":{\"messages\":[{\"text\":\"hello\"}]}}" + })), + ]); + + let output = server + .command() + .args([ + "utils", + "calculate", + "--numbers", + "2", + "3", + "--operation", + "add", + ]) + .output() + .expect("run agent-tools tool"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(stdout.trim()).expect("parse response JSON"), + json!({ + "result": { + "messages": [{"text": "hello"}] + } + }) + ); +} + +#[test] +fn tool_invocation_raw_prints_full_response_envelope() { + let response = json!({ + "content": [ + {"text": {"text": "{\"sum\":5}"}} + ], + "is_error": false, + "structured_content_json": "{\"sum\":5}" + }); + let server = MockServer::start(vec![ + list_tools_response("utils", calculate_tool_schema(false)), + MockResponse::json(response.clone()), + ]); + + let output = server + .command() + .args([ + "utils", + "calculate", + "--numbers", + "2", + "3", + "--operation", + "add", + "--raw", + ]) + .output() + .expect("run agent-tools tool"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(stdout.trim()).expect("parse raw response JSON"), + response + ); +} + +#[test] +fn tool_invocation_error_response_exits_nonzero() { + let server = MockServer::start(vec![ + list_tools_response("utils", calculate_tool_schema(false)), + MockResponse::json(json!({ + "content": [{"text": {"text": "backend tool failed"}}], + "is_error": true + })), + ]); + + let output = server + .command() + .args([ + "utils", + "calculate", + "--numbers", + "2", + "3", + "--operation", + "add", + ]) + .output() + .expect("run agent-tools tool"); + let _requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!(stderr.contains("backend tool failed")); +} + +#[test] +fn optional_boolean_defaults_do_not_crash_invocation() { + let server = MockServer::start(vec![ + tool_response( + "slack", + "post_message", + "Post a Slack message", + post_message_tool_schema(), + ), + MockResponse::json(json!({ + "content": [{"text": {"text": "{\"ok\":true}"}}], + "is_error": false + })), + ]); + + let output = server + .command() + .args(["slack", "post-message", "--channel-id", "C123"]) + .output() + .expect("run agent-tools tool"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!( + serde_json::from_str::(stdout.trim()).expect("parse rendered JSON"), + json!({"ok": true}) + ); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].body["tool_name"], json!("post_message")); + assert_eq!( + serde_json::from_str::( + requests[1].body["arguments_json"] + .as_str() + .expect("tool arguments_json"), + ) + .expect("parse arguments_json"), + json!({"channel_id": "C123"}) + ); +} + +#[cfg(unix)] +#[test] +fn root_metadata_commands_do_not_read_auth_storage() { + let temp = temp_test_dir("agent-tools-metadata-auth-storage"); + let malformed_storage = temp.join("auth-sessions.json"); + fs::write(&malformed_storage, "not json").expect("write malformed auth storage"); + + for args in [ + vec!["--version"], + vec!["--summary"], + vec!["--describe-commands"], + ] { + let server = MockServer::start(vec![]); + let output = server + .command() + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &malformed_storage) + .args(args) + .output() + .expect("run metadata command"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(requests.is_empty(), "requests were: {requests:#?}"); + } + + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn describe_commands_uses_static_catalog_without_network() { + let server = MockServer::start(vec![]); + let catalog_path = write_extensions_catalog( + "describe-commands", + r#" +- name: secret + about: Needs more auth +- name: utils + about: Utility helpers +"#, + ); + + let output = server + .command() + .env("KGOOSE_EXTENSIONS_CATALOG", &catalog_path) + .arg("--describe-commands") + .output() + .expect("run describe-commands"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + fs::remove_file(&catalog_path).expect("remove extensions catalog"); + + assert!(output.status.success(), "stderr was: {stderr}"); + let description = serde_json::from_str::(&stdout).expect("parse describe output"); + assert_eq!(description["name"], json!("agent-tools")); + assert_eq!( + description["commands"], + json!([ + { + "name": "appkit", + "summary": "Cloudflare-backed internal Block App Kit CLI (local exec)" + }, + { + "name": "secret", + "summary": "Needs more auth" + }, + { + "name": "utils", + "summary": "Utility helpers" + } + ]) + ); + assert!(stderr.is_empty(), "stderr was: {stderr}"); + assert!(requests.is_empty(), "requests were: {requests:#?}"); +} + +#[cfg(unix)] +#[test] +fn appkit_no_args_passes_through_to_real_cli() { + let fake_bin = temp_test_dir("appkit-no-args-passthrough"); + write_fake_executable( + &fake_bin, + "appkit", + "#!/bin/sh\nprintf 'appkit:%s\\n' \"$*\"\n", + ); + let server = MockServer::start(vec![]); + + let output = server + .command() + .env("PATH", &fake_bin) + .args(["appkit"]) + .output() + .expect("run appkit no args"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + fs::remove_dir_all(&fake_bin).expect("remove fake bin"); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(stdout.trim(), "appkit:"); + assert!(requests.is_empty(), "requests were: {requests:#?}"); +} + +#[cfg(unix)] +#[test] +fn appkit_help_flag_passes_through_to_real_cli() { + let fake_bin = temp_test_dir("appkit-help-passthrough"); + write_fake_executable( + &fake_bin, + "appkit", + "#!/bin/sh\nprintf 'appkit:%s\\n' \"$*\"\n", + ); + let server = MockServer::start(vec![]); + + let output = server + .command() + .env("PATH", &fake_bin) + .args(["appkit", "--help"]) + .output() + .expect("run appkit --help"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + fs::remove_dir_all(&fake_bin).expect("remove fake bin"); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(stdout.trim(), "appkit:--help"); + assert!(requests.is_empty(), "requests were: {requests:#?}"); +} + +#[cfg(unix)] +#[test] +fn appkit_on_path_is_preferred_over_uvx() { + let fake_bin = temp_test_dir("appkit-preferred"); + write_fake_executable( + &fake_bin, + "appkit", + "#!/bin/sh\nprintf 'appkit:%s\\n' \"$*\"\n", + ); + write_fake_executable(&fake_bin, "uvx", "#!/bin/sh\nprintf 'uvx:%s\\n' \"$*\"\n"); + let server = MockServer::start(vec![]); + + let output = server + .command() + .env("PATH", &fake_bin) + .args(["appkit", "deploy", "my-site", "./build"]) + .output() + .expect("run fake appkit"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + fs::remove_dir_all(&fake_bin).expect("remove fake bin"); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(stdout.trim(), "appkit:deploy my-site ./build"); + assert!(!stdout.contains("uvx:")); + assert!(requests.is_empty(), "requests were: {requests:#?}"); +} + +#[cfg(unix)] +#[test] +fn appkit_owned_sq_flags_are_forwarded_before_bootstrap() { + let fake_bin = temp_test_dir("appkit-owned-flags"); + write_fake_executable( + &fake_bin, + "appkit", + "#!/bin/sh\nprintf 'appkit:%s\\n' \"$*\"\n", + ); + let server = MockServer::start(vec![]); + + let output = server + .command() + .env("PATH", &fake_bin) + .env("KGOOSE_TIMEOUT", "not-a-number") + .args([ + "appkit", + "deploy", + "--timeout", + "also-not-a-number", + "--summary", + ]) + .output() + .expect("run fake appkit with sq-looking args"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + fs::remove_dir_all(&fake_bin).expect("remove fake bin"); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!( + stdout.trim(), + "appkit:deploy --timeout also-not-a-number --summary" + ); + assert!(requests.is_empty(), "requests were: {requests:#?}"); +} + +#[cfg(unix)] +#[test] +fn appkit_falls_back_to_uvx_and_preserves_environment() { + let fake_bin = temp_test_dir("appkit-uvx-fallback"); + write_fake_executable( + &fake_bin, + "uvx", + "#!/bin/sh\nprintf 'uvx:%s\\n' \"$*\"\nprintf 'sts:%s\\n' \"${STS_ACCESS_TOKEN:-}\"\n", + ); + let server = MockServer::start(vec![]); + + let output = server + .command() + .env("PATH", &fake_bin) + .env("STS_ACCESS_TOKEN", "test-sts-token") + .args(["appkit", "deploy", "my-site", "./build"]) + .output() + .expect("run fake uvx fallback"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + fs::remove_dir_all(&fake_bin).expect("remove fake bin"); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert!(stdout.contains("uvx:--from mcp_block_app_kit appkit deploy my-site ./build")); + assert!(stdout.contains("sts:test-sts-token")); + assert!(requests.is_empty(), "requests were: {requests:#?}"); +} + +#[test] +fn appkit_missing_binary_prints_clear_error() { + let server = MockServer::start(vec![]); + + let output = server + .command() + .env("PATH", "") + .args(["appkit", "list"]) + .output() + .expect("run missing appkit command"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stderr.contains("appkit or uvx not found")); + assert!(stderr.contains("sq agent-tools can run mcp_block_app_kit on demand")); + assert!(requests.is_empty(), "requests were: {requests:#?}"); +} + +#[test] +fn inaccessible_extension_errors_are_humanized_in_e2e_flow() { + let server = MockServer::start(vec![MockResponse::text( + 404, + "Extension 'notion' not found or not authorized", + )]); + + let output = server + .command() + .args(["--playpen", "baxen", "notion", "--help"]) + .output() + .expect("run help for inaccessible extension"); + let requests = server.finish(); + let (_stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stderr.contains("Can't inspect `notion` in playpen `baxen`")); + assert!(stderr.contains("wouldn't return its tools")); + assert!(stderr.contains("G2 Connections settings")); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, LIST_TOOLS_PATH); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..3cc4d11 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,467 @@ +//! Shared e2e harness: a sequential mock HTTP server plus helpers used by +//! both the `agent-tools`/sq test suite and the `bl` test suite. Each test +//! binary compiles its own copy, so unused helpers are expected per-binary. +#![allow(dead_code)] + +use std::collections::{BTreeMap, VecDeque}; +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use serde_json::{json, Value}; + +pub const LIST_EXTENSIONS_PATH: &str = "/cash-app/goose/v3/list-extensions"; +pub const LIST_TOOLS_PATH: &str = "/cash-app/goose/v3/list-tools"; +pub const CALL_TOOL_PATH: &str = "/cash-app/goose/v3/call-tool"; +pub const BL_TOOLS_LIST_TOOLS_PATH: &str = "/api/v3/list-tools"; +pub const BL_TOOLS_CALL_TOOL_PATH: &str = "/api/v3/call-tool"; + +#[derive(Debug, Clone)] +pub struct RecordedRequest { + pub method: String, + pub path: String, + pub headers: BTreeMap, + pub body: Value, + pub body_bytes: Vec, +} + +#[derive(Debug, Clone)] +pub struct MockResponse { + pub status: u16, + pub headers: BTreeMap, + pub body: Vec, +} + +impl MockResponse { + pub fn json(body: Value) -> Self { + let mut headers = BTreeMap::new(); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + Self { + status: 200, + headers, + body: serde_json::to_vec(&body).expect("serialize mock response"), + } + } + + pub fn text(status: u16, body: &str) -> Self { + let mut headers = BTreeMap::new(); + headers.insert("Content-Type".to_string(), "text/plain".to_string()); + Self { + status, + headers, + body: body.as_bytes().to_vec(), + } + } + + pub fn bytes(status: u16, body: Vec, headers: &[(&str, String)]) -> Self { + Self { + status, + headers: headers + .iter() + .map(|(name, value)| ((*name).to_string(), value.clone())) + .collect(), + body, + } + } +} + +pub struct MockServer { + pub base_url: String, + requests: Arc>>, + shutdown: Arc, + handle: Option>, +} + +impl MockServer { + pub fn start(responses: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server"); + listener + .set_nonblocking(true) + .expect("set mock server nonblocking"); + + let base_url = format!( + "http://{}", + listener.local_addr().expect("mock server addr") + ); + let requests = Arc::new(Mutex::new(Vec::new())); + let shutdown = Arc::new(AtomicBool::new(false)); + + let thread_requests = Arc::clone(&requests); + let thread_shutdown = Arc::clone(&shutdown); + let handle = thread::spawn(move || { + let mut responses = VecDeque::from(responses); + + loop { + match listener.accept() { + Ok((stream, _)) => { + handle_connection(stream, &thread_requests, &mut responses); + if responses.is_empty() { + break; + } + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + if thread_shutdown.load(Ordering::SeqCst) { + break; + } + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("accept mock request: {err}"), + } + } + + assert!( + responses.is_empty(), + "unserved mock responses remained: {}", + responses.len() + ); + }); + + Self { + base_url, + requests, + shutdown, + handle: Some(handle), + } + } + + pub fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_agent-tools")); + command + .env_remove("KGOOSE_BASE_URL") + .env_remove("KGOOSE_DEBUG") + .env_remove("KGOOSE_PLAYPEN") + .env_remove("KGOOSE_SERVICE_PATH") + .env_remove("KGOOSE_TIMEOUT") + .env_remove("STS_ACCESS_TOKEN") + .env_remove("BL_HOME") + .env_remove("BL_SKILLS_PROFILE") + .env_remove("BL_AUTH_STORAGE") + .env_remove("BL_AUTH_STORAGE_FILE") + .arg("--base-url") + .arg(&self.base_url); + command + } + + pub fn bl_tools_command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_bl")); + let bl_home = bl_home_with_org("bl-tools-home", "test"); + command + .env_remove("KGOOSE_BASE_URL") + .env_remove("KGOOSE_DEBUG") + .env_remove("KGOOSE_PLAYPEN") + .env_remove("KGOOSE_SERVICE_PATH") + .env_remove("KGOOSE_TIMEOUT") + .env_remove("STS_ACCESS_TOKEN") + .env("BL_HOME", bl_home) + .env_remove("BL_SKILLS_CONFIG") + .env_remove("BL_SKILLS_PROFILE") + .env_remove("BL_AUTH_STORAGE") + .env_remove("BL_AUTH_STORAGE_FILE") + .arg("tools") + .arg("--base-url") + .arg(&self.base_url); + command + } + + pub fn finish(mut self) -> Vec { + self.shutdown.store(true, Ordering::SeqCst); + self.handle + .take() + .expect("mock server handle") + .join() + .expect("join mock server thread"); + self.requests + .lock() + .expect("lock recorded requests") + .clone() + } +} + +pub fn bl_command() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_bl")); + let bl_home = bl_home_with_org("bl-home", "test"); + command + .env("BL_HOME", bl_home) + .env_remove("BL_SKILLS_HOME") + .env_remove("BL_SKILLS_PACKAGES_DIR") + .env_remove("BL_SKILLS_CONFIG") + .env_remove("BL_SKILLS_PROFILE") + .env_remove("BL_KGOOSE_PLAYPEN") + .env_remove("BL_AUTH_STORAGE") + .env_remove("BL_AUTH_STORAGE_FILE") + .env_remove("KGOOSE_BASE_URL") + .env_remove("KGOOSE_DEBUG") + .env_remove("KGOOSE_PLAYPEN") + .env_remove("KGOOSE_SERVICE_PATH") + .env_remove("KGOOSE_TIMEOUT") + .env_remove("STS_ACCESS_TOKEN"); + command +} + +pub fn bl_home_with_org(prefix: &str, org: &str) -> PathBuf { + let bl_home = temp_test_dir(prefix); + write_bl_org_config(&bl_home, org); + bl_home +} + +pub fn write_bl_org_config(bl_home: &Path, org: &str) { + fs::create_dir_all(bl_home).expect("create bl home"); + fs::write(bl_home.join("config.yaml"), format!("org: {org}\n")).expect("write bl config"); +} + +fn handle_connection( + mut stream: TcpStream, + requests: &Arc>>, + responses: &mut VecDeque, +) { + stream + .set_nonblocking(false) + .expect("set mock stream blocking"); + let mut reader = BufReader::new(stream.try_clone().expect("clone mock stream")); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .expect("read mock request line"); + assert!( + !request_line.is_empty(), + "mock server received an empty request" + ); + + let mut parts = request_line.split_whitespace(); + let method = parts.next().expect("mock request method"); + let path = parts.next().expect("mock request path"); + + let mut headers = BTreeMap::new(); + let mut content_length = 0usize; + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .expect("read mock request header"); + if line == "\r\n" { + break; + } + + let (name, value) = line + .split_once(':') + .expect("mock request header should contain ':'"); + let normalized_name = name.trim().to_ascii_lowercase(); + let normalized_value = value.trim().to_string(); + + if normalized_name == "content-length" { + content_length = normalized_value + .parse::() + .expect("parse mock request content-length"); + } + + headers.insert(normalized_name, normalized_value); + } + + let mut body = vec![0; content_length]; + reader + .read_exact(&mut body) + .expect("read mock request body"); + let parsed_body = if body.is_empty() { + Value::Null + } else if headers + .get("content-type") + .is_some_and(|value| value.starts_with("application/json")) + { + serde_json::from_slice::(&body).expect("json mock request body") + } else { + Value::Null + }; + + requests + .lock() + .expect("lock recorded requests") + .push(RecordedRequest { + method: method.to_string(), + path: path.to_string(), + headers, + body: parsed_body, + body_bytes: body, + }); + + let response = responses + .pop_front() + .expect("unexpected request without a queued response"); + let mut response_head = format!( + "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n", + response.status, + http_reason_phrase(response.status), + response.body.len() + ); + for (name, value) in &response.headers { + response_head.push_str(name); + response_head.push_str(": "); + response_head.push_str(value); + response_head.push_str("\r\n"); + } + response_head.push_str("\r\n"); + stream + .write_all(response_head.as_bytes()) + .expect("write mock response head"); + stream + .write_all(&response.body) + .expect("write mock response"); +} + +fn http_reason_phrase(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 403 => "Forbidden", + 404 => "Not Found", + _ => "Internal Server Error", + } +} + +pub fn output_text(output: &Output) -> (String, String) { + ( + String::from_utf8(output.stdout.clone()).expect("utf8 stdout"), + String::from_utf8(output.stderr.clone()).expect("utf8 stderr"), + ) +} + +pub fn write_extensions_catalog(prefix: &str, contents: &str) -> PathBuf { + let unique = format!("{}-{}", std::process::id(), unique_suffix()); + let path = std::env::temp_dir().join(format!("{prefix}-{unique}.yaml")); + fs::write(&path, contents).expect("write extensions catalog"); + path +} + +pub fn unique_suffix() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() +} + +pub fn temp_test_dir(prefix: &str) -> PathBuf { + let unique = format!("{}-{}", std::process::id(), unique_suffix()); + let path = std::env::temp_dir().join(format!("{prefix}-{unique}")); + fs::create_dir_all(&path).expect("create temp test dir"); + path +} + +#[cfg(unix)] +pub fn write_fake_executable(directory: &Path, name: &str, body: &str) { + let path = directory.join(name); + fs::write(&path, body).expect("write fake executable"); + let mut permissions = fs::metadata(&path) + .expect("fake executable metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake executable"); +} + +pub fn calculate_tool_schema(with_round_up: bool) -> String { + let mut properties = serde_json::Map::from_iter([ + ( + "numbers".to_string(), + json!({ + "type": "array", + "items": {"type": "number"}, + "description": "Numbers to process" + }), + ), + ( + "operation".to_string(), + json!({ + "type": "string", + "enum": ["add", "subtract"], + "description": "Operation to apply" + }), + ), + ]); + + if with_round_up { + properties.insert( + "round_up".to_string(), + json!({ + "type": "boolean", + "description": "Round the result up", + "default": true + }), + ); + } + + json!({ + "type": "object", + "properties": properties, + "required": ["numbers", "operation"] + }) + .to_string() +} + +pub fn post_message_tool_schema() -> String { + json!({ + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "description": "Slack channel ID" + }, + "dm_myself": { + "type": "boolean", + "description": "Send the message to yourself", + "default": false + } + }, + "required": ["channel_id"] + }) + .to_string() +} + +pub fn list_tools_response(extension_name: &str, schema_json: String) -> MockResponse { + tool_response_with_extension_description( + extension_name, + "Utility helpers", + "calculate", + "Perform math", + schema_json, + ) +} + +pub fn tool_response( + extension_name: &str, + tool_name: &str, + description: &str, + schema_json: String, +) -> MockResponse { + tool_response_with_extension_description( + extension_name, + "Utility helpers", + tool_name, + description, + schema_json, + ) +} + +pub fn tool_response_with_extension_description( + extension_name: &str, + extension_description: &str, + tool_name: &str, + description: &str, + schema_json: String, +) -> MockResponse { + MockResponse::json(json!({ + "extension_name": extension_name, + "extension_description": extension_description, + "tools": [{ + "tool": tool_name, + "description": description, + "config_json": schema_json, + "mutates_state": false + }] + })) +} diff --git a/tests/docker_acceptance_contract.rs b/tests/docker_acceptance_contract.rs new file mode 100644 index 0000000..5c13711 --- /dev/null +++ b/tests/docker_acceptance_contract.rs @@ -0,0 +1,278 @@ +use std::fs; +use std::net::TcpListener; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::thread; +use std::time::Duration; + +use serde_json::Value; +use tempfile::TempDir; + +fn acceptance_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("docker/acceptance") +} + +fn available_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind port"); + listener.local_addr().expect("read port").port() +} + +fn running_as_root() -> bool { + Command::new("id") + .arg("-u") + .output() + .map(|output| output.stdout == b"0\n") + .unwrap_or(false) +} + +fn wait_for_server(port: u16) { + for _ in 0..50 { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + return; + } + thread::sleep(Duration::from_millis(20)); + } + panic!("mock marketplace did not start on port {port}"); +} + +fn start_marketplace(port: u16, args: &[&str]) -> Child { + let mut command = Command::new("python3"); + command + .arg(acceptance_root().join("mock-marketplace.py")) + .arg("--port") + .arg(port.to_string()) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let child = command.spawn().expect("start mock marketplace"); + wait_for_server(port); + child +} + +fn runner_command(temp: &TempDir) -> Command { + let mut command = Command::new("sh"); + command + .arg(acceptance_root().join("run-acceptance.sh")) + .env("BL_ACCEPTANCE_BL_PATH", env!("CARGO_BIN_EXE_bl")) + .env( + "BL_ACCEPTANCE_MOCK_MARKETPLACE", + acceptance_root().join("mock-marketplace.py"), + ) + .env("BL_ACCEPTANCE_REPORT_PATH", temp.path().join("report.json")) + .env_remove("BL_HOME") + .env_remove("BL_SKILLS_HOME") + .env_remove("BL_SKILLS_PACKAGES_DIR") + .env_remove("BL_AUTH_STORAGE") + .env_remove("BL_AUTH_STORAGE_FILE") + .env_remove("KGOOSE_BASE_URL") + .env_remove("KGOOSE_SERVICE_PATH") + .env_remove("KGOOSE_PLAYPEN") + .env_remove("BL_KGOOSE_PLAYPEN") + .env_remove("BL_MARKETPLACE_BASE_URL") + .env_remove("BL_SESSION_CREDENTIAL") + .env_remove("BL_ACCEPTANCE_BUNDLE") + .env_remove("BL_ACCEPTANCE_MOCK_START_ATTEMPTS"); + command +} + +fn output_text(output: &Output) -> (String, String) { + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +fn assert_isolated_report(temp: &TempDir, mode: &str) { + let report: Value = serde_json::from_slice( + &fs::read(temp.path().join("report.json")).expect("read acceptance report"), + ) + .expect("parse acceptance report"); + assert_eq!(report["mode"], mode); + let home = report["home"].as_str().expect("report home"); + for key in [ + "bl_home", + "skills_home", + "packages_dir", + "auth_storage_file", + ] { + assert!( + report[key].as_str().expect("report path").starts_with(home), + "{key} escapes the isolated home" + ); + } +} + +#[test] +fn runner_executes_mock_bundle_contract_in_an_isolated_home() { + if running_as_root() { + return; + } + let temp = tempfile::tempdir().expect("create temp dir"); + let port = available_port(); + let output = runner_command(&temp) + .env("BL_ACCEPTANCE_MODE", "mock") + .env("BL_ACCEPTANCE_MOCK_PORT", port.to_string()) + .output() + .expect("run acceptance runner"); + let (stdout, stderr) = output_text(&output); + + assert!( + output.status.success(), + "stdout: {stdout}\nstderr: {stderr}" + ); + assert!(stdout.contains("Docker mock acceptance passed.")); + assert_isolated_report(&temp, "mock"); +} + +#[test] +fn runner_succeeds_when_no_diagnostic_report_is_requested() { + if running_as_root() { + return; + } + let temp = tempfile::tempdir().expect("create temp dir"); + let port = available_port(); + let output = runner_command(&temp) + .env_remove("BL_ACCEPTANCE_REPORT_PATH") + .env("BL_ACCEPTANCE_MODE", "mock") + .env("BL_ACCEPTANCE_MOCK_PORT", port.to_string()) + .output() + .expect("run acceptance runner without a report"); + let (stdout, stderr) = output_text(&output); + + assert!( + output.status.success(), + "stdout: {stdout}\nstderr: {stderr}" + ); + assert!(stdout.contains("Docker mock acceptance passed.")); + assert!(!temp.path().join("report.json").exists()); +} + +#[test] +fn runner_fails_when_the_mock_exits_before_becoming_ready() { + if running_as_root() { + return; + } + let temp = tempfile::tempdir().expect("create temp dir"); + let port = available_port(); + let output = runner_command(&temp) + .env("BL_ACCEPTANCE_MODE", "mock") + .env("BL_ACCEPTANCE_MOCK_PORT", port.to_string()) + .env("BL_ACCEPTANCE_MOCK_START_ATTEMPTS", "2") + .env( + "BL_ACCEPTANCE_MOCK_MARKETPLACE", + acceptance_root().join("missing-mock-marketplace.py"), + ) + .output() + .expect("run acceptance runner with a missing mock"); + let (_, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!( + stderr.contains("mock marketplace exited before becoming ready"), + "stderr was: {stderr}" + ); + assert!(!temp.path().join("report.json").exists()); +} + +#[test] +fn runner_live_mode_forwards_runtime_credential_and_playpen() { + if running_as_root() { + return; + } + let temp = tempfile::tempdir().expect("create temp dir"); + let port = available_port(); + let secret = "docker-acceptance-test-session"; + let mut server = start_marketplace( + port, + &[ + "--expect-session-credential", + secret, + "--expect-playpen", + "test-playpen", + "--expect-service-path", + "/cash-app/goose", + ], + ); + let output = runner_command(&temp) + .env("BL_ACCEPTANCE_MODE", "live") + .env( + "BL_MARKETPLACE_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .env("BL_SESSION_CREDENTIAL", secret) + .env("KGOOSE_PLAYPEN", "test-playpen") + .output() + .expect("run live acceptance runner"); + server.kill().expect("stop mock marketplace"); + server.wait().expect("wait for mock marketplace"); + let (stdout, stderr) = output_text(&output); + + assert!( + output.status.success(), + "stdout: {stdout}\nstderr: {stderr}" + ); + assert!(!stdout.contains(secret)); + assert!(!stderr.contains(secret)); + assert_isolated_report(&temp, "live"); +} + +#[test] +fn runner_live_mode_reports_each_missing_runtime_input() { + if running_as_root() { + return; + } + let temp = tempfile::tempdir().expect("create temp dir"); + let output = runner_command(&temp) + .env("BL_ACCEPTANCE_MODE", "live") + .output() + .expect("run live acceptance runner without inputs"); + let (_, stderr) = output_text(&output); + assert!(!output.status.success()); + assert!(stderr.contains("BL_MARKETPLACE_BASE_URL")); + + let output = runner_command(&temp) + .env("BL_ACCEPTANCE_MODE", "live") + .env("BL_MARKETPLACE_BASE_URL", "http://127.0.0.1:1") + .output() + .expect("run live acceptance runner without credential"); + let (_, stderr) = output_text(&output); + assert!(!output.status.success()); + assert!(stderr.contains("BL_SESSION_CREDENTIAL")); +} + +#[cfg(unix)] +#[test] +fn just_recipe_builds_and_runs_the_acceptance_image() { + let temp = tempfile::tempdir().expect("create temp dir"); + let bin_dir = temp.path().join("bin"); + fs::create_dir(&bin_dir).expect("create bin dir"); + let log = temp.path().join("docker.log"); + let docker = bin_dir.join("docker"); + fs::write( + &docker, + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$BL_ACCEPTANCE_DOCKER_LOG\"\n", + ) + .expect("write docker shim"); + fs::set_permissions(&docker, fs::Permissions::from_mode(0o755)) + .expect("make docker shim executable"); + let path = std::env::var("PATH").expect("read PATH"); + let output = Command::new("just") + .arg("bl-cli-docker-acceptance") + .current_dir(Path::new(env!("CARGO_MANIFEST_DIR"))) + .env("PATH", format!("{}:{path}", bin_dir.display())) + .env("BL_ACCEPTANCE_DOCKER_LOG", &log) + .output() + .expect("run acceptance just recipe"); + let (stdout, stderr) = output_text(&output); + + assert!( + output.status.success(), + "stdout: {stdout}\nstderr: {stderr}" + ); + assert_eq!( + fs::read_to_string(log).expect("read docker command log"), + "build --tag bl-cli-acceptance --file docker/acceptance/Dockerfile .\nrun --rm bl-cli-acceptance\n" + ); +} From 1008cc08e49533da66d78d873f4d7c4a48c89a08 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Tue, 22 Sep 2026 14:41:16 +1000 Subject: [PATCH 02/11] Add repository CI workflow Signed-off-by: Jarrod Sibbison --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b4ada6f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install Hermit tools + run: ./bin/hermit install rustup just + + - name: Run repository CI + run: | + eval "$(./bin/hermit env --activate)" + just ci From 043ff7267bb3bec99ba628b7ed6d3458b6a37d05 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Tue, 22 Sep 2026 15:44:55 +1000 Subject: [PATCH 03/11] Run CI without private Hermit packages Signed-off-by: Jarrod Sibbison --- .github/workflows/ci.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4ada6f..32c19df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,11 @@ jobs: - name: Check out source uses: actions/checkout@v4 - - name: Install Hermit tools - run: ./bin/hermit install rustup just + - name: Install Rust toolchain + run: rustup toolchain install 1.91.1 --profile minimal --component clippy --component rustfmt + + - name: Install just + run: cargo +1.91.1 install just --locked - name: Run repository CI - run: | - eval "$(./bin/hermit env --activate)" - just ci + run: just ci From 1c3b85e4684dc3adef0ab52ee91cac0a54c57a32 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Tue, 22 Sep 2026 15:47:20 +1000 Subject: [PATCH 04/11] Use standard Hermit CI setup Signed-off-by: Jarrod Sibbison --- .github/workflows/ci.yml | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32c19df..bf43114 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,19 +10,33 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CI: "true" + FORCE_COLOR: "1" + jobs: ci: + name: Rust checks runs-on: ubuntu-latest + timeout-minutes: 45 steps: - - name: Check out source - uses: actions/checkout@v4 + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - - name: Install Rust toolchain - run: rustup toolchain install 1.91.1 --profile minimal --component clippy --component rustfmt + - name: Activate Hermit + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Install just - run: cargo +1.91.1 install just --locked + - name: Restore Rust cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: . -> target - name: Run repository CI run: just ci From 16ffb99b0e85ef8301533d456266c849e86a4342 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Tue, 22 Sep 2026 15:49:03 +1000 Subject: [PATCH 05/11] Allow Hermit to manage CI git authentication Signed-off-by: Jarrod Sibbison --- bin/hermit.hcl | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bin/hermit.hcl b/bin/hermit.hcl index cc17d79..b429ec9 100644 --- a/bin/hermit.hcl +++ b/bin/hermit.hcl @@ -1,4 +1 @@ -manage-git = false - -github-token-auth { -} +manage-git = true From e4109944327d8ef3893e33b354213c9560eab6cc Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Tue, 22 Sep 2026 15:52:07 +1000 Subject: [PATCH 06/11] Provide GitHub token to Hermit Signed-off-by: Jarrod Sibbison --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf43114..3822fe5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ concurrency: env: CI: "true" FORCE_COLOR: "1" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: ci: From a1fc20923a0a9b1c479d901b4a9b85f6d30c4048 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Tue, 22 Sep 2026 15:53:45 +1000 Subject: [PATCH 07/11] Use public runner tools for CI Signed-off-by: Jarrod Sibbison --- .github/workflows/ci.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3822fe5..1fcb460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,6 @@ concurrency: env: CI: "true" FORCE_COLOR: "1" - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: ci: @@ -31,13 +30,20 @@ jobs: with: persist-credentials: false - - name: Activate Hermit - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Restore Rust cache uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: . -> target + # Public GitHub runners cannot read Block's private hermit-packages repo. + # Use the pinned repository toolchain until Actions access is provisioned. + - name: Install Rust toolchain + run: rustup toolchain install 1.91.1 --profile minimal --component clippy --component rustfmt + + - name: Install just + uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2.85.11 + with: + tool: just@1.48.0 + - name: Run repository CI run: just ci From 9c186492d5c5351b0770eaff513115b2a8fa3b4e Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Tue, 22 Sep 2026 16:35:32 +1000 Subject: [PATCH 08/11] Use public Hermit packages Signed-off-by: Jarrod Sibbison --- .github/workflows/ci.yml | 15 +++++---------- Justfile | 7 ++++++- bin/.schema-registry-0.127.1.pkg | 1 - bin/hermit.hcl | 1 + bin/schema-registry | 1 - 5 files changed, 12 insertions(+), 13 deletions(-) delete mode 120000 bin/.schema-registry-0.127.1.pkg delete mode 120000 bin/schema-registry diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fcb460..b9baeb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,20 +30,15 @@ jobs: with: persist-credentials: false + - name: Activate Hermit + uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + with: + cache: "true" + - name: Restore Rust cache uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: . -> target - # Public GitHub runners cannot read Block's private hermit-packages repo. - # Use the pinned repository toolchain until Actions access is provisioned. - - name: Install Rust toolchain - run: rustup toolchain install 1.91.1 --profile minimal --component clippy --component rustfmt - - - name: Install just - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2.85.11 - with: - tool: just@1.48.0 - - name: Run repository CI run: just ci diff --git a/Justfile b/Justfile index b725f13..5022d1d 100644 --- a/Justfile +++ b/Justfile @@ -27,7 +27,12 @@ protos: # Download all protos without transitive deps (-i) to avoid pulling in # ~250 arcade/UI/franklin protos via client_renderable.proto - bin/schema-registry get --save-to=protos -i \ + if ! command -v schema-registry >/dev/null 2>&1; then + echo "schema-registry is not a public Hermit package; install the internal CLI separately to refresh protos." >&2 + exit 1 + fi + + schema-registry get --save-to=protos -i \ google/api/annotations.proto \ google/api/http.proto \ squareup/cash/kgoose/api/v3/tool_endpoint_service.proto \ diff --git a/bin/.schema-registry-0.127.1.pkg b/bin/.schema-registry-0.127.1.pkg deleted file mode 120000 index 383f451..0000000 --- a/bin/.schema-registry-0.127.1.pkg +++ /dev/null @@ -1 +0,0 @@ -hermit \ No newline at end of file diff --git a/bin/hermit.hcl b/bin/hermit.hcl index b429ec9..85c8ffb 100644 --- a/bin/hermit.hcl +++ b/bin/hermit.hcl @@ -1 +1,2 @@ +sources = ["https://github.com/cashapp/hermit-packages.git"] manage-git = true diff --git a/bin/schema-registry b/bin/schema-registry deleted file mode 120000 index 8b5fe99..0000000 --- a/bin/schema-registry +++ /dev/null @@ -1 +0,0 @@ -.schema-registry-0.127.1.pkg \ No newline at end of file From e240241b58dde8e636e968f925ed70596422413f Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Tue, 22 Sep 2026 20:35:00 +1000 Subject: [PATCH 09/11] Update BuilderLab CODEOWNERS team --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 7e2f4e0..101eb97 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -6,4 +6,4 @@ # The format is described: https://github.blog/2017-07-06-introducing-code-owners/ # These owners will be the default owners for everything in the repo. -* @block/berd-oss-team +* @block/builderlab-oss-team From f7a9ac269f8f532bae7da8a6feea26e372a6623c Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Wed, 23 Sep 2026 10:18:58 -0400 Subject: [PATCH 10/11] Preserve backend and persisted contracts across CLI rename Signed-off-by: Nathan Thillairajah --- README.md | 24 ++++++++++++++++++++ crates/builderlab-auth/src/auth_login.rs | 3 ++- crates/builderlab-auth/src/auth_storage.rs | 8 +++---- crates/builderlab-auth/src/keychain.rs | 2 +- docker/acceptance/mock-marketplace.py | 2 +- docker/acceptance/run-acceptance.sh | 6 ++--- docs/bl-auth-local-testing.md | 2 +- extensions.yaml | 2 +- src/bl/agents_install.rs | 2 +- src/bl/apps.rs | 21 ++++++++--------- src/bl/skills_api.rs | 2 +- src/bl/skills_config.rs | 3 ++- src/bl/skills_install.rs | 4 ++-- src/catalog.rs | 7 ++++++ tests/bl_e2e.rs | 26 +++++++++++----------- 15 files changed, 74 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 6839976..56b5432 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ cargo build --locked --bin bl ./target/debug/bl --version ``` +Service access requires an existing BuilderLab account and a reachable backend. +The inherited default is Block's internal `https://kgoose.sqprod.co`; building +this public repository does not grant access to that service. For another +BuilderLab installation, set `KGOOSE_BASE_URL` to the base URL supplied by its +operator before logging in. Organization configuration does not replace that +base URL. + Authenticate before using commands that access BuilderLab services: ```bash @@ -143,3 +150,20 @@ integration. - [Security policy](SECURITY.md) - [Block Open Source governance](GOVERNANCE.md) - [Apache License 2.0](LICENSE) + +## Moving from `bb` + +The executable and environment variables now use `bl` and `BL_`. The default +state directory is `~/.bl`; existing `~/.bb` preferences and agent installation +records are not automatically moved. To continue using that state, explicitly +set `BL_HOME="$HOME/.bb"` before running `bl`, and translate any `BB_` overrides +to their `BL_` equivalents. Keep the same backend URL and profile to reuse a +stored session. macOS keychain IDs and skill/agent ownership metadata retain +their legacy names so existing credentials and managed installs remain usable. +Do not use `--force` just to adopt an existing installation. + +The backend `X-BB-Session-Credential` header, `BBIdentity` authorization scheme, +`builderbot` extension ID, and +Playpen routing key are protocol identifiers and retain their existing names. +On Linux and Windows, keyring login storage is not implemented; the explicit +`BL_AUTH_STORAGE=file` option is available as described in the local auth guide. diff --git a/crates/builderlab-auth/src/auth_login.rs b/crates/builderlab-auth/src/auth_login.rs index 8f07a26..f50e718 100644 --- a/crates/builderlab-auth/src/auth_login.rs +++ b/crates/builderlab-auth/src/auth_login.rs @@ -195,8 +195,9 @@ pub fn auth_url(server_url: &str, path: &str) -> Result { Ok(url) } +// Backend routing contract; preserve this key across CLI product renames. pub fn playpen_baggage(playpen: Option<&str>) -> Option { - playpen.map(|playpen| format!("kgoose-builderlab-playpen={playpen}")) + playpen.map(|playpen| format!("kgoose-builderbot-playpen={playpen}")) } #[cfg(test)] diff --git a/crates/builderlab-auth/src/auth_storage.rs b/crates/builderlab-auth/src/auth_storage.rs index 702145c..fcdd747 100644 --- a/crates/builderlab-auth/src/auth_storage.rs +++ b/crates/builderlab-auth/src/auth_storage.rs @@ -13,9 +13,9 @@ use sha2::{Digest, Sha256}; use crate::config::kgoose_service_url; #[cfg(target_os = "macos")] -const KEYRING_SERVICE: &str = "com.squareup.builderlab.cli-auth"; +const KEYRING_SERVICE: &str = "com.squareup.builderbot.cli-auth"; #[cfg(target_os = "macos")] -const LEGACY_PURPOSE_TOKEN_KEYRING_SERVICE: &str = "com.squareup.builderlab.cli-auth-purpose-token"; +const LEGACY_PURPOSE_TOKEN_KEYRING_SERVICE: &str = "com.squareup.builderbot.cli-auth-purpose-token"; pub const BL_AUTH_STORAGE_ENV_VAR: &str = "BL_AUTH_STORAGE"; pub const BL_AUTH_STORAGE_FILE_ENV_VAR: &str = "BL_AUTH_STORAGE_FILE"; @@ -561,14 +561,14 @@ mod tests { let key = SessionStorageKey::new("default", "https://kgoose.stage.sqprod.co/cash-app/goose/"); - assert_eq!(KEYRING_SERVICE, "com.squareup.builderlab.cli-auth"); + assert_eq!(KEYRING_SERVICE, "com.squareup.builderbot.cli-auth"); assert_eq!( key.account(), "default@https://kgoose.stage.sqprod.co/cash-app/goose" ); assert_eq!( LEGACY_PURPOSE_TOKEN_KEYRING_SERVICE, - "com.squareup.builderlab.cli-auth-purpose-token" + "com.squareup.builderbot.cli-auth-purpose-token" ); assert_eq!( legacy_compose_token_account(&key), diff --git a/crates/builderlab-auth/src/keychain.rs b/crates/builderlab-auth/src/keychain.rs index 1b84440..a7ea6c7 100644 --- a/crates/builderlab-auth/src/keychain.rs +++ b/crates/builderlab-auth/src/keychain.rs @@ -133,7 +133,7 @@ mod tests { #[test] fn build_query_does_not_scope_to_access_group() { - let query = build_query("com.squareup.builderlab.cli-auth", "default@example"); + let query = build_query("com.squareup.builderbot.cli-auth", "default@example"); assert_eq!(query.len(), 3); assert!( diff --git a/docker/acceptance/mock-marketplace.py b/docker/acceptance/mock-marketplace.py index 2064f29..5634349 100644 --- a/docker/acceptance/mock-marketplace.py +++ b/docker/acceptance/mock-marketplace.py @@ -54,7 +54,7 @@ def authenticate(self): return False if self.expected_playpen: baggage = self.headers.get("Baggage", "") - if f"kgoose-builderlab-playpen={self.expected_playpen}" not in baggage: + if f"kgoose-builderbot-playpen={self.expected_playpen}" not in baggage: self.send_error(400) return False if self.expected_service_path and not self.path.startswith(f"{self.expected_service_path}/v1/marketplace/"): diff --git a/docker/acceptance/run-acceptance.sh b/docker/acceptance/run-acceptance.sh index 8d33407..b389cc4 100755 --- a/docker/acceptance/run-acceptance.sh +++ b/docker/acceptance/run-acceptance.sh @@ -78,9 +78,9 @@ PY assert_mock_result() { package="$BL_SKILLS_PACKAGES_DIR/docker-harness" test -f "$package/SKILL.md" || fail "mock install did not create the skills-only package" - test -f "$package/.bl-skills-meta.json" || fail "mock install did not create BL metadata" - grep -q 'bl-skills-install/v1' "$package/.bl-skills-meta.json" || fail "mock metadata has unexpected schema" - grep -q 'bundle:default' "$package/.bl-skills-meta.json" || fail "mock metadata lacks bundle provenance" + test -f "$package/.bb-skills-meta.json" || fail "mock install did not create BL metadata" + grep -q 'bb-skills-install/v1' "$package/.bb-skills-meta.json" || fail "mock metadata has unexpected schema" + grep -q 'bundle:default' "$package/.bb-skills-meta.json" || fail "mock metadata lacks bundle provenance" test -f "$BL_SKILLS_PACKAGES_DIR/unmanaged/sentinel.txt" || fail "mock install removed unmanaged sentinel" assert_idempotent_result "$RUN_ROOT/repeat-install.json" "repeat install" assert_idempotent_result "$RUN_ROOT/update.json" "update" diff --git a/docs/bl-auth-local-testing.md b/docs/bl-auth-local-testing.md index 01b0bd0..c52e88a 100644 --- a/docs/bl-auth-local-testing.md +++ b/docs/bl-auth-local-testing.md @@ -99,5 +99,5 @@ KGOOSE_BASE_URL="https://blockstaging.build" \ - The dynamic Java app port is the port that serves `/cash-app/goose`; `8080` is the health/admin listener. - Non-local-dev `bl` commands require `org`; set it with `bl config set org ` or let interactive `bl auth login` prompt for it. - `KGOOSE_BASE_URL` is the pure base URL. For non-local commands, the CLI derives the org-routed host and uses the public `/api/goose` BFF prefix; set `KGOOSE_SERVICE_PATH=/cash-app/goose` when calling kgoose directly. -- `BL_KGOOSE_PLAYPEN` routes bl backend requests with `Baggage: kgoose-builderlab-playpen=`. +- `BL_KGOOSE_PLAYPEN` routes bl backend requests with `Baggage: kgoose-builderbot-playpen=`. - Do not log callback query strings, cookies, or returned session credentials. diff --git a/extensions.yaml b/extensions.yaml index 27cca8c..6b0dd37 100644 --- a/extensions.yaml +++ b/extensions.yaml @@ -19,7 +19,7 @@ about: Searches Square's public support-center articles (Bookshelf semantic search). - name: bugsnag about: Browse and triage Bugsnag error reports for mobile and client apps. -- name: builderlab +- name: builderbot about: BuilderLab — Block's task orchestration system for engineering automation. - name: ci-results about: Fetch CI build analysis and diagnose build failures. diff --git a/src/bl/agents_install.rs b/src/bl/agents_install.rs index 9f2d3b1..f6e138f 100644 --- a/src/bl/agents_install.rs +++ b/src/bl/agents_install.rs @@ -17,7 +17,7 @@ use super::skills_archive::{extract_zip_safely, sha256_hex, verify_agent_artifac use super::skills_config::{default_agents_agents_dir, kgoose_service_url, SkillsConfig}; use super::skills_targets::iso8601_utc; -const RECORD_SCHEMA: &str = "bl-agent-install/v1"; +const RECORD_SCHEMA: &str = "bb-agent-install/v1"; const LOCK_STALE_SECS: u64 = 15 * 60; #[derive(Debug, Clone)] diff --git a/src/bl/apps.rs b/src/bl/apps.rs index a4e8cd2..56fe1d4 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -1,12 +1,12 @@ //! External BuilderLab Apps Platform control-plane commands. //! -//! This module serves only the external pilot: first in `bl-block` staging, -//! then in the multi-tenant `bl-public` environment. It does not replace the +//! This module serves only the external pilot: first in `bb-block` staging, +//! then in the multi-tenant `bb-public` environment. It does not replace the //! existing Cloudflare-backed internal Block App Kit CLI exposed through //! `bl tools appkit`, and it does not migrate the separate internal Compose //! workflow. Both internal paths remain unchanged. //! -//! The CLI sends its stored blidentity session only to the allowlisted Compose +//! The CLI sends its stored bbidentity session only to the allowlisted Compose //! control-plane origins. Public ingress authorizes that session through kgoose //! `ext_authz` and removes it before forwarding the request internally. Compose //! never receives the session credential. @@ -64,7 +64,7 @@ pub fn command() -> Command { .about("Manage apps through Apps Platform") .long_about( "Manage apps through the BuilderLab Apps Platform control plane on Compose, first in \ - `bl-block` staging and then in multi-tenant `bl-public`. This does not replace the \ + `bb-block` staging and then in multi-tenant `bb-public`. This does not replace the \ Cloudflare-backed internal App Kit CLI (`bl tools appkit`) or migrate the separate internal \ Compose workflow.", ) @@ -987,7 +987,8 @@ impl ComposeSessionCredential { } fn new(secret: String) -> Result { - let authorization = HeaderValue::from_str(&format!("BLIdentity {secret}")) + // Authorization scheme is a backend contract, independent of CLI branding. + let authorization = HeaderValue::from_str(&format!("BBIdentity {secret}")) .context("stored BuilderLab CLI auth session is invalid; run `bl auth login`")?; Ok(Self { authorization, @@ -1968,7 +1969,7 @@ mod tests { assert_eq!(request.path, path); assert_eq!( request.headers.get("authorization").map(String::as_str), - Some(format!("BLIdentity {credential}").as_str()) + Some(format!("BBIdentity {credential}").as_str()) ); assert_eq!( request @@ -3855,7 +3856,7 @@ mod tests { .authorization_header() .to_str() .expect("authorization text"), - format!("BLIdentity {secret}") + format!("BBIdentity {secret}") ); for invalid in ["credential\r\nInjected: header", "credential\nheader"] { let error = ComposeSessionCredential::new(invalid.to_string()) @@ -3879,7 +3880,7 @@ mod tests { .authorization_header() .to_str() .expect("authorization text"), - format!("BLIdentity {secret}") + format!("BBIdentity {secret}") ); } @@ -3923,7 +3924,7 @@ mod tests { } #[test] - fn control_plane_uses_blidentity_authorization_without_identity_headers() { + fn control_plane_uses_bbidentity_authorization_without_identity_headers() { let secret = "opaque_session_credential_1234567890"; let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); let base_url = format!("http://{}", server.server_addr()); @@ -3937,7 +3938,7 @@ mod tests { .iter() .find(|header| header.field.equiv("Authorization")) .map(|header| header.value.as_str()), - Some("BLIdentity opaque_session_credential_1234567890") + Some("BBIdentity opaque_session_credential_1234567890") ); for forbidden in [ "Cookie", diff --git a/src/bl/skills_api.rs b/src/bl/skills_api.rs index 73b4d76..cdeb505 100644 --- a/src/bl/skills_api.rs +++ b/src/bl/skills_api.rs @@ -139,7 +139,7 @@ impl MarketplaceClient { if let Some(playpen) = &config.playpen { headers.insert( "Baggage", - HeaderValue::from_str(&format!("kgoose-builderlab-playpen={playpen}")) + HeaderValue::from_str(&format!("kgoose-builderbot-playpen={playpen}")) .context("build marketplace Baggage header")?, ); } diff --git a/src/bl/skills_config.rs b/src/bl/skills_config.rs index bdfe56b..963737d 100644 --- a/src/bl/skills_config.rs +++ b/src/bl/skills_config.rs @@ -33,7 +33,8 @@ pub const BL_SKILLS_CONFIG_ENV_VAR: &str = "BL_SKILLS_CONFIG"; pub const BL_KGOOSE_PLAYPEN_ENV_VAR: &str = "BL_KGOOSE_PLAYPEN"; pub const KGOOSE_PLAYPEN_ENV_VAR: &str = "KGOOSE_PLAYPEN"; pub const DEFAULT_CONFIG_FILE_NAME: &str = "skills.yaml"; -pub const META_FILE_NAME: &str = ".bl-skills-meta.json"; +// Persistent ownership marker shared with existing bb installs; not a product name. +pub const META_FILE_NAME: &str = ".bb-skills-meta.json"; #[derive(Debug, Clone, Default)] pub struct SkillsProfileResolveOptions { pub local_dev: bool, diff --git a/src/bl/skills_install.rs b/src/bl/skills_install.rs index 6fa5c97..83c3be4 100644 --- a/src/bl/skills_install.rs +++ b/src/bl/skills_install.rs @@ -426,7 +426,7 @@ fn execute_install_operation( persist_download(config, slug, &operation.skill.version_id, &download.bytes); let metadata = InstalledSkillMetadata { - schema_version: "bl-skills-install/v1".to_string(), + schema_version: "bb-skills-install/v1".to_string(), server_url: kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path), slug: slug.clone(), version_id: operation.skill.version_id.clone(), @@ -691,7 +691,7 @@ pub fn install_local_path( let content_sha = hash_directory(&staging)?; let metadata = InstalledSkillMetadata { - schema_version: "bl-skills-install/v1".to_string(), + schema_version: "bb-skills-install/v1".to_string(), server_url: kgoose_service_url(&config.kgoose_base_url, &config.kgoose_service_path), slug: slug.clone(), version_id: format!("local-{}", &content_sha[..12]), diff --git a/src/catalog.rs b/src/catalog.rs index 0157099..99a0177 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -112,6 +112,13 @@ mod tests { .expect("parse embedded catalog"); assert!(!extensions.is_empty()); + // Catalog names are backend extension IDs, independent of CLI branding. + assert!(extensions + .iter() + .any(|extension| extension.name == "builderbot")); + assert!(!extensions + .iter() + .any(|extension| extension.name == "builderlab")); } #[test] diff --git a/tests/bl_e2e.rs b/tests/bl_e2e.rs index 060f2f1..84eee21 100644 --- a/tests/bl_e2e.rs +++ b/tests/bl_e2e.rs @@ -220,7 +220,7 @@ fn agent_state(bl_home: &Path, slug: &str) -> PathBuf { fn managed_agent_metadata(slug: &str, document: &[u8]) -> Value { json!({ - "schema_version": "bl-agent-install/v1", + "schema_version": "bb-agent-install/v1", "kind": "agent", "slug": slug, "version_id": "agent-v1", @@ -578,7 +578,7 @@ fn bl_agents_lifecycle_is_idempotent_and_local_queries_stay_offline() { ); let persisted = serde_json::from_slice::(&fs::read(&state).expect("read state")) .expect("parse persisted state"); - assert_eq!(persisted["schema_version"], "bl-agent-install/v1"); + assert_eq!(persisted["schema_version"], "bb-agent-install/v1"); assert_eq!(persisted["kind"], "agent"); assert_eq!(persisted["slug"], "release-notes"); assert_eq!(persisted["version_id"], "agent-v2"); @@ -1168,9 +1168,9 @@ fn write_installed_package(skills_home: &Path, slug: &str, content_sha: &str, ta fs::create_dir_all(&package).expect("create package dir"); fs::write(package.join("SKILL.md"), "# BuilderLab Tools\n").expect("write SKILL.md"); fs::write( - package.join(".bl-skills-meta.json"), + package.join(".bb-skills-meta.json"), serde_json::to_vec_pretty(&json!({ - "schema_version": "bl-skills-install/v1", + "schema_version": "bb-skills-install/v1", "server_url": "http://marketplace.local", "slug": slug, "version_id": "ver_builtin_builderlab_tools_0_1_0", @@ -1714,11 +1714,11 @@ fn bl_skills_env_playpen_adds_baggage_header() { assert_eq!(requests.len(), 2); assert_eq!( requests[0].headers.get("baggage").map(String::as_str), - Some("kgoose-builderlab-playpen=baxen") + Some("kgoose-builderbot-playpen=baxen") ); assert_eq!( requests[1].headers.get("baggage").map(String::as_str), - Some("kgoose-builderlab-playpen=baxen") + Some("kgoose-builderbot-playpen=baxen") ); } @@ -2006,7 +2006,7 @@ fn bl_auth_login_env_playpen_adds_baggage_to_stored_session_check() { assert_eq!(requests[0].path, "/api/goose/v1/auth/me"); assert_eq!( requests[0].headers.get("baggage").map(String::as_str), - Some("kgoose-builderlab-playpen=baxen") + Some("kgoose-builderbot-playpen=baxen") ); fs::remove_dir_all(temp).expect("remove temp dir"); } @@ -2701,7 +2701,7 @@ fn bl_skills_install_downloads_verifies_and_installs_into_isolated_home() { let package = skills_home.join("packages/builderlab-tools"); assert!(package.join("SKILL.md").is_file()); let metadata = serde_json::from_slice::( - &fs::read(package.join(".bl-skills-meta.json")).expect("read metadata"), + &fs::read(package.join(".bb-skills-meta.json")).expect("read metadata"), ) .expect("parse metadata"); assert_eq!(metadata["slug"], json!("builderlab-tools")); @@ -2951,7 +2951,7 @@ fn bl_skills_install_canonical_agents_dir_holds_real_package() { // The agents entry is the real package directory, not a symlink. let package = agents_dir.join("builderlab-tools"); assert!(package.join("SKILL.md").is_file()); - assert!(package.join(".bl-skills-meta.json").is_file()); + assert!(package.join(".bb-skills-meta.json").is_file()); assert!(!fs::symlink_metadata(&package) .expect("package metadata") .file_type() @@ -3233,7 +3233,7 @@ fn bl_skills_install_backs_up_unmanaged_package_before_replacing_it() { fs::read_to_string(unmanaged.join("SKILL.md")).expect("read unmanaged skill"), "# BuilderLab Tools\n" ); - assert!(unmanaged.join(".bl-skills-meta.json").is_file()); + assert!(unmanaged.join(".bb-skills-meta.json").is_file()); let backup_root = unmanaged .parent() .expect("packages directory") @@ -3417,7 +3417,7 @@ fn bl_skills_install_local_path_installs_without_marketplace() { let package = temp.join("skills-home/packages/local-skill"); assert!(package.join("SKILL.md").is_file()); let metadata = serde_json::from_slice::( - &fs::read(package.join(".bl-skills-meta.json")).expect("read metadata"), + &fs::read(package.join(".bb-skills-meta.json")).expect("read metadata"), ) .expect("parse metadata"); assert_eq!(metadata["local_source"], json!(true)); @@ -3647,8 +3647,8 @@ fn bl_apps_help_distinguishes_external_and_internal_paths() { assert!(output.status.success(), "stderr was: {stderr}"); for expected in [ "Apps Platform", - "bl-block", - "bl-public", + "bb-block", + "bb-public", "Cloudflare-backed internal App Kit", "bl tools appkit", "separate internal Compose workflow", From 5e266e6289596c70ed8f4dda48fed3bf65568f1d Mon Sep 17 00:00:00 2001 From: Nathan Thillairajah Date: Wed, 23 Sep 2026 10:19:39 -0400 Subject: [PATCH 11/11] Exercise auth workspace and bl acceptance paths in CI Signed-off-by: Nathan Thillairajah --- .github/workflows/ci.yml | 3 +++ Cargo.toml | 4 ++++ Justfile | 6 ++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9baeb6..d16ec68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,6 @@ jobs: - name: Run repository CI run: just ci + + - name: Run isolated Docker acceptance tests + run: just bl-cli-docker-acceptance diff --git a/Cargo.toml b/Cargo.toml index dbf1b03..643274f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ +[workspace] +members = ["crates/builderlab-auth"] +resolver = "2" + [package] name = "sq-kgoose" version = "0.7.12" diff --git a/Justfile b/Justfile index 5022d1d..3219e0d 100644 --- a/Justfile +++ b/Justfile @@ -106,13 +106,15 @@ fmt-check: cargo fmt --all -- --check lint: fmt-check - cargo clippy --locked --all-targets --all-features -- -D warnings + cargo clippy --locked --workspace --all-targets --all-features -- -D warnings test: - cargo test --locked --all-features + cargo test --locked --workspace --all-features package-smoke: build-sq ./sqbin/{{BIN_NAME}}.exoskeleton --version + ./target/release/bl --version + ./target/release/bl --help # Build and run the isolated, deterministic Docker acceptance harness for bl skills. bl-cli-docker-acceptance: