feat(agent): pairing-code login per RFC §9 Fleet Mode

Implements `core login CODE` — exchanges a 6-digit pairing code generated
at app.lthn.ai/device for an AgentApiKey, persisted to ~/.claude/brain.key.
Pairing code is the proof, so the POST is unauthenticated.

- auth.go: AuthLoginInput/Output DTOs + handleAuthLogin handler
- commands_platform.go: login / auth/login / agentic:login CLI commands
  with cmdAuthLogin persisting the returned key
- prep.go: registered agentic.auth.login / agent.auth.login actions
- auth_test.go / commands_platform_test.go / prep_test.go: Good/Bad/Ugly
  triads per repo convention, including key persistence verification

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-14 12:51:17 +01:00
parent 716546d0d5
commit eed2274746
6 changed files with 225 additions and 0 deletions

View file

@ -25,6 +25,23 @@ type AgentApiKey struct {
CreatedAt string `json:"created_at,omitempty"`
}
// input := agentic.AuthLoginInput{Code: "123456"}
// Login exchanges a 6-digit pairing code (generated at app.lthn.ai/device by a
// logged-in user) for an AgentApiKey. See RFC §9 Fleet Mode — bootstrap via
// `core-agent login CODE`.
type AuthLoginInput struct {
Code string `json:"code"`
}
// out := agentic.AuthLoginOutput{Success: true, Key: agentic.AgentApiKey{Prefix: "ak_abcd", Key: "ak_live_secret"}}
// The Key.Key field carries the new AgentApiKey raw value that the caller
// should persist to `~/.claude/brain.key` (or `CORE_BRAIN_KEY` env) so
// subsequent platform requests authenticate successfully.
type AuthLoginOutput struct {
Success bool `json:"success"`
Key AgentApiKey `json:"key"`
}
// input := agentic.AuthProvisionInput{OAuthUserID: "user-42", Permissions: []string{"plans:read"}, IPRestrictions: []string{"10.0.0.0/8"}}
type AuthProvisionInput struct {
OAuthUserID string `json:"oauth_user_id"`
@ -102,6 +119,43 @@ func (s *PrepSubsystem) handleAuthProvision(ctx context.Context, options core.Op
}, OK: true}
}
// result := c.Action("agentic.auth.login").Run(ctx, core.NewOptions(core.Option{Key: "code", Value: "123456"}))
// Login exchanges a 6-digit pairing code for an AgentApiKey without requiring
// a pre-existing API key. The caller is responsible for persisting the
// returned Key.Key value to `~/.claude/brain.key` (the CLI command does this
// automatically).
func (s *PrepSubsystem) handleAuthLogin(ctx context.Context, options core.Options) core.Result {
input := AuthLoginInput{
Code: optionStringValue(options, "code", "pairing_code", "pairing-code", "_arg"),
}
if input.Code == "" {
return core.Result{Value: core.E("agentic.auth.login", "code is required (6-digit pairing code)", nil), OK: false}
}
body := core.JSONMarshalString(map[string]any{"code": input.Code})
url := core.Concat(s.syncAPIURL(), "/v1/agent/auth/login")
// Login is intentionally unauthenticated — the pairing code IS the proof.
requestResult := HTTPDo(ctx, "POST", url, body, "", "")
if !requestResult.OK {
return core.Result{Value: platformResultError("agentic.auth.login", requestResult), OK: false}
}
var payload map[string]any
parseResult := core.JSONUnmarshalString(requestResult.Value.(string), &payload)
if !parseResult.OK {
err, _ := parseResult.Value.(error)
return core.Result{Value: core.E("agentic.auth.login", "failed to parse platform response", err), OK: false}
}
key := parseAgentApiKey(payloadResourceMap(payload, "key", "api_key", "agent_api_key"))
if key.Key == "" {
return core.Result{Value: core.E("agentic.auth.login", "platform did not return an api key", nil), OK: false}
}
return core.Result{Value: AuthLoginOutput{Success: true, Key: key}, OK: true}
}
// result := c.Action("agentic.auth.revoke").Run(ctx, core.NewOptions(core.Option{Key: "key_id", Value: "7"}))
func (s *PrepSubsystem) handleAuthRevoke(ctx context.Context, options core.Options) core.Result {
keyID := optionStringValue(options, "key_id", "key-id", "_arg")

View file

@ -132,3 +132,63 @@ func TestAuth_HandleAuthRevoke_Ugly(t *testing.T) {
assert.Equal(t, "7", output.KeyID)
assert.True(t, output.Revoked)
}
func TestAuth_HandleAuthLogin_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/agent/auth/login", r.URL.Path)
require.Equal(t, http.MethodPost, r.Method)
// Login is unauthenticated — pairing code is the proof.
require.Equal(t, "", r.Header.Get("Authorization"))
bodyResult := core.ReadAll(r.Body)
require.True(t, bodyResult.OK)
var payload map[string]any
parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload)
require.True(t, parseResult.OK)
require.Equal(t, "123456", payload["code"])
_, _ = w.Write([]byte(`{"data":{"key":{"id":11,"name":"charon","key":"ak_live_abcdef","prefix":"ak_live","permissions":["fleet:run"],"expires_at":"2027-01-01T00:00:00Z"}}}`))
}))
defer server.Close()
subsystem := testPrepWithPlatformServer(t, server, "")
subsystem.brainURL = server.URL
subsystem.brainKey = ""
result := subsystem.handleAuthLogin(context.Background(), core.NewOptions(
core.Option{Key: "code", Value: "123456"},
))
require.True(t, result.OK)
output, ok := result.Value.(AuthLoginOutput)
require.True(t, ok)
assert.True(t, output.Success)
assert.Equal(t, 11, output.Key.ID)
assert.Equal(t, "ak_live_abcdef", output.Key.Key)
assert.Equal(t, "ak_live", output.Key.Prefix)
assert.Equal(t, []string{"fleet:run"}, output.Key.Permissions)
}
func TestAuth_HandleAuthLogin_Bad(t *testing.T) {
subsystem := testPrepWithPlatformServer(t, nil, "")
result := subsystem.handleAuthLogin(context.Background(), core.NewOptions())
assert.False(t, result.OK)
}
func TestAuth_HandleAuthLogin_Ugly(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Server returns a malformed payload: missing key field entirely.
_, _ = w.Write([]byte(`{"data":{}}`))
}))
defer server.Close()
subsystem := testPrepWithPlatformServer(t, server, "")
subsystem.brainURL = server.URL
subsystem.brainKey = ""
result := subsystem.handleAuthLogin(context.Background(), core.NewOptions(
core.Option{Key: "code", Value: "999999"},
))
assert.False(t, result.OK)
}

