Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ Common values:
- `BRIO_RELAY_TRUSTED_PROXY_CIDRS` - comma-separated reverse-proxy networks allowed to supply `X-Forwarded-For` for rate limiting. Forwarded addresses are ignored unless the direct TCP peer matches this list.
- Production requests outside loopback require HTTPS/WSS. TLS-terminating proxies must be listed in `BRIO_RELAY_TRUSTED_PROXY_CIDRS` and send `X-Forwarded-Proto: https`; plaintext `/health` remains available for load-balancer probes.
- Current Mobile and connector clients also reject remote plaintext relay URLs and relay HTTP redirects before sending enrollment codes or long-lived credentials. Plain HTTP remains available only for explicit loopback development URLs.
- `BRIO_RELAY_ALLOWED_ORIGINS` - comma-separated browser origins allowed for relay CORS and WebSocket upgrades. Browser origins are denied when this is unset unless insecure development mode is explicitly enabled; origin-less native clients continue to work.
- `BRIO_RELAY_ALLOWED_ORIGINS` - comma-separated origins allowed for relay CORS and WebSocket upgrades. Include the relay's own public HTTPS origin for React Native Android, which sends that origin by default, plus any separate browser app origins. Origins are denied when this is unset unless insecure development mode is explicitly enabled.
- `BRIO_CLERK_SECRET_KEY` or `BRIO_CLERK_JWT_KEY` - enables verified Clerk identity on `POST /auth/devices`. Also set the exact `BRIO_CLERK_ISSUER` and `BRIO_CLERK_JWT_AUDIENCE`; `BRIO_CLERK_AUTHORIZED_PARTIES` optionally restricts the Clerk `azp` claim. The matching Mobile build uses `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`, `EXPO_PUBLIC_CLERK_JWT_TEMPLATE`, and `EXPO_PUBLIC_BRIO_RELAY_URL`.
- `BRIO_DEVICE_REGISTRATION_KEY` - operations-only fallback for `POST /auth/devices` through `Authorization: Bearer ...` or `X-Brio-Registration-Key`. With neither verified identity, this key, nor insecure development mode configured, device registration fails closed. Never embed this key in a public app build.
- `EXPO_PUBLIC_BRIO_DEV_AUTH` - exposes Mobile's unverified email form for local development. Production builds leave this unset and use the configured Clerk account flow.
Expand Down Expand Up @@ -338,8 +338,9 @@ above). The mobile app includes the same recovery flow.

## Deployment

The relay ships as a Docker image (`apps/relay/Dockerfile`), and an AWS Copilot
manifest lives under `copilot/`. No production relay URL is embedded in Mobile
or the installer: deploy the service, configure Postgres and verified identity,
then provide that HTTPS URL through `EXPO_PUBLIC_BRIO_RELAY_URL` and generated
The relay ships as a Docker image (`apps/relay/Dockerfile`). The current Oracle
deployment stack lives under `deploy/oracle`, while the AWS Copilot manifest
remains under `copilot/`. No production relay URL is embedded in Mobile or the
installer: deploy the service, configure Postgres and verified identity, then
provide its HTTPS URL through `EXPO_PUBLIC_BRIO_RELAY_URL` and generated
enrollment commands.
6 changes: 3 additions & 3 deletions apps/connect/internal/cli/service_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func installService(exe string, startNow bool) error {
return runCommand("systemctl", "--user", "enable", serviceName+".service")
}

