sigit code cloud init

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