View file

@ -18,6 +18,10 @@ func (s *PrepSubsystem) registerPlatformCommands() {
c.Command("agentic:auth/provision", core.Command{Description: "Provision a platform API key for an authenticated agent user", Action: s.cmdAuthProvision})
c.Command("auth/revoke", core.Command{Description: "Revoke a platform API key", Action: s.cmdAuthRevoke})
c.Command("agentic:auth/revoke", core.Command{Description: "Revoke a platform API key", Action: s.cmdAuthRevoke})
c.Command("login", core.Command{Description: "Exchange a 6-digit pairing code (from app.lthn.ai/device) for an AgentApiKey", Action: s.cmdAuthLogin})
c.Command("auth/login", core.Command{Description: "Exchange a 6-digit pairing code (from app.lthn.ai/device) for an AgentApiKey", Action: s.cmdAuthLogin})
c.Command("agentic:login", core.Command{Description: "Exchange a 6-digit pairing code (from app.lthn.ai/device) for an AgentApiKey", Action: s.cmdAuthLogin})
c.Command("agentic:auth/login", core.Command{Description: "Exchange a 6-digit pairing code (from app.lthn.ai/device) for an AgentApiKey", Action: s.cmdAuthLogin})
c.Command("message/send", core.Command{Description: "Send a direct message to another agent", Action: s.cmdMessageSend})
c.Command("messages/send", core.Command{Description: "Send a direct message to another agent", Action: s.cmdMessageSend})
c.Command("agentic:message/send", core.Command{Description: "Send a direct message to another agent", Action: s.cmdMessageSend})
@ -129,6 +133,61 @@ func (s *PrepSubsystem) cmdAuthRevoke(options core.Options) core.Result {
return core.Result{OK: true}
}
// cmdAuthLogin exchanges a 6-digit pairing code generated at
// `app.lthn.ai/device` for an AgentApiKey and persists the raw key to
// `~/.claude/brain.key` so subsequent platform calls authenticate
// automatically. This is RFC §9 Fleet Mode bootstrap.
//
// Usage: `core-agent login 123456`
// Usage: `core-agent login --code=123456`
func (s *PrepSubsystem) cmdAuthLogin(options core.Options) core.Result {
if optionStringValue(options, "code", "pairing_code", "pairing-code", "_arg") == "" {
core.Print(nil, "usage: core-agent login <6-digit-code>")
core.Print(nil, " generate a pairing code at app.lthn.ai/device first")
return core.Result{Value: core.E("agentic.cmdAuthLogin", "pairing code is required", nil), OK: false}
}
result := s.handleAuthLogin(s.commandContext(), options)
if !result.OK {
err := commandResultError("agentic.cmdAuthLogin", result)
core.Print(nil, "error: %v", err)
return core.Result{Value: err, OK: false}
}
output, ok := result.Value.(AuthLoginOutput)
if !ok {
err := core.E("agentic.cmdAuthLogin", "invalid auth login output", nil)
core.Print(nil, "error: %v", err)
return core.Result{Value: err, OK: false}
}
// Persist the raw key so the agent authenticates on the next invocation.
keyPath := core.JoinPath(HomeDir(), ".claude", "brain.key")
if r := fs.EnsureDir(core.PathDir(keyPath)); !r.OK {
core.Print(nil, "warning: could not create %s — key not persisted", core.PathDir(keyPath))
} else if r := fs.Write(keyPath, output.Key.Key); !r.OK {
core.Print(nil, "warning: could not write %s — key not persisted", keyPath)
} else {
s.brainKey = output.Key.Key
}
core.Print(nil, "logged in")
if output.Key.Prefix != "" {
core.Print(nil, "key prefix: %s", output.Key.Prefix)
}
if output.Key.Name != "" {
core.Print(nil, "name: %s", output.Key.Name)
}
if output.Key.ExpiresAt != "" {
core.Print(nil, "expires: %s", output.Key.ExpiresAt)
}
if len(output.Key.Permissions) > 0 {
core.Print(nil, "permissions: %s", core.Join(",", output.Key.Permissions...))
}
core.Print(nil, "saved to: %s", keyPath)
return core.Result{OK: true}
}
func (s *PrepSubsystem) cmdSyncPush(options core.Options) core.Result {
result := s.handleSyncPush(s.commandContext(), options)
if !result.OK {

View file

@ -152,6 +152,54 @@ func TestCommandsplatform_CmdSyncStatus_Good(t *testing.T) {
assert.Contains(t, output, "status: online")
}
func TestCommandsplatform_CmdAuthLogin_Bad(t *testing.T) {
subsystem := testPrepWithPlatformServer(t, nil, "")
result := subsystem.cmdAuthLogin(core.NewOptions())
assert.False(t, result.OK)
}
func TestCommandsplatform_CmdAuthLogin_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/agent/auth/login", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "", r.Header.Get("Authorization"))
bodyResult := core.ReadAll(r.Body)
assert.True(t, bodyResult.OK)
var payload map[string]any
parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload)
assert.True(t, parseResult.OK)
assert.Equal(t, "654321", payload["code"])
_, _ = w.Write([]byte(`{"data":{"key":{"id":42,"name":"charon","key":"ak_live_xyz","prefix":"ak_live","expires_at":"2027-01-01T00:00:00Z"}}}`))
}))
defer server.Close()
// Pin HOME to a temp dir so we do not overwrite a real ~/.claude/brain.key.
homeDir := t.TempDir()
t.Setenv("CORE_HOME", homeDir)
subsystem := testPrepWithPlatformServer(t, server, "")
subsystem.brainURL = server.URL
subsystem.brainKey = ""
output := captureStdout(t, func() {
result := subsystem.cmdAuthLogin(core.NewOptions(core.Option{Key: "_arg", Value: "654321"}))
assert.True(t, result.OK)
})
assert.Contains(t, output, "logged in")
assert.Contains(t, output, "key prefix: ak_live")
assert.Contains(t, output, "saved to:")
// Verify the key was persisted so the next dispatch authenticates.
keyPath := core.JoinPath(homeDir, ".claude", "brain.key")
readResult := fs.Read(keyPath)
assert.True(t, readResult.OK)
assert.Equal(t, "ak_live_xyz", core.Trim(readResult.Value.(string)))
}
func TestCommandsplatform_CmdSubscriptionDetect_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":{"providers":{"claude":true},"available":["claude"]}}`))

