sigit code cloud init

Seto Elkahfi committed Jun 19, 2026 at 03:38 UTC 6a9ad8aa38214927ee455807d6421975842cfd7e
6 files changed +234 -1
.env.example
+10
index 10a6a6d..3c49344 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,16 @@ SMBCLOUD_APP_SECRET=your-app-secret-here SMBCLOUD_ENVIRONMENT=production +# ----------------------------------------------------------------------------- +# Anthropic (required for the desktop coding agent) +# sigit-si proxies the Messages API on behalf of the siGit Code & Deploy app so +# the desktop binary never holds this key. Get one at https://console.anthropic.com +# POST /api/v1/messages forwards native Messages API requests using this key. +# ----------------------------------------------------------------------------- + +ANTHROPIC_API_KEY=your-anthropic-api-key-here + + # ----------------------------------------------------------------------------- # Database (PostgreSQL) # The defaults below work with a local Postgres.app installation.
Gemfile
+5
index 42d6d5d..afdaf47 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,11 @@ gem "jbuilder" # Local path for testing 0.4.4 (reset_password) before it lands on RubyGems. gem "smbcloud-auth", path: "../smbcloud-cli/sdk/gems/auth" +# Anthropic Messages API — server-side wrapper for the desktop coding agent. +# sigit-si holds the confidential ANTHROPIC_API_KEY and proxies token-authed +# requests from the public desktop client. See app/services/anthropic_service.rb. +gem "anthropic", "~> 1.49" + # Markdown rendering for README files gem "redcarpet", "~> 3.6"
Gemfile.lock
+8 -1
index 49a1f2d..c08d983 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: ../smbcloud-cli/sdk/gems/auth specs: - smbcloud-auth (0.4.4) + smbcloud-auth (0.4.5) json rb_sys (~> 0.9.91) @@ -84,6 +84,10 @@ GEM uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) + anthropic (1.49.0) + cgi + connection_pool + standardwebhooks ast (2.4.3) base64 (0.3.0) bcrypt_pbkdf (1.1.2) @@ -97,6 +101,7 @@ GEM bundler-audit (0.9.3) bundler (>= 1.2.0) thor (~> 1.0) + cgi (0.5.1) childprocess (5.1.0) logger (~> 1.5) concurrent-ruby (1.3.7) @@ -325,6 +330,7 @@ GEM net-sftp (>= 2.1.2) net-ssh (>= 2.8.0) ostruct + standardwebhooks (1.1.0) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) @@ -360,6 +366,7 @@ PLATFORMS arm64-darwin DEPENDENCIES + anthropic (~> 1.49) bootsnap brakeman bundler-audit
app/controllers/api/v1/messages_controller.rb
+99
new file mode 100644 index 0000000..e88431e --- /dev/null +++ b/app/controllers/api/v1/messages_controller.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Api + module V1 + # Server-side proxy to the Anthropic Messages API for the desktop coding + # agent. The desktop app sends a native Messages API request with its + # smbCloud access token as the bearer; we authenticate it, then forward the + # call through AnthropicService, which injects the confidential + # `ANTHROPIC_API_KEY` server-side. The desktop never sees the key. + # + # POST /api/v1/messages + # Header: Authorization: Bearer <access_token> + # Body: a native Anthropic Messages API request (model, messages, + # system, tools, thinking, max_tokens, …). Set "stream": true for + # an SSE response. + # + # Responses use the native Anthropic shapes so the desktop can speak the + # standard Messages protocol: + # - non-stream → the Message JSON object + # - stream → text/event-stream of Messages API events + # (message_start, content_block_delta, message_stop, …) + class MessagesController < Api::BaseController + include ActionController::Live + + before_action :authenticate_token! + + def create + if streaming_requested? + stream_message + else + message = AnthropicService.create(message_payload) + render json: message.to_h, status: :ok + end + rescue AnthropicService::InvalidRequestError => e + render_anthropic_error("invalid_request_error", e.message, status: :bad_request) + rescue Anthropic::Errors::APIStatusError => e + render_upstream_error(e) + rescue Anthropic::Errors::APIConnectionError => e + Rails.logger.error("Anthropic connection error: #{e.message}") + render_anthropic_error("api_error", "Could not reach the model provider.", + status: :bad_gateway) + end + + private + + # Streams the upstream response to the client as Server-Sent Events, + # re-emitting each Messages API stream event with its native `type` as the + # SSE event name and the event body as JSON `data`. Errors raised mid-stream + # are surfaced as an `error` event because the status line is already sent. + def stream_message + response.headers["Content-Type"] = "text/event-stream" + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" # disable proxy buffering (nginx) + + sse = ActionController::Live::SSE.new(response.stream) + AnthropicService.stream(message_payload) do |event| + sse.write(event.to_h, event: event.type) + end + rescue AnthropicService::InvalidRequestError => e + write_sse_error(sse, "invalid_request_error", e.message) + rescue Anthropic::Errors::APIStatusError => e + write_sse_error(sse, e.try(:type) || "api_error", e.message) + rescue Anthropic::Errors::APIConnectionError => e + Rails.logger.error("Anthropic stream connection error: #{e.message}") + write_sse_error(sse, "api_error", "Could not reach the model provider.") + rescue ActionController::Live::ClientDisconnected + # Client hung up mid-stream — nothing to do but stop writing. + ensure + sse&.close + end + + # The raw inbound request body (everything except Rails routing keys), + # handed to AnthropicService for curation. + def message_payload + params.except(:controller, :action, :format, :message).to_unsafe_h + end + + def streaming_requested? + ActiveModel::Type::Boolean.new.cast(params[:stream]) + end + + # Forwards Anthropic's own status code and error shape so the desktop's + # Messages client sees a native error rather than a translated one. + def render_upstream_error(error) + status = error.try(:status) || 502 + render_anthropic_error(error.try(:type) || "api_error", error.message, status: status) + end + + # Native Anthropic error envelope: { "type": "error", "error": { type, message } }. + def render_anthropic_error(type, message, status:) + render json: { type: "error", error: { type: type, message: message } }, status: status + end + + def write_sse_error(sse, type, message) + sse&.write({ type: "error", error: { type: type, message: message } }, event: "error") + end + end + end +end
app/services/anthropic_service.rb
+111
new file mode 100644 index 0000000..e85f469 --- /dev/null +++ b/app/services/anthropic_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "anthropic" + +# Server-side wrapper around the Anthropic Messages API. +# +# sigit-si is the trust boundary for the siGit Code & Deploy desktop app's +# coding agent: the desktop is a public client (a shipped binary), so it must +# never hold the org's Anthropic API key. Instead the app sends a Messages API +# request to sigit.si/api/v1/messages with its smbCloud access token as the +# bearer; sigit-si verifies that token (Api::BaseController#authenticate_token!) +# and then makes the upstream call here, injecting the confidential +# `ANTHROPIC_API_KEY` that lives only on the server. +# +# The request/response shapes are the native Anthropic Messages API shapes — we +# only curate the inbound parameters and forward the model's output verbatim, so +# the desktop can speak the standard Messages protocol (including streaming SSE, +# tool use, and adaptive thinking) to its own backend. +# +# Required env var: +# ANTHROPIC_API_KEY — the org's Anthropic API key (confidential, server-only) +class AnthropicService + # Models the proxy is allowed to serve. The key is the org's, so this caps the + # blast radius (and cost) of an arbitrary `model` value from a public client. + # Defaults to Claude Opus 4.8 — the coding-agent default. + ALLOWED_MODELS = %w[ + claude-opus-4-8 + claude-opus-4-7 + claude-sonnet-4-6 + claude-haiku-4-5 + ].freeze + + DEFAULT_MODEL = "claude-opus-4-8" + + # Required by the Messages API; a sane ceiling for a single agent turn when the + # client doesn't specify one. Clients that need long outputs (and stream) can + # override up to the model's limit. + DEFAULT_MAX_TOKENS = 16_000 + + # Inbound keys we forward to the Messages API. Anything else (including + # `stream`, which the helpers set themselves) is dropped. + ALLOWED_KEYS = %i[ + model messages system max_tokens tools tool_choice thinking + stop_sequences metadata output_config service_tier + ].freeze + + # Raised when the inbound request is malformed before it reaches Anthropic + # (e.g. an unsupported model). Carries an HTTP-ish status for the controller. + class InvalidRequestError < StandardError; end + + # A single, app-wide client. `Anthropic::Client` is threadsafe and keeps its + # own connection pool, so the recommendation is one instance per process. + def self.client + @client ||= Anthropic::Client.new(api_key: api_key, max_retries: 2) + end + + # Non-streaming completion. Returns the `Anthropic::Message` response object; + # the controller serializes it with `#to_h` to forward the native shape. + def self.create(payload) + client.messages.create(**message_params(payload)) + end + + # Streaming completion. Yields each raw Messages-API stream event + # (`message_start`, `content_block_delta`, `message_stop`, …) so the controller + # can re-emit them verbatim as Server-Sent Events. + # + # Uses `stream_raw` (not the `stream` helper): the helper interleaves + # SDK-specific accumulation events (`text`, `thinking`, …) that aren't part of + # the wire protocol, whereas `stream_raw` yields only the native events — so a + # client speaking the standard Messages SSE protocol sees an unpolluted stream. + def self.stream(payload, &block) + client.messages.stream_raw(**message_params(payload)).each(&block) + end + + # Curates an inbound request hash into Messages API parameters: symbolizes + # keys, slices to the allowed set, applies defaults, and validates the model. + def self.message_params(payload) + raw = (payload || {}).deep_symbolize_keys.slice(*ALLOWED_KEYS) + + raw[:model] = resolve_model(raw[:model]) + raw[:max_tokens] = (raw[:max_tokens] || DEFAULT_MAX_TOKENS).to_i + + if raw[:messages].blank? + raise InvalidRequestError, "`messages` is required and must be a non-empty array." + end + + raw + end + + # Validates an inbound model string against the allowlist, defaulting when + # absent. Rejects unknown models rather than silently swapping them, so a + # client typo surfaces instead of quietly billing a different model. + def self.resolve_model(model) + return DEFAULT_MODEL if model.blank? + + model = model.to_s + unless ALLOWED_MODELS.include?(model) + raise InvalidRequestError, + "Unsupported model #{model.inspect}. Allowed: #{ALLOWED_MODELS.join(', ')}." + end + model + end + + def self.api_key + ENV.fetch("ANTHROPIC_API_KEY") do + raise KeyError, "Missing required env var: ANTHROPIC_API_KEY" + end + end + + private_class_method :client, :message_params, :resolve_model, :api_key +end
config/routes.rb
+1
index 9146b93..0f388c0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -70,6 +70,7 @@ Rails.application.routes.draw do get "repos", to: "repos#index" post "repos", to: "repos#create" post "git_credentials", to: "git_credentials#create" + post "messages", to: "messages#create" end end