diff --git a/openspec/changes/add-extension-mechanism/.openspec.yaml b/openspec/changes/add-extension-mechanism/.openspec.yaml new file mode 100644 index 00000000..ff1fbc83 --- /dev/null +++ b/openspec/changes/add-extension-mechanism/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-19 diff --git a/openspec/changes/add-extension-mechanism/design.md b/openspec/changes/add-extension-mechanism/design.md new file mode 100644 index 00000000..c51ea1a7 --- /dev/null +++ b/openspec/changes/add-extension-mechanism/design.md @@ -0,0 +1,122 @@ +## Context + +`lstk` today is a single Go binary whose commands are wired in `cmd/` (Cobra) with domain logic in `internal/`. New capabilities must be merged into the open-source core, which blocks proprietary/partner features and couples their release to the core. We want to let internal and external authors add `lstk ` subcommands as independent binaries — open or closed source — placed on the user's `PATH`. lstk's job is dispatch plus handing the extension enough runtime context to be useful; any authorization decision belongs to the extension. + +The closest existing pattern in the codebase is the IaC proxy (`internal/iac//cli/`, wired via `cmd/iac.go`): lstk resolves an external binary, builds a child environment (resolved endpoint, credentials, stripped real-AWS vars), execs it with `DisableFlagParsing: true`, streams stdio, and propagates the exit code. The extension mechanism generalizes this pattern from a fixed set of known tools to a dynamic, user-installable set discovered by name on `PATH`. + +Relevant existing building blocks: +- **Auth**: `internal/auth` resolves the token (keyring → `LOCALSTACK_AUTH_TOKEN` → file fallback). The token is conveyed to the extension; lstk does not validate licenses for extensions. +- **Endpoint resolution**: `internal/endpoint.ResolveHost()` and container discovery (`internal/container`) already produce the emulator URL used by `lstk aws`/IaC proxies. +- **Config dir**: `internal/config` resolves the lstk config directory; extension dispatch passes it through. + +## Goals / Non-Goals + +**Goals:** +- Git-style dispatch: `lstk ` → `lstk-` executable on `PATH` when `` is not built-in, with built-ins always winning. +- A stable, versioned environment-variable contract (`LSTK_EXT_*`) carrying emulator endpoint/type/port, config dir, and auth token. +- Convey the auth token so an extension can authorize the user itself; lstk makes no entitlement decision. +- Parse lstk's own global flags (e.g. `--non-interactive`) before the command name and convey the resolved state to the extension via the `LSTK_EXT_*` contract. +- Bundle LocalStack's own extensions (e.g. a closed-source `lstk-deploy`) alongside `lstk` so they ship by default, resolve ahead of `PATH`, and update atomically with lstk. +- Help listing (bundled dir + PATH scan) so installed extensions are discoverable by name. +- Keep the mechanism in the open-source repo; closed-source extensions ship only as binaries (bundled by LocalStack, or placed on `PATH` by anyone else). + +**Non-Goals (deferred to future work):** +- lstk-side entitlement verification, signed grants / signed entitlement descriptions, and offline grant verification by extensions. +- Emulator genuineness attestation (licensed vs. clone). +- User-facing `lstk extension` management commands (`install`/`remove`/`list`/`info`) and a user-mutable managed extensions directory. (Static, ships-with-lstk bundling is in scope — see Decision 6 — but the user-driven management UX is not.) +- An in-process plugin ABI (Go plugins, shared libraries, WASM) — we deliberately use separate processes for language-agnostic, closed-source friendliness. +- Sandboxing or capability-limiting extension processes — extensions run with the user's full privileges, same as the IaC proxies and any tool on `PATH`. + +## Decisions + +### Decision 1: Separate-process, Git-style extensions (not in-process plugins) + +`lstk ` resolves and execs an `lstk-` binary on `PATH`, forwarding args/stdio/exit code, exactly like Git's `git-` model and lstk's own IaC proxies. + +**Rationale**: This is the only model that cleanly satisfies "open or closed source" and "internal or external authors" — a closed-source extension ships as an opaque binary in any language and never touches the core repo. Go's `plugin` package is Linux/macOS-only, requires identical toolchain/build flags, and exposes a Go ABI (excludes other languages and complicates closed source). WASM would sandbox nicely but can't easily run arbitrary partner toolchains and adds a heavy runtime. Process-per-invocation matches an existing, proven pattern in this codebase. + +**Alternatives considered**: Go `plugin` shared objects (rejected: platform/toolchain coupling, Go-only); embedded scripting/WASM (rejected: language lock-in or runtime weight); MCP-style long-running server processes (rejected: overkill for CLI subcommands, more lifecycle complexity). + +### Decision 2: Dispatch via Cobra's unknown-command hook, built-ins first + +Wire extension dispatch in `cmd/root.go` so that resolution happens only after Cobra fails to match a built-in command/alias. This guarantees built-ins always shadow extensions (spec requirement) and avoids parsing extension flags (`DisableFlagParsing` semantics applied to the synthesized extension command, as the IaC proxies do). + +**Rationale**: Reuses Cobra's existing matching and keeps the change localized. An extension can never override `start`, `snapshot`, etc., which matters because extensions are user-installable. + +**Global flags before the command name**: lstk's global flags (`--non-interactive`, `--config`, and any added later) must be recognized when they precede the extension name but must not be parsed out of the extension's own args. The mechanism is `SetInterspersed(false)` on the root flag set: Cobra parses *leading* flags into `cfg` and stops at the first positional (the command name), handing the dispatch path everything from the command name onward verbatim. This gives Git-style "globals only before the command" for free and, because it reuses the existing persistent-flag definitions, any future global flag is handled with no extension-specific code. This deliberately differs from the IaC proxies, which strip `--non-interactive` from *any* position via `stripGlobalFlags` ([cmd/proxy.go](../../../cmd/proxy.go)); for generic extensions, stripping a flag out of the middle of the extension's args could clobber an extension's own identically-named flag, so we only consume leading globals. The proxy's manual `stripGlobalFlags` remains the fallback if `SetInterspersed(false)` interacts badly with the existing bare-root `start` behavior. + +**Alternatives considered**: Pre-scanning `os.Args[1]` before Cobra runs (rejected: duplicates Cobra's alias/normalization logic and risks divergence). Registering a synthetic Cobra command per discovered extension at startup (we do a lightweight bundled-dir + PATH scan for `--help` listing but keep dispatch in the unknown-command path to avoid eagerly stat-ing the filesystem on every invocation). Reusing `stripGlobalFlags` verbatim (rejected as the primary path: its strip-from-anywhere behavior risks colliding with extension-owned flags). + +### Decision 3: Environment-variable runtime contract (`LSTK_EXT_*`), versioned + +Context flows to the extension purely through environment variables prefixed `LSTK_EXT_`, with `LSTK_EXT_API_VERSION` as an integer contract version. Variables in this change: `LSTK_EXT_API_VERSION`, `LSTK_EXT_EMULATOR_ENDPOINT`, `LSTK_EXT_EMULATOR_TYPE`, `LSTK_EXT_EMULATOR_PORT`, `LSTK_EXT_CONFIG_DIR`, `LSTK_EXT_AUTH_TOKEN`, and `LSTK_EXT_NON_INTERACTIVE`. The host environment is inherited; only `LSTK_EXT_*` is added/overridden. Endpoint vars are omitted when no emulator is running; the auth token is omitted when none is available. + +Resolved global flags travel by env, not argv: `LSTK_EXT_NON_INTERACTIVE` is set when the session is non-interactive (the `--non-interactive` flag was given or stdout is not a TTY — the same condition lstk already computes at [cmd/root.go](../../../cmd/root.go) `isInteractive`). Carrying global state by env (rather than re-forwarding `--non-interactive` on the extension's command line) is what lets the extension own its entire flag space without collision, and each new global flag becomes one additive `LSTK_EXT_*` variable. + +**Rationale**: Mirrors how the IaC proxies already pass context, is language-agnostic (every runtime can read env), and avoids forcing extensions to parse lstk's TOML or re-implement discovery. Versioning makes the contract evolvable: additive within a major version, bump on any removal/repurpose. A future signed-entitlement description would be added additively (e.g. `LSTK_EXT_GRANT` / `LSTK_EXT_PUBLIC_KEY`) under a documented version bump. + +**Alternatives considered**: A JSON context file referenced by a single env var (cleaner for large/structured payloads; reconsider if the contract grows, but env is simpler for the current fields and avoids temp-file lifecycle/cleanup). Passing context as CLI flags (rejected: collides with extension-owned flag space). + +### Decision 4: No lstk-side manifest — extensions are self-describing + +lstk executes any resolvable `lstk-` on `PATH` without reading a manifest. Contract compatibility is checked by the extension reading `LSTK_EXT_API_VERSION`; whether auth is needed is the extension's call (lstk always passes the token when it has one); a human description is shown in help only as the command name (PATH scan). + +**Rationale**: This is the pure Git model (`git-` extensions have no manifest) and it removes a whole class of questions — where the manifest lives in a shared `PATH` bindir, how lstk trusts it, how it stays in sync with the binary. Because lstk no longer makes any entitlement decision (Decision 5), it has no need to read an entitlement name from a manifest either. + +**Alternatives considered**: A co-located `extension.toml` (rejected for now: ambiguous in shared `PATH` directories and unnecessary once lstk does no pre-launch gating). A managed extensions directory that owns manifests (deferred together with `lstk extension` management). + +### Decision 5: Authorization belongs to the extension; the signed mechanism is deferred + +lstk conveys `LSTK_EXT_AUTH_TOKEN` and makes no entitlement or license decision for an extension. An extension that wants to restrict its use authorizes the user itself — typically a server-side check against the LocalStack platform with the conveyed token. + +**Rationale**: Because lstk is open source and rebuildable, any lstk-side gate is a UX speed bump, not a control — the only durable boundary is a check the extension performs against something a modified lstk cannot forge (a server response keyed to the user's token, or a signature the extension verifies). Deferring the signed mechanism lets the host land now with no platform changes; when signing is added, it slots into the `LSTK_EXT_*` contract additively without changing dispatch. + +**Alternatives considered**: lstk-side entitlement gate + per-extension signed grant (the original design; deferred, not rejected — it improves UX and enables offline verification but requires platform work and adds complexity not needed for the first cut). Purely client-side license checks inside lstk (rejected: trivially bypassable, no IP protection). + +### Decision 6: Bundled LocalStack extensions live next to the binary and resolve ahead of PATH + +LocalStack ships its own extensions (e.g. a closed-source `lstk-deploy`) in a fixed directory derived from lstk's own symlink-resolved executable path — the Git `libexec/git-core` model. Resolution order becomes: built-ins → bundled dir → `PATH`, so a bundled extension wins over a same-named `PATH` executable. `internal/update` replaces lstk and its bundled extensions as one atomic, version-matched set. This reuses the `lstk-` naming convention (no manifest) and does not change the authorization model — a bundled premium extension still self-authorizes with the conveyed token. + +**Rationale**: Searching the directory next to the resolved executable is robust where co-located binaries are not on `PATH` — bare tarballs where the user symlinks only `lstk`, and npm/Homebrew layouts where the invoked `lstk` is a shim (hence resolving symlinks to find the real sibling dir). Bundled-wins precedence makes the official `lstk-deploy` deterministic: a stray or malicious `lstk-deploy` on `PATH` cannot hijack it. This is a deliberately narrow re-introduction of what the management capability deferred: a *static, read-only, ships-with-lstk* location and the packaging/update wiring — but **not** the user-mutable managed dir or the `lstk extension install/remove` UX, which stay deferred. The closed-source binaries are built in private CI and injected into the public release artifacts, version-pinned to the lstk release that carries them. + +**Alternatives considered**: Just install bundled binaries onto `PATH` (rejected: zero lstk change but fragile for bare tarballs and pollutes the user's `PATH`). PATH-wins precedence (rejected: lets a stray binary shadow the official extension; bundled-wins is safer for closed-source premium commands). A managed mutable dir under the config dir (deferred with the rest of management; bundling needs only a static dir). + +### Decision 7: One-line help descriptions from a release-generated file, bundled-only + +Bundled extensions get a one-line description in `lstk help` from a static descriptions file generated during the release process and shipped alongside the bundled extensions (a single file mapping command name → description, e.g. `extensions.toml` in the bundled dir). lstk reads it at help time and never executes an extension to obtain text. `PATH`/custom extensions are always name-only. + +**Rationale**: This keeps help rendering side-effect-free — the property we lost in the earlier exec-based approach. Because LocalStack controls the bundled set, their descriptions are known at release time, so there is no need to run anything: no timeout, no cache, no incidental-usage-on-a-typo hazard, no risk of executing untrusted `PATH` binaries during help. It is not a return of the per-extension `extension.toml` manifest (Decision 4): it is one release-owned file covering only the bundled set, generated by CI and version-locked to the binaries it describes, not authored per-extension by third parties. The cost — descriptions are static and only available for bundled extensions — is acceptable: third-party `PATH` extensions being name-only matches Git's `git help -a`. + +**Alternatives considered**: A describe protocol that execs `lstk- lstk:describe` (rejected: turns inert help into code execution, needs timeout/cache/trust-boundary machinery, and a mistyped command rendering Cobra usage could fork extensions). Per-extension manifests (rejected with Decision 4). Name-only for everything (viable and simplest, but the user wants descriptions for bundled extensions like `lstk-deploy`, which this delivers with no exec). + +## Threat Model: hostile lstk rebuild (why authorization lives in the extension) + +Because lstk is and remains open source, the adversary is assumed to have the full lstk source and can rebuild it with any check removed or any value forged. The single question every security claim must pass is: **does this check depend on lstk behaving honestly, or on a response the attacker cannot produce?** Anything in the first category is a UX speed bump, not a control. + +Consequence for this change: lstk deliberately holds **no** authorization logic. It conveys the auth token and dispatches. An extension that needs protection must anchor its enforcement server-side (a LocalStack platform call keyed to the token) or in its own verification — a rebuilt lstk that strips or forges `LSTK_EXT_*` values cannot make such a check pass. This is also why the eventual signed-entitlement description (deferred) is designed to be verified *by the extension* against LocalStack's public key, not gated by lstk. + +Residual risk we explicitly accept: an extension whose value is purely local logic cannot be protected client-side at all — an attacker rebuilds it with the checks removed. The only durable protection is server-side gating of the valuable behavior. This is the standard DRM reality and should be stated plainly in the author guide rather than implied away. + +## Risks / Trade-offs + +- **Auth token exposed to extension processes via env** → Tokens already flow to subprocesses for IaC proxies; only pass it when one is resolved, and document the trust boundary. A future scoped/short-lived token can replace the raw auth token without changing the contract shape. +- **Name collisions / malicious shadowing** → Built-ins always win (dispatch only on unknown commands); bundled LocalStack extensions win over `PATH`, so a stray `lstk-deploy` on `PATH` cannot hijack the official one. Among `PATH` entries, standard first-match-wins resolution applies, same as any `lstk-*` or shell command. +- **Bundled/lstk version skew** → `internal/update` must replace lstk and its bundled extensions atomically; an interrupted update must not leave them mismatched. Treated as a single versioned set. +- **npm/Homebrew shim hides the real binary location** → resolve symlinks to find lstk's real executable before locating the sibling bundled dir; verify on each channel. +- **Untrusted extension binaries run with user privileges** → Same trust model as installing any CLI tool or the IaC binaries lstk already shells out to; no sandboxing is promised. +- **No lstk-side authorization in this cut** → An extension that forgets to authorize is wide open; the author guide must make the self-authorization responsibility explicit and provide a recommended pattern (server-side check with the conveyed token). +- **Contract drift between lstk and extensions** → `LSTK_EXT_API_VERSION` lets extensions detect/require a minimum contract and refuse to run when incompatible. + +## Migration Plan + +- Purely additive: no built-in command behavior changes, so no user migration is required. Existing `terraform-proxy`/`cdk-proxy` specs are untouched. +- Land the host mechanism (dispatch + runtime context) with a reference extension so discovery, dispatch, the `LSTK_EXT_*` contract, and help listing are testable with no platform dependency. +- Rollback: because dispatch only triggers on unknown commands, having no `lstk-*` on `PATH` leaves built-in behavior identical. + +## Open Questions + +- **Token scoping**: Should extensions receive the raw `LOCALSTACK_AUTH_TOKEN` or a derived, audience-/extension-scoped, short-lived token? (Affects the eventual signed mechanism too.) +- **Help-listing cost**: Scanning the bundled dir + all of `PATH` for `lstk-*` on every `--help` has a filesystem cost; is lazy/cached scanning warranted, or is on-demand fine? +- **Closed-source build/release pipeline**: Where do the prebuilt closed-source bundled binaries (e.g. `lstk-deploy`) come from at release time — a private artifact registry, a private release? How are their versions pinned to the lstk release, and how does the public repo's release workflow pull them into the Homebrew formula, npm package, and binary archive without exposing source? +- **Exact bundled-dir layout**: Is it the same directory as the lstk binary, or a dedicated sibling (e.g. `libexec`-style) to avoid mixing with unrelated binaries — and how does each channel (Homebrew Cellar/libexec, npm package dir, tarball root) lay it out so the symlink-resolved lookup finds it? +- **Future signed entitlement**: When the deferred mechanism lands, what is the token format (JWT? which alg/curve), how is LocalStack's public key distributed to extensions, and what is the key-rotation strategy? (Tracked for the follow-up change, not this one.) diff --git a/openspec/changes/add-extension-mechanism/proposal.md b/openspec/changes/add-extension-mechanism/proposal.md new file mode 100644 index 00000000..68775b52 --- /dev/null +++ b/openspec/changes/add-extension-mechanism/proposal.md @@ -0,0 +1,41 @@ +## Why + +`lstk` ships a fixed set of built-in commands, so any new capability — whether built by LocalStack engineers or partners — has to land in the core open-source repository. That blocks closed-source/proprietary features, slows third-party contribution, and couples every new feature's release to the core CLI's release cadence. We want a Git-style extension mechanism so anyone can add `lstk ` subcommands as separate binaries on their `PATH`, with lstk handing each extension enough runtime context (resolved emulator endpoint, config dir, auth token) to do useful work — while leaving any authorization decision to the extension itself. + +## What Changes + +- Introduce a Git-style extension model: when `lstk ` is not a built-in command, lstk resolves and executes an `lstk-` executable found on `PATH`, forwarding all remaining arguments and propagating the child's stdin/stdout/stderr and exit code. +- Define an **extension runtime contract**: lstk passes the resolved emulator endpoint, emulator type, config directory, auth token, and resolved global-flag state to the extension process through a stable, versioned set of `LSTK_EXT_*` environment variables so extensions can talk to the emulator and the platform without re-implementing discovery or config resolution. +- **Honor global flags before the command name**: lstk parses its own global flags (e.g. `--non-interactive`, and any added later) when they precede the extension name, consumes them itself, and conveys the resolved state to the extension via `LSTK_EXT_*` (e.g. `LSTK_EXT_NON_INTERACTIVE`) rather than forwarding them on the extension's command line. +- **Bundle LocalStack's own extensions**: ship LocalStack-built extensions (e.g. a closed-source `lstk-deploy`) by default alongside `lstk` in a directory next to the binary, resolved ahead of `PATH` and updated atomically with `lstk` — with no user-facing install step. +- Establish that **authorization is the extension's responsibility**: lstk conveys the user's auth token and makes no entitlement decision of its own. An extension that needs to restrict its use authorizes the user itself (e.g. by calling the LocalStack platform with the conveyed token). A richer lstk-side mechanism — lstk obtaining a LocalStack-signed entitlement description for the extension to verify offline — is deliberately **deferred** to future work. +- List resolvable extensions in `lstk help` by scanning the bundled directory and `PATH` for `lstk-*` executables; bundled extensions show a one-line description read from a static descriptions file generated during the release process, while `PATH`/custom extensions are name-only. Help rendering never executes an extension. +- Keep the entire mechanism in the open-source repository; closed-source extensions ship only as binaries placed on `PATH` and never require source in the core repo. + +## Capabilities + +### New Capabilities + +- `extension-framework`: Git-style discovery, resolution, and dispatch of `lstk-` extension executables (bundled dir + `PATH`), including built-in precedence, leading-only global-flag handling, forwarding of arguments/streams/exit codes, and side-effect-free help listing (bundled extensions described from a static file, others name-only). No lstk-side manifest — extensions are self-describing and self-validating. +- `extension-runtime-context`: The versioned environment-variable contract lstk establishes for an extension process — resolved emulator endpoint/type/port, config directory, auth token, and resolved global-flag state (e.g. non-interactive) — so extensions can reach the emulator and platform and honor lstk's global flags. +- `extension-entitlement`: The authorization model — lstk conveys the auth token and the extension authorizes itself — plus the explicit deferral of any lstk-side signed-entitlement mechanism and the security rationale (lstk is open source, so authorization cannot depend on it). +- `extension-bundling`: Shipping LocalStack-built (possibly closed-source) extensions by default alongside `lstk` — a read-only bundled directory next to the binary, resolution ahead of `PATH`, a release-generated descriptions file for help text, cross-channel packaging (binary archive, Homebrew, npm), and atomic version-matched updates via `internal/update`. Excludes user-facing management commands and a user-mutable directory. + +### Modified Capabilities + + + +## Impact + +- **New code**: `internal/extension/` (bundled-dir + PATH resolution, runtime context builder, global-flag conveyance, exec), and unknown-command dispatch + help-listing wiring in `cmd/root.go`. +- **Touched code**: `cmd/root.go` (fallthrough to extension dispatch for unknown commands; `SetInterspersed(false)` for leading-only global flags; bundled-dir + PATH scan for help), reuse of `internal/auth` (token resolution), `internal/config`/`internal/endpoint` (config dir and emulator endpoint resolution), `internal/container` (running-emulator discovery for endpoint/type), and `internal/update` (atomic version-matched update of bundled extensions). +- **Packaging/release**: binary archive, Homebrew formula, and npm package must lay out bundled extensions where lstk resolves them; the public release workflow must pull prebuilt closed-source bundled binaries from private CI, version-pinned to the lstk release. +- **External dependencies/services**: None required by this change. Extensions that authorize use the existing LocalStack platform with the conveyed auth token; no new platform or emulator endpoints are needed. +- **Security surface**: lstk passes the auth token into extension processes via env (as it already does for IaC proxies); this defines a local trust boundary to document. Authorization guarantees live in the extension, never in lstk. +- **Docs**: New "Extensions" section in CLAUDE.md and a public extension-author guide (manifest-free contract, `LSTK_EXT_*` variables, the self-authorization model and why it cannot rely on lstk). + +## Deferred (future work) + +- lstk-side entitlement verification and a LocalStack-signed entitlement description (grant) that extensions verify offline against a published public key. +- Emulator genuineness attestation (distinguishing a licensed emulator from a clone). +- User-facing `lstk extension` management commands (`list`/`info`/`install`/`remove`) and a user-mutable managed extensions directory. (Static, ships-with-lstk bundling is **in scope** via the `extension-bundling` capability; only the user-driven install/remove UX is deferred.) diff --git a/openspec/changes/add-extension-mechanism/specs/extension-bundling/spec.md b/openspec/changes/add-extension-mechanism/specs/extension-bundling/spec.md new file mode 100644 index 00000000..49b06952 --- /dev/null +++ b/openspec/changes/add-extension-mechanism/specs/extension-bundling/spec.md @@ -0,0 +1,71 @@ +# extension-bundling Specification + +## Purpose + +Allow LocalStack to ship its own extensions (for example a closed-source `lstk-deploy`) by default alongside `lstk`, so they are available immediately after a standard install with no separate step, are kept in lockstep with the `lstk` version that ships them, and are resolved deterministically ahead of any same-named executable on `PATH`. This capability covers only static, read-only, ships-with-lstk extensions; user-driven install/remove of extensions and a user-mutable managed directory remain out of scope (deferred). + +## ADDED Requirements + +### Requirement: Bundled-extensions directory alongside the executable + +lstk SHALL look for bundled extensions in a fixed directory derived from the location of its own executable, resolving symlinks so the directory is found even when `lstk` is invoked through a symlink or package shim (e.g. an npm `.bin` link). Bundled extension executables follow the same `lstk-` naming convention as any other extension and SHALL NOT require a manifest. This directory is owned by the lstk distribution and is read-only from the user's perspective: lstk SHALL NOT provide commands to add to or remove from it in this change. + +#### Scenario: Bundled directory resolved through a symlink + +- **WHEN** `lstk` is invoked via a symlink or package shim and an `lstk-deploy` is bundled +- **THEN** lstk resolves its real executable location, finds the bundled-extensions directory, and can resolve `lstk deploy` + +#### Scenario: Naming convention identifies bundled extensions + +- **WHEN** the bundled-extensions directory contains an executable named `lstk-deploy` +- **THEN** lstk treats it as the `deploy` extension without reading any manifest + +### Requirement: Bundled extensions are available after a standard install + +A set of extensions MAY be designated as bundled and SHALL be installed alongside `lstk` by the same single installation command across supported distribution channels (binary archive, Homebrew, npm), placed in the bundled-extensions directory, and resolvable immediately as `lstk ` with no separate install step. Packaging SHALL place bundled extensions where lstk resolves them without requiring the user to add them to `PATH`. + +#### Scenario: Bundled extension available immediately + +- **WHEN** a user installs lstk via the standard installation command for any supported channel and `lstk-deploy` is bundled +- **THEN** `lstk deploy` resolves to the bundled extension with no extra install step + +#### Scenario: Bundled extension found without PATH changes + +- **WHEN** a user extracts the binary archive and places only `lstk` on `PATH` +- **THEN** a bundled `lstk-deploy` sibling is still resolved by `lstk deploy` because lstk searches the directory alongside its executable + +### Requirement: Bundled extensions update atomically with lstk + +Updating lstk SHALL update its bundled extensions to the matching version as a single, atomic set, so a running `lstk` and its bundled extensions are never left at mismatched versions. `internal/update` SHALL replace the lstk executable and its bundled extensions together regardless of the install method, or fail without partially updating. + +#### Scenario: Bundled extensions updated with lstk + +- **WHEN** lstk is updated to a new version that ships a newer bundled `lstk-deploy` +- **THEN** the bundled `lstk-deploy` is replaced with the matching version as part of the same update +- **AND** an interrupted update does not leave lstk and the bundled extension at mismatched versions + +### Requirement: Release-generated descriptions file for bundled extensions + +The release process SHALL generate a static descriptions file that maps each bundled extension's command name to a one-line description, and SHALL ship it with the distribution where lstk reads it (alongside the bundled extensions). The file SHALL cover only bundled, LocalStack-controlled extensions; it is not a per-extension manifest authored by third parties. It SHALL be versioned and updated together with the bundled extension set, so descriptions never drift from the binaries that ship. lstk reads this file for help rendering (see the extension-framework capability) and never executes an extension to obtain a description. + +#### Scenario: Descriptions file ships with the bundled set + +- **WHEN** a release bundles `lstk-deploy` +- **THEN** the release process produces a descriptions file entry mapping `deploy` to its one-line description +- **AND** that file is shipped where lstk resolves bundled extensions + +#### Scenario: Descriptions update atomically with the bundled set + +- **WHEN** lstk is updated to a version that bundles a renamed or re-described extension +- **THEN** the descriptions file is updated as part of the same atomic update +- **AND** lstk never shows a description that disagrees with the bundled binaries + +### Requirement: Bundled closed-source extensions still self-authorize + +Bundling SHALL NOT change the authorization model: a bundled extension that gates on entitlement (for example a premium closed-source extension) SHALL perform its own authorization using the conveyed auth token exactly as a separately distributed extension would. lstk SHALL NOT treat a bundled extension as automatically entitled. + +#### Scenario: Bundled premium extension enforces its own entitlement + +- **WHEN** a bundled `lstk-deploy` requires entitlement and an unentitled user runs `lstk deploy` +- **THEN** lstk dispatches to the bundled extension and conveys the token +- **AND** the bundled extension performs its own authorization and refuses the unentitled user diff --git a/openspec/changes/add-extension-mechanism/specs/extension-entitlement/spec.md b/openspec/changes/add-extension-mechanism/specs/extension-entitlement/spec.md new file mode 100644 index 00000000..91489fd0 --- /dev/null +++ b/openspec/changes/add-extension-mechanism/specs/extension-entitlement/spec.md @@ -0,0 +1,49 @@ +# extension-entitlement Specification + +## Purpose + +Establish that authorization for an extension is the extension's own responsibility, and that lstk's role is limited to conveying the user's auth token so the extension can decide for itself whether the user is entitled. A richer lstk-side mechanism (lstk obtaining a LocalStack-signed entitlement description and passing it for offline verification) is deliberately deferred to future work; this capability records that intent and the security rationale behind it. + +## ADDED Requirements + +### Requirement: lstk conveys the auth token; the extension authorizes + +lstk SHALL make the resolved user auth token available to the extension via the runtime context (`LSTK_EXT_AUTH_TOKEN`) and SHALL NOT itself perform any entitlement or license decision for the extension. An extension that wishes to restrict its use SHALL perform its own authorization check — for example, by calling the LocalStack platform with the conveyed token — and SHALL refuse to perform protected work when that check does not pass. + +#### Scenario: Token conveyed for the extension to authorize + +- **WHEN** a user with a resolved auth token invokes an extension +- **THEN** lstk passes the token to the extension via `LSTK_EXT_AUTH_TOKEN` +- **AND** lstk invokes the extension without making any entitlement decision of its own + +#### Scenario: Extension enforces its own authorization + +- **WHEN** an extension that gates on entitlement determines the user is not entitled +- **THEN** the extension refuses to perform its protected work +- **AND** this decision is made by the extension, not by lstk + +#### Scenario: Unauthenticated invocation still dispatches + +- **WHEN** no auth token is available and a user invokes an extension +- **THEN** lstk still resolves and executes the extension (omitting `LSTK_EXT_AUTH_TOKEN`) +- **AND** any requirement for authentication is enforced by the extension itself + +### Requirement: Security rests on the extension, not on lstk + +Because lstk is open source and can be rebuilt with any check removed, no authorization guarantee SHALL depend on lstk behaving honestly. An extension that needs durable protection SHALL anchor its enforcement in something a modified lstk cannot forge — a server-side check against the LocalStack platform using the conveyed token, and/or verification it performs itself — rather than relying on lstk to gate invocation. + +#### Scenario: Modified lstk cannot bypass extension authorization + +- **WHEN** a rebuilt lstk skips conveying the token or alters its behavior +- **THEN** an extension that authorizes server-side (or otherwise verifies independently) still refuses unauthorized work +- **AND** the absence of an lstk-side gate does not weaken the extension's protection + +### Requirement: Signed-entitlement mechanism is deferred + +This change SHALL NOT implement lstk-side entitlement verification, signed grant/entitlement-description issuance, or offline grant verification. These remain future work. lstk SHALL NOT set `LSTK_EXT_GRANT` or `LSTK_EXT_PUBLIC_KEY`; if a future change introduces a LocalStack-signed entitlement description, it will be added as an additive extension to the runtime-context contract under a documented version bump. + +#### Scenario: No grant or public key conveyed + +- **WHEN** lstk invokes any extension +- **THEN** `LSTK_EXT_GRANT` is not set +- **AND** `LSTK_EXT_PUBLIC_KEY` is not set diff --git a/openspec/changes/add-extension-mechanism/specs/extension-framework/spec.md b/openspec/changes/add-extension-mechanism/specs/extension-framework/spec.md new file mode 100644 index 00000000..c700c24f --- /dev/null +++ b/openspec/changes/add-extension-mechanism/specs/extension-framework/spec.md @@ -0,0 +1,123 @@ +# extension-framework Specification + +## Purpose + +Provide a Git-style extension model so that `lstk ` invokes an external `lstk-` executable when `name` is not a built-in command, forwarding arguments, streams, and exit codes, and resolving extensions from the user's `PATH`. + +## ADDED Requirements + +### Requirement: Unknown commands dispatch to extension executables + +When a user runs `lstk [args...]` and `` does not match any built-in command or alias, lstk SHALL attempt to resolve and execute an extension executable named `lstk-`. If no such executable is found, lstk SHALL fail with its standard unknown-command error and a non-zero exit code. + +Built-in commands SHALL always take precedence over extensions: an extension named `lstk-` SHALL NOT shadow or override a built-in command ``. + +#### Scenario: Built-in command takes precedence + +- **WHEN** a user runs `lstk start` and a built-in `start` command exists +- **THEN** the built-in command runs +- **AND** no `lstk-start` executable is searched for or executed + +#### Scenario: Unknown command resolves to an extension + +- **WHEN** a user runs `lstk hello world` and no built-in `hello` command exists but an `lstk-hello` executable is resolvable on `PATH` +- **THEN** lstk executes the `lstk-hello` executable +- **AND** passes `world` as its argument + +#### Scenario: Unknown command with no matching extension + +- **WHEN** a user runs `lstk doesnotexist` and neither a built-in command nor an `lstk-doesnotexist` executable exists +- **THEN** lstk prints an unknown-command error +- **AND** exits with a non-zero status + +### Requirement: Extension resolution order + +lstk SHALL resolve `lstk-` executables by searching, in order: (1) the bundled-extensions directory alongside the lstk executable (see the extension-bundling capability), then (2) the directories on the user's `PATH`, using the platform's standard executable lookup. The first match SHALL be used, so a bundled extension takes precedence over a `PATH` executable of the same name. On Windows, platform executable extensions (e.g. `.exe`, `.cmd`, `.bat`) SHALL be honored when resolving the executable name. + +#### Scenario: Bundled extension wins over PATH + +- **WHEN** an `lstk-deploy` exists both in the bundled-extensions directory and on the user's `PATH` +- **THEN** lstk executes the bundled `lstk-deploy` + +#### Scenario: Resolves from PATH when not bundled + +- **WHEN** an `lstk-hello` executable exists on the user's `PATH` and not in the bundled-extensions directory +- **THEN** lstk executes it when the user runs `lstk hello` + +#### Scenario: Not found anywhere + +- **WHEN** no `lstk-hello` executable exists in the bundled-extensions directory or on the user's `PATH` +- **THEN** lstk reports an unknown-command error and exits non-zero + +### Requirement: Argument, stream, and exit-code forwarding + +When invoking an extension, lstk SHALL forward all arguments that follow `` to the extension executable unmodified, and SHALL NOT attempt to parse or interpret extension-specific flags. lstk's own global flags are recognized only when they appear before ``; everything from `` onward is treated as opaque and forwarded verbatim (see the extension-runtime-context capability for how resolved global flags reach the extension). lstk SHALL pass through the child process's standard input, standard output, and standard error unmodified, and SHALL propagate the child process's exit code as lstk's own exit code. + +#### Scenario: Flags after the command name are forwarded, not parsed by lstk + +- **WHEN** a user runs `lstk hello --verbose --name=foo` +- **THEN** lstk invokes `lstk-hello` with `--verbose --name=foo` +- **AND** lstk does not error on unknown flags + +#### Scenario: Global flags before the command name are consumed by lstk + +- **WHEN** a user runs `lstk --non-interactive hello --verbose` +- **THEN** lstk consumes `--non-interactive` itself and invokes `lstk-hello` with only `--verbose` +- **AND** an extension's own flag of the same name appearing after `` is forwarded unchanged + +#### Scenario: Exit code is propagated + +- **WHEN** the `lstk-hello` extension exits with status 3 +- **THEN** lstk exits with status 3 +- **AND** lstk does not print an additional lstk-level error message + +#### Scenario: Streams are passed through + +- **WHEN** an extension reads from stdin and writes to stdout and stderr +- **THEN** the user's terminal stdin/stdout/stderr are connected to the extension unaltered + +### Requirement: Extensions are self-describing; no lstk-side manifest + +lstk SHALL NOT require a manifest file to discover, validate, or invoke an extension. Any compatibility or requirement checks (for example, a minimum supported contract version, or whether authentication is needed) are the extension's own responsibility: the extension SHALL determine these for itself from the runtime context (notably `LSTK_EXT_API_VERSION`) and SHALL refuse to run when its requirements are not met. lstk SHALL execute any resolvable `lstk-` executable without inspecting metadata about it. + +#### Scenario: No manifest required to run + +- **WHEN** an `lstk-hello` executable exists on `PATH` with no accompanying metadata file +- **THEN** lstk executes it directly without looking for or parsing a manifest + +#### Scenario: Extension self-enforces contract compatibility + +- **WHEN** an extension requires a newer runtime-context contract than `LSTK_EXT_API_VERSION` advertises +- **THEN** the extension detects the mismatch from the environment and refuses to run +- **AND** lstk does not perform this check on the extension's behalf + +### Requirement: Help and discoverability + +lstk SHALL include resolvable extensions in its help output by scanning the bundled-extensions directory and `PATH` for `lstk-*` executables and listing each discovered extension's command name under a distinct "Extensions" grouping, so users can discover installed extensions. When a bundled and a `PATH` extension share a name, the entry SHALL be listed once (the one that would run). Built-in command help SHALL remain unchanged. + +#### Scenario: Extensions listed in help + +- **WHEN** a user runs `lstk --help`, an `lstk-deploy` is bundled, and an `lstk-hello` is on `PATH` +- **THEN** the help output lists both `deploy` and `hello` under an Extensions section + +### Requirement: One-line descriptions from a bundled descriptions file + +lstk SHALL enrich the help listing with a one-line description for bundled extensions by reading a static descriptions file shipped with the distribution (generated during the release process — see the extension-bundling capability), which maps a bundled extension's command name to its description. lstk SHALL NOT execute any extension to obtain help text; help rendering remains side-effect-free. A bundled extension named in the descriptions file SHALL be listed with that description; a bundled extension absent from the file, and every `PATH`/custom extension, SHALL be listed by command name only. A missing or unreadable descriptions file SHALL degrade to name-only listing without error. + +#### Scenario: Bundled extension shows its description + +- **WHEN** the descriptions file maps `deploy` to a one-line description and a user runs `lstk help` +- **THEN** lstk lists `deploy` with that description +- **AND** lstk does not execute `lstk-deploy` to render help + +#### Scenario: PATH and custom extensions are name-only + +- **WHEN** an `lstk-hello` is resolved from `PATH` +- **THEN** lstk lists `hello` by command name with no description +- **AND** lstk does not execute it during help + +#### Scenario: Missing descriptions file degrades gracefully + +- **WHEN** no descriptions file is present (or it cannot be read) +- **THEN** lstk lists all extensions by command name only +- **AND** help rendering does not error diff --git a/openspec/changes/add-extension-mechanism/specs/extension-runtime-context/spec.md b/openspec/changes/add-extension-mechanism/specs/extension-runtime-context/spec.md new file mode 100644 index 00000000..9465d8f6 --- /dev/null +++ b/openspec/changes/add-extension-mechanism/specs/extension-runtime-context/spec.md @@ -0,0 +1,82 @@ +# extension-runtime-context Specification + +## Purpose + +Define the versioned contract by which lstk passes runtime context — resolved emulator endpoint and type, config directory, auth token, and resolved global-flag state — to an extension process, so extensions can talk to the emulator and the LocalStack platform and honor lstk's global flags without re-implementing discovery, config resolution, or auth. + +## ADDED Requirements + +### Requirement: Versioned context contract + +lstk SHALL pass runtime context to an extension exclusively through environment variables prefixed with `LSTK_EXT_`, and SHALL set `LSTK_EXT_API_VERSION` to the integer version of the contract it implements. The contract SHALL be additive within a major version; removing or repurposing a variable SHALL require incrementing `LSTK_EXT_API_VERSION`. lstk SHALL NOT require the extension to parse lstk's own config files. + +#### Scenario: API version is advertised + +- **WHEN** lstk invokes any extension +- **THEN** the extension's environment includes `LSTK_EXT_API_VERSION` set to the current contract version + +#### Scenario: Extension reads context from environment only + +- **WHEN** an extension needs the emulator endpoint and config directory +- **THEN** it can obtain both from `LSTK_EXT_` environment variables without reading lstk's TOML config + +### Requirement: Emulator endpoint and type are provided + +When a LocalStack emulator is running, lstk SHALL resolve the emulator endpoint using the same discovery and host resolution used by built-in commands, and SHALL expose it to the extension as `LSTK_EXT_EMULATOR_ENDPOINT` (a full URL), along with `LSTK_EXT_EMULATOR_TYPE` (e.g. `aws`, `snowflake`, `azure`) and the port. When no emulator is running, lstk SHALL omit the endpoint variable rather than setting an invalid value. + +#### Scenario: Endpoint provided when emulator running + +- **WHEN** an AWS emulator is running and lstk invokes an extension +- **THEN** `LSTK_EXT_EMULATOR_ENDPOINT` is set to the resolved emulator URL +- **AND** `LSTK_EXT_EMULATOR_TYPE` is set to `aws` + +#### Scenario: Endpoint omitted when no emulator running + +- **WHEN** no emulator is running and lstk invokes an extension +- **THEN** `LSTK_EXT_EMULATOR_ENDPOINT` is not set +- **AND** the extension is still executed + +### Requirement: Auth token and config directory are provided + +When the user is authenticated, lstk SHALL pass the resolved auth token to the extension as `LSTK_EXT_AUTH_TOKEN` so the extension can call the emulator and the LocalStack platform on the user's behalf. lstk SHALL pass the resolved lstk config directory as `LSTK_EXT_CONFIG_DIR`. When no auth token is available, lstk SHALL omit `LSTK_EXT_AUTH_TOKEN` rather than setting an empty value. + +#### Scenario: Auth token passed when available + +- **WHEN** the user has a resolved auth token and invokes an extension +- **THEN** `LSTK_EXT_AUTH_TOKEN` is set to that token in the extension's environment + +#### Scenario: Auth token omitted when unauthenticated + +- **WHEN** no auth token can be resolved and lstk invokes an extension +- **THEN** `LSTK_EXT_AUTH_TOKEN` is not set +- **AND** the extension is still executed + +#### Scenario: Config directory always provided + +- **WHEN** lstk invokes any extension +- **THEN** `LSTK_EXT_CONFIG_DIR` is set to the resolved lstk config directory + +### Requirement: Resolved global flags are conveyed + +lstk SHALL parse its own global flags (for example `--non-interactive`) when they appear before the extension command name, resolve them, and convey the resulting state to the extension via `LSTK_EXT_` environment variables rather than forwarding the flags on the extension's command line. In particular, lstk SHALL set `LSTK_EXT_NON_INTERACTIVE` to a truthy value when the session is non-interactive (the user passed `--non-interactive` or the standard output is not a TTY). Each lstk global flag that affects runtime behavior SHALL be conveyed as an `LSTK_EXT_` variable; adding a new global-flag variable is an additive change under `LSTK_EXT_API_VERSION`. lstk SHALL NOT include its global flags in the arguments forwarded to the extension. + +#### Scenario: Non-interactive flag conveyed via environment + +- **WHEN** a user runs `lstk --non-interactive hello --foo` and `lstk-hello` is resolvable +- **THEN** `LSTK_EXT_NON_INTERACTIVE` is set in the extension's environment +- **AND** the extension is invoked with only `--foo` (the `--non-interactive` global flag is not forwarded on its command line) + +#### Scenario: Non-interactive inferred from a non-TTY + +- **WHEN** lstk invokes an extension and standard output is not a terminal +- **THEN** `LSTK_EXT_NON_INTERACTIVE` is set even if `--non-interactive` was not passed + +### Requirement: Host environment is preserved + +lstk SHALL pass the user's existing environment through to the extension and only add or override the `LSTK_EXT_` variables defined by this contract, so extensions inherit the user's `PATH`, locale, and tool configuration. + +#### Scenario: Existing environment inherited + +- **WHEN** the user has `HTTP_PROXY` set and invokes an extension +- **THEN** the extension's environment still contains `HTTP_PROXY` +- **AND** also contains the `LSTK_EXT_` variables diff --git a/openspec/changes/add-extension-mechanism/tasks.md b/openspec/changes/add-extension-mechanism/tasks.md new file mode 100644 index 00000000..402f7f7a --- /dev/null +++ b/openspec/changes/add-extension-mechanism/tasks.md @@ -0,0 +1,56 @@ +## 1. Extension package scaffolding + +- [ ] 1.1 Create `internal/extension/` package with an `Extension` struct (resolved name, executable path) and constructor; use `log.Nop()` in tests +- [ ] 1.2 Define and document the `LSTK_EXT_API_VERSION` integer constant and the full `LSTK_EXT_*` variable contract in a package doc comment +- [ ] 1.3 Unit tests for the package's basic types/helpers + +## 2. Discovery and resolution + +- [ ] 2.1 Implement `Resolve(name)` searching the bundled dir (next to the symlink-resolved lstk executable) first, then `PATH`, for `lstk-`; honor Windows executable extensions; return first match (bundled wins) +- [ ] 2.2 Implement `List()` scanning bundled dir + `PATH` for `lstk-*` executables (names), de-duplicating by command name with bundled-then-PATH precedence +- [ ] 2.3 Implement bundled-dir resolution from `os.Executable()` with symlink resolution (works through npm/Homebrew shims) +- [ ] 2.4 Unit tests for resolution order (bundled wins), PATH fallback, not-found behavior, Windows extension handling, List de-duplication, and symlink-resolved bundled-dir lookup + +## 3. Runtime context contract + +- [ ] 3.1 Implement a builder that produces the `LSTK_EXT_*` environment (API version, emulator endpoint/type/port, config dir, auth token) layered on the inherited host environment +- [ ] 3.2 Wire emulator endpoint/type/port resolution via existing `internal/endpoint` + `internal/container` discovery; omit endpoint vars when no emulator is running +- [ ] 3.3 Include `LSTK_EXT_AUTH_TOKEN` only when a token is resolved; always include `LSTK_EXT_CONFIG_DIR`; do not set `LSTK_EXT_GRANT`/`LSTK_EXT_PUBLIC_KEY` +- [ ] 3.4 Set `LSTK_EXT_NON_INTERACTIVE` from lstk's resolved interactivity (the `isInteractive` condition: `--non-interactive` given or stdout not a TTY); document that future global flags are conveyed as additive `LSTK_EXT_*` vars +- [ ] 3.5 Unit tests asserting variable presence/absence across scenarios (emulator running vs not, authed vs not, non-interactive flag vs non-TTY, host env inherited) + +## 4. Invocation (exec) path + +- [ ] 4.1 Implement `Invoke(extension, args, ctx)` that builds the runtime env, execs the extension with args forwarded unmodified, passes stdin/stdout/stderr through, and propagates the exit code (model on `internal/iac/.../cli/exec.go`) +- [ ] 4.2 Ensure non-zero extension exits propagate without an extra lstk-level error message +- [ ] 4.3 Unit/integration tests for argument forwarding, stream passthrough, and exit-code propagation using a reference extension + +## 5. Command wiring and dispatch + +- [ ] 5.1 Wire unknown-command dispatch in `cmd/root.go`: when Cobra finds no built-in/alias for ``, attempt bundled+PATH resolution and invoke; built-ins always take precedence +- [ ] 5.2 Apply `SetInterspersed(false)` so lstk's global flags are parsed only before the command name and everything from `` onward is forwarded verbatim; verify it doesn't disturb bare-root `start` or built-in subcommand flags (fall back to a `stripGlobalFlags`-style pass if needed) +- [ ] 5.3 Ensure extension args are not parsed by lstk (`DisableFlagParsing` semantics for the synthesized extension path) +- [ ] 5.4 Add extensions to `lstk help` under an "Extensions" grouping by scanning bundled dir + `PATH` for `lstk-*` (de-duplicated, bundled wins) +- [ ] 5.5 Read the bundled descriptions file (name → one-liner) shipped in the bundled dir and attach descriptions to bundled extensions in help; `PATH`/custom extensions and bundled names missing from the file are name-only; a missing/unreadable file degrades to name-only without error; never execute an extension during help +- [ ] 5.6 Wire config initialization only where needed (extension dispatch needs config dir/endpoint); keep side-effect-free paths unaffected +- [ ] 5.7 Integration tests: built-in precedence, unknown→extension, unknown with no extension errors, help listing showing bundled descriptions from the file, PATH extensions name-only (and not executed during help), missing-descriptions-file degrades to name-only, and `lstk --non-interactive ` conveying `LSTK_EXT_NON_INTERACTIVE` while not forwarding the flag + +## 6. Reference extension and end-to-end coverage + +- [ ] 6.1 Add a small reference/example `lstk-*` extension used by tests that echoes the `LSTK_EXT_*` it received +- [ ] 6.2 Integration test: place the reference extension on a test `PATH`, invoke via `lstk `, and assert it received the expected runtime context (endpoint when emulator running, auth token when authed) + +## 7. Bundled LocalStack extensions (distribution + update) + +- [ ] 7.1 Define the bundled-extensions on-disk layout next to the lstk binary (including the descriptions file) and how each channel populates it: binary archive (sibling files), Homebrew (libexec, not symlinked to global bin), npm (package dir resolvable via the symlink-resolved exe path) +- [ ] 7.2 Generate the bundled descriptions file (name → one-liner) during the release process, version-locked to the bundled binaries, and ship it where lstk reads it +- [ ] 7.3 Wire the public release workflow to pull prebuilt closed-source bundled binaries (e.g. `lstk-deploy`) from private CI, version-pinned to the lstk release, without exposing source +- [ ] 7.4 Extend `internal/update` to replace lstk, its bundled extensions, and the descriptions file as one atomic, version-matched set across all install methods; never leave them mismatched on interrupted update +- [ ] 7.5 Add a reference bundled extension (in-tree, for tests) with a descriptions-file entry that echoes the `LSTK_EXT_*` it received and performs a stubbed self-authorization, exercising the bundled path end to end +- [ ] 7.6 Integration tests: bundled extension resolvable immediately, bundled wins over a same-named `PATH` extension, resolvable via a symlinked/shim `lstk`, bundled description shown in help, and bundled premium extension still self-authorizes + +## 8. Documentation and finalize + +- [ ] 8.1 Add an "Extensions" section to CLAUDE.md describing the mechanism, the `LSTK_EXT_*` contract (including `LSTK_EXT_NON_INTERACTIVE`), bundled-dir + PATH resolution, bundled-wins precedence, the release-generated descriptions file (bundled-only help text), and the self-authorization model (lstk passes the token; authorization is the extension's job) +- [ ] 8.2 Write a public extension-author guide: the manifest-free contract, runtime-context variables, global-flag conveyance via env, that help descriptions are bundled-only (custom/PATH extensions are name-only), how to authorize the user with the conveyed token, and the security note that authorization must not rely on lstk (which is open source); note the deferred signed-entitlement mechanism +- [ ] 8.3 Run `make lint`, `make test`, and `make test-integration`; ensure all pass