View file

@ -132,6 +132,8 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result {
c.Action("agent.auth.provision", s.handleAuthProvision).Description = "Provision a platform API key for an authenticated agent user"
c.Action("agentic.auth.revoke", s.handleAuthRevoke).Description = "Revoke a platform API key"
c.Action("agent.auth.revoke", s.handleAuthRevoke).Description = "Revoke a platform API key"
c.Action("agentic.auth.login", s.handleAuthLogin).Description = "Exchange a 6-digit pairing code for an AgentApiKey"
c.Action("agent.auth.login", s.handleAuthLogin).Description = "Exchange a 6-digit pairing code for an AgentApiKey"
c.Action("agentic.fleet.register", s.handleFleetRegister).Description = "Register a fleet node with the platform API"
c.Action("agent.fleet.register", s.handleFleetRegister).Description = "Register a fleet node with the platform API"
c.Action("agentic.fleet.heartbeat", s.handleFleetHeartbeat).Description = "Send a heartbeat for a fleet node"

View file

@ -649,6 +649,8 @@ func TestPrep_OnStartup_Good_RegistersPlatformActionAliases(t *testing.T) {
assert.True(t, c.Action("agent.auth.provision").Exists())
assert.True(t, c.Action("agentic.auth.revoke").Exists())
assert.True(t, c.Action("agent.auth.revoke").Exists())
assert.True(t, c.Action("agentic.auth.login").Exists())
assert.True(t, c.Action("agent.auth.login").Exists())
assert.True(t, c.Action("agentic.fleet.register").Exists())
assert.True(t, c.Action("agent.fleet.register").Exists())
assert.True(t, c.Action("agentic.credits.balance").Exists())