Add board agent bootstrap and skill-backed CLI flow#2759
Add board agent bootstrap and skill-backed CLI flow#2759blackopsrepl wants to merge 12 commits intobasecamp:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a board-scoped agent bootstrap flow (create/show/claim + skill download endpoint) and adds a standalone fizzy CLI intended for agent runtimes to onboard without shared credentials.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Changes:
- Added persistent
Board::AgentBootstraprecords and controllers/routes to create/view bootstraps and to claim/download the bootstrap skill. - Added a standalone Thor-based
fizzyCLI with profile storage, an installer script, and tests. - Updated docs and skill content (
SKILL.md+ references) to point agents at a real skill download URL and bootstrap command.
Reviewed changes
Copilot reviewed 34 out of 37 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/system/agent_bootstraps_test.rb | System test coverage for generating an agent bootstrap from a board UI. |
| test/lib/fizzy/config_store_test.rb | Unit tests for CLI config/profile persistence and env overrides. |
| test/lib/fizzy/client_test.rb | Unit tests for CLI HTTP client JSON + auth behavior and error handling. |
| test/lib/fizzy/cli_test.rb | Unit tests for CLI bootstrap, whoami, and raw API command behavior. |
| test/controllers/boards/agent_bootstraps_controller_test.rb | Controller tests for board admin bootstrap creation/show and visibility/authorization. |
| test/controllers/agent_bootstrap_skills_controller_test.rb | Tests for unauthenticated skill download endpoint and 410 semantics. |
| test/controllers/agent_bootstrap_claims_controller_test.rb | Tests for claim flow producing identity/user/token and single-use/expiry behavior. |
| skills/fizzy-cli/references/commands.md | Reference documentation for CLI commands used by agents/humans. |
| skills/fizzy-cli/agents/openai.yaml | Agent skill metadata/prompt configuration. |
| skills/fizzy-cli/SKILL.md | Primary skill instructions agents download via the bootstrap skill endpoint. |
| docs/API.md | Public API documentation for bootstrap creation, skill download, claim flow, and board involvement endpoint. |
| db/schema_sqlite.rb | Schema update for SQLite including board_agent_bootstraps table and schema version bump. |
| db/schema.rb | Schema update including board_agent_bootstraps table and schema version bump. |
| db/migrate/20260325120000_create_board_agent_bootstraps.rb | Migration introducing board_agent_bootstraps persistence + indexes. |
| config/routes.rb | Routes for board-scoped bootstraps and untenanted skill/claim endpoints. |
| cli/lib/fizzy/version.rb | CLI version constant. |
| cli/lib/fizzy/config_store.rb | YAML-backed profile storage for CLI with file permission hardening. |
| cli/lib/fizzy/client.rb | Minimal Net::HTTP JSON client with bearer token support and ApiError. |
| cli/lib/fizzy/cli.rb | Thor CLI implementation for auth/bootstrap, discovery, and CRUD-style operations. |
| cli/fizzy-cli.gemspec | Gem packaging for the standalone CLI. |
| cli/exe/fizzy | CLI executable entrypoint with error handling. |
| cli/README.md | CLI install and dev usage documentation. |
| cli/Gemfile | Bundler setup for the CLI gem. |
| bin/install/fizzy/cli | Compatibility wrapper to the CLI installer. |
| bin/install-fizzy-cli | Installer that builds/installs the CLI gem and installs a launcher. |
| bin/fizzy | Repo-level wrapper to run the CLI from the checkout. |
| app/views/boards/show.html.erb | Adds “Agent setup” action link for account admins on board header. |
| app/views/boards/agent_bootstraps/show.json.jbuilder | JSON payload for bootstrap creation/show (including URLs and setup block). |
| app/views/boards/agent_bootstraps/show.html.erb | Bootstrap show page with copyable setup command, skill URL, prompt, and raw claim URL. |
| app/views/boards/agent_bootstraps/new.html.erb | Bootstrap creation page (one-click generate flow). |
| app/models/board/agent_bootstrap.rb | Model for bootstraps with expiry/single-use and claim transaction that provisions access/token. |
| app/models/board.rb | Adds association for board agent bootstraps. |
| app/helpers/agent_bootstraps_helper.rb | Helper methods for bootstrap URLs, setup command, and agent prompt block. |
| app/controllers/boards/agent_bootstraps_controller.rb | Board-scoped create/show/new for admins; returns HTML or JSON. |
| app/controllers/agent_bootstrap_skills_controller.rb | Untenanted skill download endpoint guarded by claimability. |
| app/controllers/agent_bootstrap_claims_controller.rb | Untenanted claim endpoint that provisions identity/user/token and returns JSON profile payload. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def agent_bootstrap_setup_command(agent_bootstrap) | ||
| suggested_email = "agent+#{agent_bootstrap.board.id.first(8)}@example.com" | ||
| suggested_name = "#{agent_bootstrap.board.name} Agent" | ||
|
|
||
| %(fizzy auth bootstrap "#{agent_bootstrap_claim_url_for(agent_bootstrap)}" --email "#{suggested_email}" --name "#{suggested_name}") | ||
| end |
There was a problem hiding this comment.
The generated setup command interpolates suggested_email/suggested_name directly into a shell command inside double quotes. If the board name contains quotes, backticks, $(), etc., the copy/paste command can break or be unsafe. Consider generating the command using proper shell escaping (e.g., Shellwords.shellescape) for each argument value.
There was a problem hiding this comment.
Kept the setup command on Shellwords.shelljoin, which is the escaping boundary here, and added controller coverage that round-trips a hostile board name through Shellwords.split to confirm the argv stays intact. The helper now only builds the argv pieces; it does not hand-roll a quoted shell string.
app/models/board/agent_bootstrap.rb
Outdated
| identity = Identity.find_or_initialize_by(email_address: email_address) | ||
| identity.save! if identity.new_record? | ||
|
|
||
| user = identity.users.find_or_initialize_by(account: account) | ||
| if user.new_record? | ||
| user.name = name | ||
| user.role = :member | ||
| user.verified_at ||= Time.current | ||
| user.save! | ||
| elsif !user.active? | ||
| user.update!(identity: identity, active: true) | ||
| end | ||
|
|
||
| board.accesses.find_or_create_by!(user: user) do |access| | ||
| access.account = account | ||
| access.involvement = involvement | ||
| end.update!(involvement: involvement) | ||
|
|
||
| access_token = identity.access_tokens.create!( | ||
| description: "Fizzy CLI#{profile_name.present? ? " (#{profile_name})" : " (#{name})"}", | ||
| permission: permission | ||
| ) |
There was a problem hiding this comment.
claim! will reuse an existing Identity purely based on email_address and then mint a new Identity::AccessToken without any proof the caller controls that email/identity. Because access tokens are identity-scoped (usable across all accounts the identity belongs to), a leaked bootstrap token could be used to mint a token for an existing identity that has access to other accounts, escalating access beyond the intended board/account. Consider restricting claims to new identities (or identities that only belong to this account), or require the caller to already be authenticated as that identity when Identity already exists/has other accounts.
There was a problem hiding this comment.
Fixed in two steps, then corrected the over-tightened version. The final behavior is: reuse an existing identity only if it belongs exclusively to this same account; reject identities that belong to any other account. That preserves the valid “same agent joins another board in the same account” flow while closing the cross-account token-minting issue.
app/models/board/agent_bootstrap.rb
Outdated
| if user.new_record? | ||
| user.name = name | ||
| user.role = :member | ||
| user.verified_at ||= Time.current |
There was a problem hiding this comment.
New users created via a bootstrap are being marked verified immediately (user.verified_at ||= Time.current). This bypasses the normal verification/magic-link flow and makes the bootstrap token the only gate. If this is intended for agents, consider using a separate “service/agent” identity type or a distinct verification state so this doesn’t implicitly confer “verified human” semantics elsewhere in the app.
| user.verified_at ||= Time.current |
There was a problem hiding this comment.
Removed the implicit verified_at write from bootstrap claims. Bootstrap-created users now stay unverified as humans, and I added a regression test showing the returned bearer token can still access the board API in that state.
| def set_current_profile(name) | ||
| data = load | ||
| raise Error, "Unknown profile #{name}" unless data.fetch("profiles", {}).key?(name) | ||
|
|
||
| data["current_profile"] = name | ||
| persist(data) | ||
| end |
There was a problem hiding this comment.
save_profile/set_current_profile raise Error, but Fizzy::Error is defined in client.rb and isn’t required by this file. If someone requires/uses Fizzy::ConfigStore without loading the client first, raising will trigger NameError. Consider defining Fizzy::Error in a dedicated file (e.g., fizzy/errors) required by both, or use a local exception class in ConfigStore.
There was a problem hiding this comment.
Fixed by moving Fizzy::Error into a shared cli/lib/fizzy/error.rb and requiring it from both client.rb and config_store.rb. There is now also a standalone test that loads ConfigStore without client.rb first and asserts it raises Fizzy::Error correctly.
| def agent_bootstrap_setup_command(agent_bootstrap) | ||
| suggested_email = "agent+#{agent_bootstrap.board.id.first(8)}@example.com" | ||
| suggested_name = "#{agent_bootstrap.board.name} Agent" | ||
|
|
||
| %(fizzy auth bootstrap "#{agent_bootstrap_claim_url_for(agent_bootstrap)}" --email "#{suggested_email}" --name "#{suggested_name}") | ||
| end |
There was a problem hiding this comment.
agent_bootstrap.board.id is a UUID/string, so calling .first(8) will raise NoMethodError (String doesn’t implement first). Use agent_bootstrap.board.id.to_s[0, 8] (or similar) to build the suggested email prefix.
There was a problem hiding this comment.
Fixed. The suggested email is no longer derived from board.id.first(8). It now uses a bootstrap-token-derived suffix, which both avoids the helper bug and ensures each generated setup command gets a fresh default email.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa7fee18f6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
app/models/board/agent_bootstrap.rb
Outdated
| identity = Identity.find_or_initialize_by(email_address: email_address) | ||
| identity.save! if identity.new_record? |
There was a problem hiding this comment.
Prevent claiming bootstraps as an existing identity
This claim flow looks up Identity by raw email and reuses it before minting a new access token, so the unauthenticated /agent_bootstrap/:token/claim endpoint can be used to impersonate any existing user whose email is known. In practice, anyone holding a valid bootstrap token can claim as an owner/admin identity and receive a bearer token for that identity (which is not board-scoped), enabling access beyond the intended board bootstrap scope.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed. The unauthenticated claim flow no longer reuses arbitrary existing identities. It now reuses an identity only when that identity belongs exclusively to the same account; otherwise the claim is rejected. That keeps multi-board agent onboarding inside one account working without reopening the cross-account impersonation path.
| suggested_email = "agent+#{agent_bootstrap.board.id.first(8)}@example.com" | ||
| suggested_name = "#{agent_bootstrap.board.name} Agent" | ||
|
|
||
| %(fizzy auth bootstrap "#{agent_bootstrap_claim_url_for(agent_bootstrap)}" --email "#{suggested_email}" --name "#{suggested_name}") |
There was a problem hiding this comment.
Escape board name before embedding shell bootstrap command
The generated setup command injects board.name directly into a double-quoted shell argument, which allows shell expansion/substitution ($(...), backticks, $VAR, embedded quotes) when an operator copies and runs it. Because board names are user-controlled text, this can execute unintended commands or corrupt the bootstrap command; shell-escape every interpolated argument before rendering command text.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The command generation stays on Shellwords.shelljoin, which is doing the escaping here. I also added coverage that uses a hostile board name and asserts the copied command splits back into the intended argv, so this path is now verified rather than only assumed.
|
Hey @blackopsrepl - thanks for your work on this. Check out the development in-progress at https://github.com/basecamp/fizzy-cli - perhaps better suited over there? |
Unfortunately, I just noticed the CLI existed already and just pointed it out on X. Is there anything that you didn't cover over there, yet? How do you suggest I proceed? |
|
Reverted to draft. I guess I'll keep the proposed frontend changes, but without my cli tool in ruby and fizzy-cli in its place. |
Summary
This PR adds a board-scoped agent bootstrap flow and a standalone
fizzyCLI so an external agent can be onboarded directly from Fizzy.A board admin can generate a one-time bootstrap from the board UI or API. The agent can then fetch the
fizzy-cliskill, claim the bootstrap with its own identity, receive its own personal access token, and immediately subscribe to the target board.Why
The objective is to make board-specific agent onboarding usable through OpenClaw and similar agent runtimes without manual token provisioning or shared credentials.
Each agent should:
What Changed
Board bootstrap flow
Board::AgentBootstraprecords with expiry and single-use claim semanticsStandalone CLI
fizzyCLI for auth/bootstrap and board/card/comment operationsSkill delivery
GET /agent_bootstrap/:token/skillskill_urlfizzy auth bootstrap ...fizzy whoami --jsonURL fix
claimandskillURLs are now generated outside tenant scope/1/agent_bootstrap/...URLs that redirect instead of serving the bootstrap endpointsDevelopment Notes
This was built incrementally:
The important correction late in the work was that mentioning
fizzy-cliwas not enough to teach the agent anything. The final approach serves the actualSKILL.mdcontent through a tokenized endpoint.Quickstart
UI flow
Agent setup.Agent flow
/agent_bootstrap/:token/skillTesting
Targeted controller coverage passed for:
bin/rails testalso passed locally after the rebase:Notes
Best effort was made on where the new pieces live in the codebase, but we will keep the PR thread monitored and adjust placement if review turns up a better boundary.