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