func renderSystemdUserService(exe string, home string) string {
func renderSystemdUserService(exe string, _ string) string {
return fmt.Sprintf(`[Unit]
Description=Brio Connector
After=network-online.target
Expand All @@ -53,7 +53,7 @@ StartLimitBurst=5
[Service]
Type=simple
ExecStart=%s connect
WorkingDirectory=%s
WorkingDirectory=%%h
# Hermes CLI subprocesses share this cgroup. If the kernel kills one greedy
# child, keep the connector alive and let that request fail in isolation.
OOMPolicy=continue
Expand All @@ -62,7 +62,7 @@ RestartSec=5

[Install]
WantedBy=default.target
`, systemdQuote(exe), systemdQuote(home))
`, systemdQuote(exe))
}

func stopService() error {
Expand Down
2 changes: 1 addition & 1 deletion apps/connect/internal/cli/service_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestRenderSystemdUserServiceBoundsCrashLoopsAndChildOOMs(t *testing.T) {
"Restart=always",
"RestartSec=5",
`ExecStart="/opt/Brio Connector/brio" connect`,
`WorkingDirectory="/home/brio user"`,
`WorkingDirectory=%h`,
} {
if !strings.Contains(unit, expected) {
t.Fatalf("systemd unit omitted %q:\n%s", expected, unit)
Expand Down
13 changes: 13 additions & 0 deletions apps/connect/internal/cli/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ func runStatus(ctx context.Context, opts runOptions) error {
fmt.Printf("Relay URL: %s\n", opts.relayURL)
}

serviceState := ""
if status, err := serviceStatus(); err != nil {
fmt.Printf("Service: unknown (%v)\n", err)
} else {
serviceState = status
fmt.Printf("Service: %s\n", status)
}

Expand All @@ -58,6 +60,13 @@ func runStatus(ctx context.Context, opts runOptions) error {
fmt.Println("Tunnel: no stored credentials (run `brio setup` or `brio recover`)")
return nil
}
// A companion probe authenticates by opening a real tunnel. The relay permits
// one companion per agent, so probing while the service is active would
// replace the production tunnel and cause a visible reconnect in Mobile.
if !shouldProbeTunnel(serviceState) {
fmt.Println("Tunnel: service active (credential probe skipped)")
return nil
}
if err := tunnel.Probe(statusCtx, tunnel.Config{
AgentID: opts.agentID,
RelayURL: opts.relayURL,
Expand All @@ -70,6 +79,10 @@ func runStatus(ctx context.Context, opts runOptions) error {
return nil
}

func shouldProbeTunnel(serviceState string) bool {
return serviceState != "active"
}

func checkRelayHealth(ctx context.Context, relayURL string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, relayURL+"/health", nil)
if err != nil {
Expand Down
14 changes: 14 additions & 0 deletions apps/connect/internal/cli/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package cli

import "testing"

func TestShouldProbeTunnelSkipsActiveService(t *testing.T) {
if shouldProbeTunnel("active") {
t.Fatal("active service must not be displaced by a credential probe")
}
for _, state := range []string{"", "inactive", "failed", "unknown"} {
if !shouldProbeTunnel(state) {
t.Fatalf("service state %q should allow a credential probe", state)
}
}
}
137 changes: 137 additions & 0 deletions apps/connect/internal/hermes/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
Expand Down Expand Up @@ -78,6 +79,9 @@ const (
RouteUnknown RouteKind = iota
// RouteForward means the path is proxied to the Hermes API server.
RouteForward
// RouteControlForward means the path is proxied to the authenticated
// Hermes dashboard/control HTTP server.
RouteControlForward
// RouteLocal means the path is served from the Hermes home directory.
RouteLocal
)
Expand Down Expand Up @@ -141,8 +145,30 @@ func RoutePath(path string) Route {
return Route{Kind: RouteLocal, Path: path, Name: "composer-attachments"}
case "/api/sessions":
return Route{Kind: RouteForward, Path: path}
case "/api/sessions/search":
return Route{Kind: RouteControlForward, Path: path}
case "/api/model/options":
return Route{Kind: RouteForward, Path: path}
case "/files":
return Route{Kind: RouteControlForward, Path: "/api/files"}
case "/files/read":
return Route{Kind: RouteControlForward, Path: "/api/files/read"}
case "/files/write":
return Route{Kind: RouteControlForward, Path: "/api/files/upload"}
case "/config/raw":
return Route{Kind: RouteControlForward, Path: "/api/config/raw"}
case "/skills":
return Route{Kind: RouteControlForward, Path: "/api/skills"}
case "/tools/toolsets":
return Route{Kind: RouteControlForward, Path: "/api/tools/toolsets"}
case "/gateway/status":
return Route{Kind: RouteControlForward, Path: "/api/status"}
case "/gateway/restart":
return Route{Kind: RouteControlForward, Path: "/api/gateway/restart"}
case "/logs":
return Route{Kind: RouteControlForward, Path: "/api/logs"}
case "/jobs", "/jobs/":
return Route{Kind: RouteControlForward, Path: "/api/cron/jobs"}
}
switch {
case strings.HasPrefix(path, "/api/profiles/"):
Expand All @@ -151,13 +177,39 @@ func RoutePath(path string) Route {
return Route{Kind: RouteLocal, Path: path, Name: "composer-attachment"}
case strings.HasPrefix(path, "/v1/runs"), strings.HasPrefix(path, "/api/jobs"):
return Route{Kind: RouteForward, Path: path}
case strings.HasPrefix(path, "/tools/toolsets/"):
name := strings.TrimPrefix(path, "/tools/toolsets/")
if name != "" && !strings.Contains(name, "/") {
return Route{Kind: RouteControlForward, Path: "/api/tools/toolsets/" + name}
}
case strings.HasPrefix(path, "/jobs/"):
if mapped, ok := legacyCronJobPath(path); ok {
return Route{Kind: RouteControlForward, Path: mapped}
}
}
if isSessionMessagesPath(path) || isSessionDetailPath(path) || isSessionModelPath(path) {
return Route{Kind: RouteForward, Path: path}
}
return Route{Kind: RouteUnknown}
}

func legacyCronJobPath(path string) (string, bool) {
rest := strings.TrimPrefix(path, "/jobs/")
id, action, hasAction := strings.Cut(rest, "/")
if id == "" || strings.Contains(action, "/") {
return "", false
}
if !hasAction {
return "/api/cron/jobs/" + id, true
}
switch action {
case "pause", "resume", "trigger":
return "/api/cron/jobs/" + id + "/" + action, true
default:
return "", false
}
}

// splitProfilePrefix recognizes `/p/<name>` and `/p/<name>/...` prefixes for
// NAMED profiles. The remaining path keeps its leading slash so it can be
// routed exactly like an unprefixed request. `default` is rejected as a
Expand Down Expand Up @@ -316,13 +368,98 @@ func (c *Client) Serve(ctx context.Context, frame tunnel.Frame, emit func(tunnel
switch route.Kind {
case RouteForward:
return c.forward(ctx, frame, method, route, query, emit)
case RouteControlForward:
return c.forwardControlHTTP(ctx, frame, method, route, query, emit)
case RouteLocal:
return c.serveLocal(ctx, frame, method, route, query, emit)
default:
return emit(errorFrame(frame.ID, "NOT_FOUND", "no route for "+method+" "+frame.Path))
}
}

// forwardControlHTTP serves dashboard-only JSON endpoints that the Hermes
// gateway API does not expose. The control token is always injected locally;
// credentials supplied by Mobile are never forwarded.
func (c *Client) forwardControlHTTP(ctx context.Context, frame tunnel.Frame, method string, route Route, query string, emit func(tunnel.Frame) error) error {
requestMethod := method
requestBody := frame.Body
if route.Path == "/api/files/upload" && method == http.MethodPut {
body, ok := frame.Body.(map[string]any)
if !ok {
return emit(errorFrame(frame.ID, "BAD_REQUEST", "file write body must be an object"))
}
path, pathOK := body["path"].(string)
content, contentOK := body["content"].(string)
if !pathOK || strings.TrimSpace(path) == "" || !contentOK {
return emit(errorFrame(frame.ID, "BAD_REQUEST", "file write requires path and content"))
}
if len(content) > 1024*1024 {
return emit(errorFrame(frame.ID, "BAD_REQUEST", "file content exceeds 1 MiB"))
}
requestMethod = http.MethodPost
requestBody = map[string]any{
"path": path,
"data_url": "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte(content)),
"overwrite": true,
}
}
if strings.HasPrefix(route.Path, "/api/tools/toolsets/") && method == http.MethodPatch {
requestMethod = http.MethodPut
}
endpoint, err := c.gatewayEndpoint(route.Profile)
if err != nil {
return emit(errorFrame(frame.ID, "CONTROL_UNAVAILABLE", err.Error()))
}
target := strings.TrimRight(endpoint.BaseURL, "/") + route.Path
if query != "" {
target += "?" + query
}
var bodyReader io.Reader = http.NoBody
if requestBody != nil {
payload, marshalErr := json.Marshal(requestBody)
if marshalErr != nil {
return emit(errorFrame(frame.ID, "BAD_REQUEST", marshalErr.Error()))
}
bodyReader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, requestMethod, target, bodyReader)
if err != nil {
return emit(errorFrame(frame.ID, "BAD_REQUEST", err.Error()))
}
req.Header.Set("Accept", "application/json")
if requestBody != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+endpoint.Token)
resp, err := c.httpClient().Do(req)
if err != nil {
return emit(errorFrame(frame.ID, "LOCAL_UNREACHABLE", err.Error()))
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return emit(errorFrame(frame.ID, "LOCAL_READ_FAILED", err.Error()))
}
if len(data) > maxResponseBytes {
return emit(errorFrame(frame.ID, "RESPONSE_TOO_LARGE", "local response is larger than 10 MiB"))
}
contentType := resp.Header.Get("Content-Type")
var body any
if len(data) > 0 && strings.Contains(strings.ToLower(contentType), "json") {
_ = json.Unmarshal(data, &body)
}
if body == nil {
body = string(data)
}
return emit(tunnel.Frame{
Type: "response",
ID: frame.ID,
Status: resp.StatusCode,
Headers: map[string]string{"Content-Type": contentType},
Body: body,
})
}

func (c *Client) forward(ctx context.Context, frame tunnel.Frame, method string, route Route, query string, emit func(tunnel.Frame) error) error {
if route.Path == "/v1/responses" && method == http.MethodPost {
prepared, failure := c.prepareResponseRequest(ctx, frame.Body)
Expand Down
Loading
Loading