-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Add board agent bootstrap and skill-backed CLI flow #2759
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 10 commits
f11fae4
a7b3379
13e4a2c
c37fad8
01f8999
90c2b40
0900d64
c59898c
95e487c
fa7fee1
7ad2723
5698135
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| class AgentBootstrapClaimsController < ApplicationController | ||
| disallow_account_scope | ||
| allow_unauthenticated_access | ||
| wrap_parameters :agent_bootstrap, include: %i[ email_address name profile_name ] | ||
|
|
||
| def create | ||
| bootstrap = Board::AgentBootstrap.find_by!(token: params.expect(:token)) | ||
| claim = bootstrap.claim!(**claim_params.to_h.symbolize_keys) | ||
|
|
||
| render json: { | ||
| token: claim[:access_token].token, | ||
| permission: claim[:access_token].permission, | ||
| account: { | ||
| id: bootstrap.account.id, | ||
| name: bootstrap.account.name, | ||
| slug: bootstrap.account.slug | ||
| }, | ||
| board: { | ||
| id: bootstrap.board.id, | ||
| name: bootstrap.board.name, | ||
| url: board_url(bootstrap.board, script_name: bootstrap.account.slug) | ||
| }, | ||
| user: { | ||
| id: claim[:user].id, | ||
| name: claim[:user].name, | ||
| email_address: claim[:identity].email_address | ||
| }, | ||
| profile: { | ||
| base_url: root_url(script_name: nil).delete_suffix("/"), | ||
| account_slug: bootstrap.account.slug, | ||
| default_board_id: bootstrap.board.id | ||
| } | ||
| }, status: :created | ||
| rescue ActiveRecord::RecordNotFound | ||
| head :gone | ||
| rescue ActiveRecord::RecordInvalid => error | ||
| render json: { errors: error.record.errors.full_messages }, status: :unprocessable_entity | ||
| end | ||
|
|
||
| private | ||
| def claim_params | ||
| params.expect(agent_bootstrap: %i[ email_address name profile_name ]) | ||
| .with_defaults(profile_name: nil) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| class AgentBootstrapSkillsController < ApplicationController | ||
| disallow_account_scope | ||
| allow_unauthenticated_access | ||
|
|
||
| def show | ||
| bootstrap = Board::AgentBootstrap.find_by!(token: params.expect(:token)) | ||
| raise ActiveRecord::RecordNotFound unless bootstrap.claimable? | ||
|
|
||
| send_data skill_source, | ||
| filename: "fizzy-cli.SKILL.md", | ||
| type: "text/markdown; charset=utf-8", | ||
| disposition: "inline" | ||
| rescue ActiveRecord::RecordNotFound | ||
| head :gone | ||
| end | ||
|
|
||
| private | ||
| def skill_source | ||
| Rails.root.join("skills", "fizzy-cli", "SKILL.md").read | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| class Boards::AgentBootstrapsController < ApplicationController | ||
| include BoardScoped | ||
|
|
||
| before_action :ensure_admin | ||
| before_action :set_agent_bootstrap, only: :show | ||
|
|
||
| def new | ||
| end | ||
|
|
||
| def show | ||
| end | ||
|
|
||
| def create | ||
| @agent_bootstrap = @board.agent_bootstraps.create!( | ||
| account: @board.account, | ||
| creator: Current.user, | ||
| expires_at: expires_in_minutes.minutes.from_now, | ||
| permission: params.fetch(:permission, :write), | ||
| involvement: params.fetch(:involvement, :watching) | ||
| ) | ||
|
|
||
| respond_to do |format| | ||
| format.html { redirect_to board_agent_bootstrap_path(@board, @agent_bootstrap) } | ||
| format.json { render :show, status: :created, location: board_agent_bootstrap_url(@board, @agent_bootstrap, format: :json) } | ||
| end | ||
| end | ||
|
|
||
| private | ||
| def set_agent_bootstrap | ||
| @agent_bootstrap = @board.agent_bootstraps.find(params[:id]) | ||
| end | ||
|
|
||
| def expires_in_minutes | ||
| params.fetch(:expires_in_minutes, 30).to_i.clamp(5, 24.hours.in_minutes) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| module AgentBootstrapsHelper | ||
| AGENT_BOOTSTRAP_SKILL_NAME = "fizzy-cli" | ||
|
|
||
| def link_to_agent_bootstrap(board) | ||
| link_to new_board_agent_bootstrap_path(board), | ||
| class: "btn btn--circle-mobile", | ||
| data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Agent setup" } do | ||
| icon_tag("settings") + tag.span("Agent setup", class: "for-screen-reader") | ||
| end | ||
| end | ||
|
|
||
| def agent_bootstrap_claim_url_for(agent_bootstrap) | ||
| agent_bootstrap_claim_url(token: agent_bootstrap.token, script_name: nil) | ||
| end | ||
|
|
||
| def agent_bootstrap_skill_url_for(agent_bootstrap) | ||
| agent_bootstrap_skill_url(token: agent_bootstrap.token, script_name: nil) | ||
| end | ||
|
|
||
| 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 | ||
|
Comment on lines
+22
to
+31
|
||
|
|
||
| def agent_bootstrap_skill_name | ||
| AGENT_BOOTSTRAP_SKILL_NAME | ||
| end | ||
|
|
||
| def agent_bootstrap_skill_block(agent_bootstrap) | ||
| <<~TEXT.strip | ||
| Download the Fizzy CLI skill from: | ||
| #{agent_bootstrap_skill_url_for(agent_bootstrap)} | ||
|
|
||
| Load that skill into your agent, then run: | ||
| #{agent_bootstrap_setup_command(agent_bootstrap)} | ||
|
|
||
| Verify the bootstrap with: | ||
| fizzy whoami --json | ||
| TEXT | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,63 @@ | ||||
| class Board::AgentBootstrap < ApplicationRecord | ||||
| belongs_to :account | ||||
| belongs_to :board | ||||
| belongs_to :creator, class_name: "User" | ||||
| belongs_to :claimed_by_identity, class_name: "Identity", optional: true | ||||
|
|
||||
| has_secure_token | ||||
|
|
||||
| enum :permission, %w[ read write ].index_by(&:itself), default: :write | ||||
| enum :involvement, Access.involvements, default: :watching | ||||
|
|
||||
| scope :active, -> { where(claimed_at: nil, expires_at: Time.current...) } | ||||
|
|
||||
| validates :expires_at, presence: true | ||||
|
|
||||
| def expired? | ||||
| expires_at <= Time.current | ||||
| end | ||||
|
|
||||
| def claimed? | ||||
| claimed_at.present? | ||||
| end | ||||
|
|
||||
| def claimable? | ||||
| !claimed? && !expired? | ||||
| end | ||||
|
|
||||
| def claim!(email_address:, name:, profile_name: nil) | ||||
| raise ActiveRecord::RecordNotFound unless claimable? | ||||
|
|
||||
| transaction do | ||||
| lock! | ||||
| raise ActiveRecord::RecordNotFound unless claimable? | ||||
|
|
||||
| identity = Identity.find_or_initialize_by(email_address: email_address) | ||||
| identity.save! if identity.new_record? | ||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This claim flow looks up Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||||
|
|
||||
| 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.verified_at ||= Time.current |
There was a problem hiding this comment.
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.
Copilot
AI
Mar 26, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| <% @page_title = "Agent setup" %> | ||
|
|
||
| <% content_for :header do %> | ||
| <div class="header__actions header__actions--start"> | ||
| <%= back_link_to @board.name, @board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> | ||
| </div> | ||
| <% end %> | ||
|
|
||
| <article class="panel panel--wide center flex flex-column gap"> | ||
| <header> | ||
| <h1 class="txt-large margin-none font-weight-black">Set up an agent for <%= @board.name %></h1> | ||
| <p class="margin-none txt-medium">This creates a one-time setup link for a CLI agent. The agent will join this account, get a write token, and start watching this board.</p> | ||
| </header> | ||
|
|
||
| <%= form_with url: board_agent_bootstraps_path(@board), method: :post, html: { class: "flex flex-column gap full-width" } do |form| %> | ||
| <%= form.hidden_field :permission, value: :write %> | ||
| <%= form.hidden_field :involvement, value: :watching %> | ||
| <%= form.hidden_field :expires_in_minutes, value: 30 %> | ||
|
|
||
| <button class="btn"> | ||
| <span>Generate setup command</span> | ||
| </button> | ||
| <% end %> | ||
| </article> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| <% @page_title = "Agent setup" %> | ||
|
|
||
| <% content_for :header do %> | ||
| <div class="header__actions header__actions--start"> | ||
| <%= back_link_to @board.name, @board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> | ||
| </div> | ||
| <% end %> | ||
|
|
||
| <article class="panel panel--wide center flex flex-column gap"> | ||
| <header> | ||
| <h1 class="txt-large margin-none font-weight-black">Agent setup for <%= @board.name %></h1> | ||
| <p class="margin-none txt-medium">Copy the command below into an agent session. It can be used once until <%= @agent_bootstrap.expires_at.to_fs(:long) %>.</p> | ||
| </header> | ||
|
|
||
| <div class="flex flex-column gap-half full-width txt-align-start"> | ||
| <strong>Setup command</strong> | ||
| <textarea class="input" readonly rows="4"><%= agent_bootstrap_setup_command(@agent_bootstrap) %></textarea> | ||
| <div class="flex justify-center"> | ||
| <%= button_to_copy_to_clipboard(agent_bootstrap_setup_command(@agent_bootstrap)) do %> | ||
| <span>Copy setup command</span> | ||
| <% end %> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="flex flex-column gap-half full-width txt-align-start"> | ||
| <strong>Skill download URL</strong> | ||
| <input type="text" class="input" readonly value="<%= agent_bootstrap_skill_url_for(@agent_bootstrap) %>"> | ||
| <div class="flex justify-center"> | ||
| <%= button_to_copy_to_clipboard(agent_bootstrap_skill_url_for(@agent_bootstrap)) do %> | ||
| <span>Copy skill URL</span> | ||
| <% end %> | ||
| </div> | ||
| <p class="txt-small txt-subtle margin-none">Give this URL to an agent so it can download the current <%= agent_bootstrap_skill_name %> skill.</p> | ||
| </div> | ||
|
|
||
| <div class="flex flex-column gap-half full-width txt-align-start"> | ||
| <strong>Agent prompt</strong> | ||
| <textarea class="input" readonly rows="6"><%= agent_bootstrap_skill_block(@agent_bootstrap) %></textarea> | ||
| <div class="flex justify-center"> | ||
| <%= button_to_copy_to_clipboard(agent_bootstrap_skill_block(@agent_bootstrap)) do %> | ||
| <span>Copy agent prompt</span> | ||
| <% end %> | ||
| </div> | ||
| <p class="txt-small txt-subtle margin-none">This prompt tells the agent where to fetch the skill, how to bootstrap the CLI, and how to verify access.</p> | ||
| </div> | ||
|
|
||
| <div class="flex flex-column gap-half full-width txt-align-start"> | ||
| <strong>Raw bootstrap URL</strong> | ||
| <input type="text" class="input" readonly value="<%= agent_bootstrap_claim_url_for(@agent_bootstrap) %>"> | ||
| <div class="flex justify-center"> | ||
| <%= button_to_copy_to_clipboard(agent_bootstrap_claim_url_for(@agent_bootstrap)) do %> | ||
| <span>Copy bootstrap URL</span> | ||
| <% end %> | ||
| </div> | ||
| </div> | ||
|
|
||
| <footer class="txt-small txt-subtle"> | ||
| <p class="margin-none">This link creates a write token and sets the agent to watch this board.</p> | ||
| </footer> | ||
| </article> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| json.id @agent_bootstrap.id | ||
| json.token @agent_bootstrap.token | ||
| json.expires_at @agent_bootstrap.expires_at.utc.iso8601 | ||
| json.bootstrap_url agent_bootstrap_claim_url_for(@agent_bootstrap) | ||
| json.skill_url agent_bootstrap_skill_url_for(@agent_bootstrap) | ||
| json.setup_command agent_bootstrap_setup_command(@agent_bootstrap) | ||
| json.skill_name agent_bootstrap_skill_name | ||
| json.skill_block agent_bootstrap_skill_block(@agent_bootstrap) | ||
| json.permission @agent_bootstrap.permission | ||
| json.involvement @agent_bootstrap.involvement | ||
|
|
||
| json.account do | ||
| json.(@agent_bootstrap.account, :id, :name, :slug) | ||
| end | ||
|
|
||
| json.board @agent_bootstrap.board, partial: "boards/board", as: :board |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| #!/usr/bin/env ruby | ||
|
|
||
| $LOAD_PATH.unshift(File.expand_path("../cli/lib", __dir__)) | ||
|
|
||
| load File.expand_path("../cli/exe/fizzy", __dir__) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The generated setup command injects
board.namedirectly 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.
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.