Skip to content
Open
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
20 changes: 11 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ go build -o squadron ./cmd/cli # Build the CLI
./squadron vars set <name> <value> # Set a variable
./squadron vars get <name> # Get a variable
./squadron vars list # List all variables
./squadron serve -c <path> # Connect to command center (requires command_center block)
./squadron serve -c <path> -w # Launch local command center + connect
./squadron serve -c <path> -w --cc-port 9090 # Custom command center port
./squadron serve -c <path> -w --no-browser # Launch without opening browser
./squadron engage -c <path> # Connect to command center (requires command_center block); daemonizes by default
./squadron engage -c <path> --cc # Launch local command center UI + connect
./squadron engage -c <path> --cc --cc-port 9090 # Custom command center port
./squadron engage -c <path> --foreground # Run in terminal instead of daemonizing (useful for dev / scripts)
./squadron disengage # Stop the running daemon and remove the system service
./squadron mcp status # Show OAuth status for configured MCP servers
./squadron mcp login <name> # Authorize an MCP server via OAuth
./squadron mcp logout <name> # Forget stored OAuth token for an MCP server
Expand Down Expand Up @@ -182,7 +183,7 @@ mission "example" {

### Schedules, Triggers, and Concurrency

Missions can run automatically via schedules (cron-based timers) or triggers (webhooks). Both are defined inside the `mission` block and are active only in serve mode.
Missions can run automatically via schedules (cron-based timers) or triggers (webhooks). Both are defined inside the `mission` block and are active only in engage mode.

#### Schedule Block

Expand Down Expand Up @@ -244,7 +245,7 @@ The command center registers the route `POST /webhooks/<instance_name>/<webhook_

#### Architecture

The scheduler lives in `scheduler/` but its lifecycle (creation, config updates, shutdown) is managed by `cmd/serve.go`, not wsbridge. The wsbridge client receives a `ConcurrencyTracker` interface for enforcing `max_parallel` on all mission starts. The cron library used is `robfig/cron/v3`.
The scheduler lives in `scheduler/` but its lifecycle (creation, config updates, shutdown) is managed by `cmd/engage.go`, not wsbridge. The wsbridge client receives a `ConcurrencyTracker` interface for enforcing `max_parallel` on all mission starts. The cron library used is `robfig/cron/v3`.

### Mission-Scoped Agents

Expand Down Expand Up @@ -568,8 +569,9 @@ If the server returns 401 on load, Squadron surfaces an `AuthRequiredError` with
The `mcp/oauth/` package houses:
- `VaultTokenStore` — implements `transport.TokenStore` against the vault
- `RunLoginFlow` — the orchestrator (discovery, DCR, PKCE, browser, exchange)
- `LoopbackCallbackSource` — serves `/callback` on `127.0.0.1:0` for CLI mode
- `CallbackSource` interface — Phase 2 adds a wsbridge-backed source for command-center mode
- `CallbackSource` interface — pluggable callback delivery
- `LoopbackCallbackSource` — serves `/callback` on `127.0.0.1:0` for standalone CLI mode
- `WsbridgeCallbackSource` — routes the callback through command center so squadrons behind NAT or remote hosts can still do OAuth, and HTTPS-only IdPs have a stable callback URL. Used automatically inside `squadron engage` when a `command_center` block is configured. The redirect URI is fixed at `<command_center.host>/oauth/callback`; routing uses the OAuth `state` value.

SSE vs streamable HTTP is auto-detected from the URL path suffix (`/sse`). The OAuth transport is only engaged when a token is already stored — anonymous servers fall through to the plain client.

Expand Down Expand Up @@ -784,7 +786,7 @@ When `--resume <missionID>` is used:

Variables are encrypted at rest in `.squadron/vars.vault` using AES-256-GCM with an Argon2id-derived key. The encryption passphrase is stored in the OS keychain (macOS Keychain, Linux Secret Service/KeyCtl, Windows WinCred).

Run `squadron init` before using vars commands. Commands `serve`, `chat`, and `mission` require init (or pass `--init` to auto-initialize).
Run `squadron init` before using vars commands. Commands `engage`, `chat`, and `mission` require init (or pass `--init` to auto-initialize).

Passphrase resolution order: in-process cache → `--passphrase-file` flag → `/run/secrets/vault_passphrase` (Docker) → OS keyring → hardcoded fallback (with warning).

Expand Down
11 changes: 8 additions & 3 deletions cmd/engage.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,10 @@ func runEngage(cmd *cobra.Command, args []string) {
}
client := wsbridge.NewClient(cfg, cfgErr == nil, cfgErrMsg, engageConfigPath, stores, Version)

// Enable commander-initiated MCP OAuth logins. Safe even when no command
// center is configured — the hook only fires on incoming WS requests.
installOAuthProxyHook()

sched := scheduler.New(client.RunScheduledMission)
client.SetConcurrencyTracker(sched)
if cfgErr == nil {
Expand Down Expand Up @@ -341,11 +345,12 @@ func runEngage(cmd *cobra.Command, args []string) {
fmt.Printf("Squadron ready — http://localhost:%d\n", ccPort)
}
} else if cfg.CommandCenter != nil {
if err := connectWithRetry(client, cfg.CommandCenter.URL, cfg.CommandCenter.AutoReconnect); err != nil {
wsURL := cfg.CommandCenter.WebSocketURL()
if err := connectWithRetry(client, wsURL, cfg.CommandCenter.AutoReconnect); err != nil {
log.Printf("Connection failed: %v (will retry when config changes)", err)
} else {
fmt.Printf("Squadron ready — connected to %s (instance: %s)\n",
cfg.CommandCenter.URL, cfg.CommandCenter.InstanceName)
wsURL, cfg.CommandCenter.InstanceName)
}
}

Expand All @@ -369,7 +374,7 @@ func runEngage(cmd *cobra.Command, args []string) {
cfg := client.GetConfig()
if cfg != nil && cfg.CommandCenter != nil && cfg.CommandCenter.AutoReconnect {
log.Println("Attempting to reconnect...")
if err := connectWithRetry(client, cfg.CommandCenter.URL, cfg.CommandCenter.AutoReconnect); err != nil {
if err := connectWithRetry(client, cfg.CommandCenter.WebSocketURL(), cfg.CommandCenter.AutoReconnect); err != nil {
log.Printf("Reconnect failed: %v", err)
// Don't exit — wait for config changes
go watchForConfigChanges(client, engageConfigPath)
Expand Down
8 changes: 4 additions & 4 deletions cmd/engage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,22 +59,22 @@ func TestConfigHasCommandCenter(t *testing.T) {
}{
{
name: "declared",
content: `command_center {` + "\n url = \"ws://example.com/ws\"\n}\n",
content: `command_center {` + "\n host = \"https://example.com\"\n}\n",
want: true,
},
{
name: "indented declared",
content: `
command_center {
url = "ws://example.com/ws"
host = "https://example.com"
}
`,
want: true,
},
{
name: "hash commented out",
content: `# command_center {
# url = "ws://example.com/ws"
# host = "https://example.com"
# }
variable "x" { secret = true }
`,
Expand All @@ -83,7 +83,7 @@ variable "x" { secret = true }
{
name: "slash commented out",
content: `// command_center {
// url = "ws://example.com/ws"
// host = "https://example.com"
// }
`,
want: false,
Expand Down
118 changes: 118 additions & 0 deletions cmd/oauth_hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package cmd

import (
"context"
"fmt"

"github.com/mlund01/squadron-wire/protocol"

"squadron/config"
"squadron/mcp/oauth"
"squadron/wsbridge"
)

// installOAuthProxyHook wires wsbridge.StartMCPLoginHook so commander-initiated
// MCP login requests can kick off the OAuth flow inside this running engage
// process, using the WsbridgeCallbackSource to route the callback back
// through command center.
//
// The hook returns the authorization URL synchronously; the rest of the
// login (code exchange + token persistence) runs in the background. The
// browser that started the flow is expected to open the URL in a new tab.
func installOAuthProxyHook() {
wsbridge.StartMCPLoginHook = runWSBridgeLogin
}

// wsbridgeClientAdapter bridges wsbridge.Client to oauth.WSCaller by
// translating wsbridge.OAuthCallback ↔ oauth.WSCallback.
type wsbridgeClientAdapter struct{ c *wsbridge.Client }

func (a wsbridgeClientAdapter) SendRequest(env *protocol.Envelope) (*protocol.Envelope, error) {
return a.c.SendRequest(env)
}

func (a wsbridgeClientAdapter) RegisterOAuthListener(state string, ch chan<- oauth.WSCallback) func() {
// We bridge the channels: wsbridge delivers wsbridge.OAuthCallback, but
// oauth expects oauth.WSCallback (identical shape, different package).
relay := make(chan wsbridge.OAuthCallback, 1)
cancel := a.c.RegisterOAuthListener(state, relay)
done := make(chan struct{})
go func() {
defer close(done)
select {
case cb, ok := <-relay:
if !ok {
return
}
ch <- oauth.WSCallback{Code: cb.Code, State: cb.State, Error: cb.Error}
}
}()
return func() {
cancel()
<-done
}
}

func runWSBridgeLogin(ctx context.Context, client *wsbridge.Client, mcpName string) (string, error) {
cfg := client.GetConfig()
if cfg == nil || cfg.CommandCenter == nil {
return "", fmt.Errorf("command_center not configured")
}
if cfg.CommandCenter.Host == "" {
return "", fmt.Errorf("command_center.host is empty")
}

// Look up the MCP spec by name from the currently loaded config.
var spec *config.MCPServer
for i := range cfg.MCPServers {
if cfg.MCPServers[i].Name == mcpName {
spec = &cfg.MCPServers[i]
break
}
}
if spec == nil {
return "", fmt.Errorf("mcp %q: not found in config", mcpName)
}
if spec.URL == "" {
return "", fmt.Errorf("mcp %q: OAuth only applies to HTTP (url) servers", mcpName)
}

urlCh := make(chan string, 1)
errCh := make(chan error, 1)

source := oauth.NewWsbridgeCallbackSource(
wsbridgeClientAdapter{c: client},
mcpName,
cfg.CommandCenter.OAuthRedirectURI(),
)
// Instead of opening a browser inside this process, ferry the auth URL
// back to the waiting request so commander can return it to the browser
// that initiated the login.
source.AuthURLHook = func(authURL string) {
select {
case urlCh <- authURL:
default:
}
}

// Run the login flow in the background. The flow blocks in Wait() until
// commander delivers the callback.
go func() {
err := oauth.RunLoginFlow(context.Background(), mcpName, spec.URL, source)
if err != nil {
select {
case errCh <- err:
default:
}
}
}()

select {
case u := <-urlCh:
return u, nil
case err := <-errCh:
return "", err
case <-ctx.Done():
return "", ctx.Err()
}
}
78 changes: 69 additions & 9 deletions config/command_center.go
Original file line number Diff line number Diff line change
@@ -1,30 +1,90 @@
package config

import "fmt"
import (
"fmt"
"net/url"
"strings"
)

// CommandCenterConfig defines connection settings for a command center server.
// If no command_center block is present in config, squadron operates standalone.
//
// Host is a base URL — just the scheme + domain (and optional path prefix).
// Squadron derives the WebSocket URL by swapping http(s)→ws(s) and appending
// "/ws", and the OAuth redirect URI by preserving the scheme and appending
// "/oauth/callback". Users may include a path prefix on Host if they map
// command center behind one (e.g. "https://foo.com/commander").
type CommandCenterConfig struct {
URL string `hcl:"url,optional"`
InstanceName string `hcl:"instance_name,optional"`
AutoReconnect bool `hcl:"auto_reconnect,optional"`
ReconnectInterval int `hcl:"reconnect_interval,optional"` // seconds
Host string `hcl:"host,optional"`
InstanceName string `hcl:"instance_name,optional"`
AutoReconnect bool `hcl:"auto_reconnect,optional"`
ReconnectInterval int `hcl:"reconnect_interval,optional"` // seconds

// Deprecated: use Host. Present only so we can detect legacy configs and
// emit a migration error. Squadron will refuse to start if this is set.
URL string `hcl:"url,optional"`
}

// Defaults fills in default values for unset fields
// Defaults fills in default values for unset fields.
func (c *CommandCenterConfig) Defaults() {
if c.ReconnectInterval <= 0 {
c.ReconnectInterval = 5
}
}

// Validate checks that required fields are set
// Validate checks that required fields are set.
func (c *CommandCenterConfig) Validate() error {
if c.URL == "" {
return fmt.Errorf("url is required")
if c.URL != "" {
return fmt.Errorf("command_center.url is deprecated; use host instead (e.g. host = \"https://mycommander.com\")")
}
if c.Host == "" {
return fmt.Errorf("host is required")
}
u, err := url.Parse(c.Host)
if err != nil {
return fmt.Errorf("host is not a valid URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("host must use http or https scheme (got %q)", u.Scheme)
}
if u.Host == "" {
return fmt.Errorf("host must include a hostname (got %q)", c.Host)
}
if c.InstanceName == "" {
return fmt.Errorf("instance_name is required")
}
return nil
}

// trimmedHost returns Host with a single trailing slash removed so suffix
// appends always produce a single delimiter.
func (c *CommandCenterConfig) trimmedHost() string {
return strings.TrimSuffix(c.Host, "/")
}

// WebSocketURL returns the full WebSocket URL derived from Host by swapping
// http→ws and https→wss, then appending "/ws".
//
// "https://foo.com" → "wss://foo.com/ws"
// "https://foo.com/commander" → "wss://foo.com/commander/ws"
// "http://localhost:8080" → "ws://localhost:8080/ws"
func (c *CommandCenterConfig) WebSocketURL() string {
h := c.trimmedHost()
switch {
case strings.HasPrefix(h, "https://"):
return "wss://" + strings.TrimPrefix(h, "https://") + "/ws"
case strings.HasPrefix(h, "http://"):
return "ws://" + strings.TrimPrefix(h, "http://") + "/ws"
default:
return h + "/ws"
}
}

// OAuthRedirectURI returns the OAuth callback URL by preserving the Host
// scheme and appending "/oauth/callback".
//
// "https://foo.com" → "https://foo.com/oauth/callback"
// "https://foo.com/commander" → "https://foo.com/commander/oauth/callback"
func (c *CommandCenterConfig) OAuthRedirectURI() string {
return c.trimmedHost() + "/oauth/callback"
}
Loading