-
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 all 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,49 @@ | ||
| module AgentBootstrapsHelper | ||
| require "shellwords" | ||
|
|
||
| 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.token.to_s[0, 8]}@example.com" | ||
| suggested_name = "#{agent_bootstrap.board.name} Agent" | ||
|
|
||
| Shellwords.shelljoin([ | ||
| "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,76 @@ | ||
| 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 = find_or_create_claim_identity!(email_address) | ||
| user = identity.users.find_or_initialize_by(account: account) | ||
| if user.new_record? | ||
| user.name = name | ||
| user.role = :member | ||
| user.save! | ||
| elsif !user.active? | ||
| user.update!(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 | ||
| ) | ||
|
|
||
| update!(claimed_at: Time.current, claimed_by_identity: identity) | ||
|
|
||
| { identity:, user:, access_token: } | ||
| end | ||
| end | ||
|
|
||
| private | ||
| def find_or_create_claim_identity!(email_address) | ||
| identity = Identity.find_or_initialize_by(email_address:) | ||
| ensure_claimable_identity!(identity) | ||
| identity.save! if identity.new_record? | ||
| identity | ||
| end | ||
|
|
||
| def ensure_claimable_identity!(identity) | ||
| return if identity.new_record? | ||
| return if identity.users.where(account: account).exists? && identity.users.where.not(account: account).none? | ||
|
|
||
| errors.add(:base, "Bootstrap claims cannot reuse an identity from another account") | ||
| raise ActiveRecord::RecordInvalid, self | ||
| end | ||
| end |
| 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 interpolates
suggested_email/suggested_namedirectly 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.
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 throughShellwords.splitto confirm the argv stays intact. The helper now only builds the argv pieces; it does not hand-roll a quoted shell string.