Skip to content

Add board agent bootstrap and skill-backed CLI flow#2759

Draft
blackopsrepl wants to merge 12 commits intobasecamp:mainfrom
blackopsrepl:feature/agent-bootstrap-cli
Draft

Add board agent bootstrap and skill-backed CLI flow#2759
blackopsrepl wants to merge 12 commits intobasecamp:mainfrom
blackopsrepl:feature/agent-bootstrap-cli

Conversation

@blackopsrepl
Copy link
Copy Markdown

Summary

This PR adds a board-scoped agent bootstrap flow and a standalone fizzy CLI 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-cli skill, 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:

  • use its own account identity
  • get its own access token
  • be subscribed to the board automatically
  • receive a copy/pasteable setup flow from the UI

What Changed

Board bootstrap flow

  • Added persistent Board::AgentBootstrap records with expiry and single-use claim semantics
  • Added board UI for generating and viewing bootstraps
  • Added API support for creating bootstraps and claiming them

Standalone CLI

  • Added a standalone fizzy CLI for auth/bootstrap and board/card/comment operations
  • Added local profile storage and JSON-oriented output for agent use
  • Added installer flow so the CLI is easy to install from the repo

Skill delivery

  • Added GET /agent_bootstrap/:token/skill
  • The bootstrap page and JSON payload now expose a real skill_url
  • Replaced the previous skill-name hint with a copyable agent prompt that:
    • points to the skill download URL
    • runs fizzy auth bootstrap ...
    • verifies with fizzy whoami --json

URL fix

  • Bootstrap claim and skill URLs are now generated outside tenant scope
  • This avoids broken /1/agent_bootstrap/... URLs that redirect instead of serving the bootstrap endpoints

Development Notes

This was built incrementally:

  1. Added the board bootstrap persistence and claim flow.
  2. Added the standalone CLI and profile handling.
  3. Added the board UI and JSON output for bootstrap setup.
  4. Added tests for the bootstrap flow and CLI.
  5. Reworked the skill integration so the agent is pointed at a real downloadable skill endpoint instead of only being told a skill name.

The important correction late in the work was that mentioning fizzy-cli was not enough to teach the agent anything. The final approach serves the actual SKILL.md content through a tokenized endpoint.

Quickstart

UI flow

  1. Open a board as an account admin.
  2. Click Agent setup.
  3. Generate a setup command.
  4. Copy the agent prompt or the skill URL.

Agent flow

  1. Fetch the skill from /agent_bootstrap/:token/skill
  2. Load that skill into the agent runtime
  3. Run the provided bootstrap command:
    fizzy auth bootstrap "..." --email "agent@example.com" --name "Board Agent"
  4. Verify:
    fizzy whoami --json

Testing

Targeted controller coverage passed for:

  • bootstrap creation/show JSON
  • bootstrap claim flow
  • bootstrap skill download endpoint

bin/rails test also passed locally after the rebase:

  • 1437 runs
  • 5248 assertions
  • 0 failures
  • 0 errors
  • 0 skips

Notes

  • Each agent should use its own bootstrap and its own CLI profile.
  • Bootstraps are single-use and expire automatically.

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.

Copilot AI review requested due to automatic review settings March 26, 2026 17:47
Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::AgentBootstrap records and controllers/routes to create/view bootstraps and to claim/download the bootstrap skill.
  • Added a standalone Thor-based fizzy CLI 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.

Comment on lines +20 to +25
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +35 to +56
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
)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

if user.new_record?
user.name = name
user.role = :member
user.verified_at ||= Time.current
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
user.verified_at ||= Time.current

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +28 to +34
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +20 to +25
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +35 to +36
identity = Identity.find_or_initialize_by(email_address: email_address)
identity.save! if identity.new_record?
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@robzolkos
Copy link
Copy Markdown
Collaborator

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?

@blackopsrepl
Copy link
Copy Markdown
Author

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?

@blackopsrepl blackopsrepl marked this pull request as draft March 26, 2026 22:31
@blackopsrepl
Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants