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