From eed2274746377de99985229f2cc9856fe9aef90c Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 12:51:17 +0100 Subject: [PATCH] =?UTF-8?q?feat(agent):=20pairing-code=20login=20per=20RFC?= =?UTF-8?q?=20=C2=A79=20Fleet=20Mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/agentic/auth.go | 54 ++++++++++++++++++++++++ pkg/agentic/auth_test.go | 60 +++++++++++++++++++++++++++ pkg/agentic/commands_platform.go | 59 ++++++++++++++++++++++++++ pkg/agentic/commands_platform_test.go | 48 +++++++++++++++++++++ pkg/agentic/prep.go | 2 + pkg/agentic/prep_test.go | 2 + 6 files changed, 225 insertions(+) diff --git a/pkg/agentic/auth.go b/pkg/agentic/auth.go index fc44ad1..db6ee59 100644 --- a/pkg/agentic/auth.go +++ b/pkg/agentic/auth.go @@ -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") diff --git a/pkg/agentic/auth_test.go b/pkg/agentic/auth_test.go index 435c880..06b14fc 100644 --- a/pkg/agentic/auth_test.go +++ b/pkg/agentic/auth_test.go @@ -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) +} diff --git a/pkg/agentic/commands_platform.go b/pkg/agentic/commands_platform.go index fe747c1..ff616d1 100644 --- a/pkg/agentic/commands_platform.go +++ b/pkg/agentic/commands_platform.go @@ -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 { diff --git a/pkg/agentic/commands_platform_test.go b/pkg/agentic/commands_platform_test.go index e5c54ad..77d52bf 100644 --- a/pkg/agentic/commands_platform_test.go +++ b/pkg/agentic/commands_platform_test.go @@ -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"]}}`)) diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 304a028..dea76fd 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -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" diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index d11005c..2678614 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -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())