diff --git a/pkg/agentic/auth.go b/pkg/agentic/auth.go new file mode 100644 index 0000000..b301904 --- /dev/null +++ b/pkg/agentic/auth.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + + core "dappco.re/go/core" +) + +// key := agentic.AgentAPIKey{ID: 7, Name: "codex local", Prefix: "ak_abcd", RateLimit: 60} +type AgentAPIKey struct { + ID int `json:"id"` + WorkspaceID int `json:"workspace_id,omitempty"` + Name string `json:"name,omitempty"` + Key string `json:"key,omitempty"` + Prefix string `json:"prefix,omitempty"` + Permissions []string `json:"permissions,omitempty"` + RateLimit int `json:"rate_limit,omitempty"` + CallCount int `json:"call_count,omitempty"` + LastUsedAt string `json:"last_used_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + RevokedAt string `json:"revoked_at,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +// input := agentic.AuthProvisionInput{OAuthUserID: "user-42", Permissions: []string{"plans:read"}} +type AuthProvisionInput struct { + OAuthUserID string `json:"oauth_user_id"` + Name string `json:"name,omitempty"` + Permissions []string `json:"permissions,omitempty"` + RateLimit int `json:"rate_limit,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +// out := agentic.AuthProvisionOutput{Success: true, Key: agentic.AgentAPIKey{Prefix: "ak_abcd"}} +type AuthProvisionOutput struct { + Success bool `json:"success"` + Key AgentAPIKey `json:"key"` +} + +// input := agentic.AuthRevokeInput{KeyID: "7"} +type AuthRevokeInput struct { + KeyID string `json:"key_id"` +} + +// out := agentic.AuthRevokeOutput{Success: true, KeyID: "7", Revoked: true} +type AuthRevokeOutput struct { + Success bool `json:"success"` + KeyID string `json:"key_id"` + Revoked bool `json:"revoked"` +} + +// result := c.Action("agentic.auth.provision").Run(ctx, core.NewOptions( +// +// core.Option{Key: "oauth_user_id", Value: "user-42"}, +// core.Option{Key: "permissions", Value: "plans:read,plans:write"}, +// +// )) +func (s *PrepSubsystem) handleAuthProvision(ctx context.Context, options core.Options) core.Result { + input := AuthProvisionInput{ + OAuthUserID: optionStringValue(options, "oauth_user_id", "oauth-user-id", "user_id", "user-id", "_arg"), + Name: optionStringValue(options, "name"), + Permissions: optionStringSliceValue(options, "permissions"), + RateLimit: optionIntValue(options, "rate_limit", "rate-limit"), + ExpiresAt: optionStringValue(options, "expires_at", "expires-at"), + } + if input.OAuthUserID == "" { + return core.Result{Value: core.E("agentic.auth.provision", "oauth_user_id is required", nil), OK: false} + } + + body := map[string]any{ + "oauth_user_id": input.OAuthUserID, + } + if input.Name != "" { + body["name"] = input.Name + } + if len(input.Permissions) > 0 { + body["permissions"] = input.Permissions + } + if input.RateLimit > 0 { + body["rate_limit"] = input.RateLimit + } + if input.ExpiresAt != "" { + body["expires_at"] = input.ExpiresAt + } + + result := s.platformPayload(ctx, "agentic.auth.provision", "POST", "/v1/agent/auth/provision", body) + if !result.OK { + return result + } + + return core.Result{Value: AuthProvisionOutput{ + Success: true, + Key: parseAgentAPIKey(payloadResourceMap(result.Value.(map[string]any), "key", "api_key", "agent_api_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") + if keyID == "" { + return core.Result{Value: core.E("agentic.auth.revoke", "key_id is required", nil), OK: false} + } + + path := core.Concat("/v1/agent/auth/revoke/", keyID) + result := s.platformPayload(ctx, "agentic.auth.revoke", "DELETE", path, nil) + if !result.OK { + return result + } + + output := AuthRevokeOutput{ + Success: true, + KeyID: keyID, + Revoked: true, + } + + payload, ok := result.Value.(map[string]any) + if !ok { + return core.Result{Value: output, OK: true} + } + + if data := payloadResourceMap(payload, "result", "revocation"); len(data) > 0 { + if value := stringValue(data["key_id"]); value != "" { + output.KeyID = value + } + if value, ok := boolValueOK(data["revoked"]); ok { + output.Revoked = value + } + if value, ok := boolValueOK(data["success"]); ok { + output.Success = value + } + return core.Result{Value: output, OK: output.Success && output.Revoked} + } + + if data, exists := payload["data"]; exists { + if value, ok := boolValueOK(data); ok { + output.Revoked = value + return core.Result{Value: output, OK: output.Success && output.Revoked} + } + } + + return core.Result{Value: output, OK: true} +} + +func parseAgentAPIKey(values map[string]any) AgentAPIKey { + return AgentAPIKey{ + ID: intValue(values["id"]), + WorkspaceID: intValue(values["workspace_id"]), + Name: stringValue(values["name"]), + Key: stringValue(values["key"]), + Prefix: stringValue(values["prefix"]), + Permissions: listValue(values["permissions"]), + RateLimit: intValue(values["rate_limit"]), + CallCount: intValue(values["call_count"]), + LastUsedAt: stringValue(values["last_used_at"]), + ExpiresAt: stringValue(values["expires_at"]), + RevokedAt: stringValue(values["revoked_at"]), + CreatedAt: stringValue(values["created_at"]), + } +} + +func boolValueOK(value any) (bool, bool) { + switch typed := value.(type) { + case bool: + return typed, true + case string: + trimmed := core.Lower(core.Trim(typed)) + switch trimmed { + case "true", "1", "yes": + return true, true + case "false", "0", "no": + return false, true + } + case int: + return typed != 0, true + case int64: + return typed != 0, true + case float64: + return typed != 0, true + } + return false, false +} diff --git a/pkg/agentic/auth_example_test.go b/pkg/agentic/auth_example_test.go new file mode 100644 index 0000000..36e3895 --- /dev/null +++ b/pkg/agentic/auth_example_test.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import "fmt" + +func Example_parseAgentAPIKey() { + key := parseAgentAPIKey(map[string]any{ + "id": 7, + "name": "codex local", + "prefix": "ak_live", + "permissions": []any{"plans:read", "plans:write"}, + }) + + fmt.Println(key.ID, key.Name, key.Prefix, len(key.Permissions)) + // Output: 7 codex local ak_live 2 +} diff --git a/pkg/agentic/auth_test.go b/pkg/agentic/auth_test.go new file mode 100644 index 0000000..a7a0704 --- /dev/null +++ b/pkg/agentic/auth_test.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuth_HandleAuthProvision_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/agent/auth/provision", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "Bearer secret-token", 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, "user-42", payload["oauth_user_id"]) + require.Equal(t, "codex local", payload["name"]) + require.Equal(t, float64(60), payload["rate_limit"]) + require.Equal(t, "2026-04-01T00:00:00Z", payload["expires_at"]) + + permissions, ok := payload["permissions"].([]any) + require.True(t, ok) + require.Equal(t, []any{"plans:read", "plans:write"}, permissions) + + _, _ = w.Write([]byte(`{"data":{"id":7,"workspace_id":3,"name":"codex local","key":"ak_live_secret","prefix":"ak_live","permissions":["plans:read","plans:write"],"rate_limit":60,"call_count":2,"expires_at":"2026-04-01T00:00:00Z"}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleAuthProvision(context.Background(), core.NewOptions( + core.Option{Key: "oauth_user_id", Value: "user-42"}, + core.Option{Key: "name", Value: "codex local"}, + core.Option{Key: "permissions", Value: "plans:read,plans:write"}, + core.Option{Key: "rate_limit", Value: 60}, + core.Option{Key: "expires_at", Value: "2026-04-01T00:00:00Z"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(AuthProvisionOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, 7, output.Key.ID) + assert.Equal(t, 3, output.Key.WorkspaceID) + assert.Equal(t, "codex local", output.Key.Name) + assert.Equal(t, "ak_live_secret", output.Key.Key) + assert.Equal(t, "ak_live", output.Key.Prefix) + assert.Equal(t, []string{"plans:read", "plans:write"}, output.Key.Permissions) + assert.Equal(t, 60, output.Key.RateLimit) + assert.Equal(t, 2, output.Key.CallCount) +} + +func TestAuth_HandleAuthProvision_Bad(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + result := subsystem.handleAuthProvision(context.Background(), core.NewOptions()) + assert.False(t, result.OK) +} + +func TestAuth_HandleAuthProvision_Ugly(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{broken json`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleAuthProvision(context.Background(), core.NewOptions( + core.Option{Key: "oauth_user_id", Value: "user-42"}, + )) + assert.False(t, result.OK) +} + +func TestAuth_HandleAuthRevoke_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/agent/auth/revoke/7", r.URL.Path) + require.Equal(t, http.MethodDelete, r.Method) + require.Equal(t, "Bearer secret-token", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"data":{"key_id":"7","revoked":true}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleAuthRevoke(context.Background(), core.NewOptions( + core.Option{Key: "key_id", Value: "7"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(AuthRevokeOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, "7", output.KeyID) + assert.True(t, output.Revoked) +} + +func TestAuth_HandleAuthRevoke_Bad(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + result := subsystem.handleAuthRevoke(context.Background(), core.NewOptions()) + assert.False(t, result.OK) +} + +func TestAuth_HandleAuthRevoke_Ugly(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":true}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleAuthRevoke(context.Background(), core.NewOptions( + core.Option{Key: "key_id", Value: "7"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(AuthRevokeOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, "7", output.KeyID) + assert.True(t, output.Revoked) +} diff --git a/pkg/agentic/commands_platform.go b/pkg/agentic/commands_platform.go index b31bf95..7f0b061 100644 --- a/pkg/agentic/commands_platform.go +++ b/pkg/agentic/commands_platform.go @@ -11,6 +11,8 @@ func (s *PrepSubsystem) registerPlatformCommands() { c.Command("sync/push", core.Command{Description: "Push completed dispatch state to the platform API", Action: s.cmdSyncPush}) c.Command("sync/pull", core.Command{Description: "Pull shared fleet context from the platform API", Action: s.cmdSyncPull}) c.Command("sync/status", core.Command{Description: "Show platform sync status for the current or named agent", Action: s.cmdSyncStatus}) + c.Command("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("fleet/register", core.Command{Description: "Register a fleet node with the platform API", Action: s.cmdFleetRegister}) c.Command("fleet/heartbeat", core.Command{Description: "Send a heartbeat for a registered fleet node", Action: s.cmdFleetHeartbeat}) @@ -31,6 +33,65 @@ func (s *PrepSubsystem) registerPlatformCommands() { c.Command("subscription/update-budget", core.Command{Description: "Update compute budget for a fleet node", Action: s.cmdSubscriptionUpdateBudget}) } +func (s *PrepSubsystem) cmdAuthProvision(options core.Options) core.Result { + if optionStringValue(options, "oauth_user_id", "oauth-user-id", "user_id", "user-id", "_arg") == "" { + core.Print(nil, "usage: core-agent auth provision [--name=codex] [--permissions=plans:read,plans:write] [--rate-limit=60] [--expires-at=2026-04-01T00:00:00Z]") + return core.Result{Value: core.E("agentic.cmdAuthProvision", "oauth_user_id is required", nil), OK: false} + } + + result := s.handleAuthProvision(s.commandContext(), options) + if !result.OK { + err := commandResultError("agentic.cmdAuthProvision", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(AuthProvisionOutput) + if !ok { + err := core.E("agentic.cmdAuthProvision", "invalid auth provision output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "key id: %d", output.Key.ID) + core.Print(nil, "name: %s", output.Key.Name) + core.Print(nil, "prefix: %s", output.Key.Prefix) + if output.Key.Key != "" { + core.Print(nil, "key: %s", output.Key.Key) + } + if len(output.Key.Permissions) > 0 { + core.Print(nil, "permissions: %s", core.Join(",", output.Key.Permissions...)) + } + if output.Key.ExpiresAt != "" { + core.Print(nil, "expires: %s", output.Key.ExpiresAt) + } + return core.Result{OK: true} +} + +func (s *PrepSubsystem) cmdAuthRevoke(options core.Options) core.Result { + if optionStringValue(options, "key_id", "key-id", "_arg") == "" { + core.Print(nil, "usage: core-agent auth revoke ") + return core.Result{Value: core.E("agentic.cmdAuthRevoke", "key_id is required", nil), OK: false} + } + + result := s.handleAuthRevoke(s.commandContext(), options) + if !result.OK { + err := commandResultError("agentic.cmdAuthRevoke", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(AuthRevokeOutput) + if !ok { + err := core.E("agentic.cmdAuthRevoke", "invalid auth revoke output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "revoked: %s", output.KeyID) + 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_example_test.go b/pkg/agentic/commands_platform_example_test.go index fab633f..325648b 100644 --- a/pkg/agentic/commands_platform_example_test.go +++ b/pkg/agentic/commands_platform_example_test.go @@ -15,3 +15,15 @@ func ExamplePrepSubsystem_cmdFleetRegister() { // usage: core-agent fleet register --platform=linux [--models=codex,gpt-5.4] [--capabilities=go,review] // false } + +func ExamplePrepSubsystem_cmdAuthProvision() { + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(core.New(), AgentOptions{}), + } + + result := s.cmdAuthProvision(core.NewOptions()) + core.Println(result.OK) + // Output: + // usage: core-agent auth provision [--name=codex] [--permissions=plans:read,plans:write] [--rate-limit=60] [--expires-at=2026-04-01T00:00:00Z] + // false +} diff --git a/pkg/agentic/commands_platform_test.go b/pkg/agentic/commands_platform_test.go index ab969d0..f072c52 100644 --- a/pkg/agentic/commands_platform_test.go +++ b/pkg/agentic/commands_platform_test.go @@ -17,6 +17,27 @@ func TestCommandsplatform_CmdFleetRegister_Bad(t *testing.T) { assert.False(t, result.OK) } +func TestCommandsplatform_CmdAuthProvision_Bad(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + result := subsystem.cmdAuthProvision(core.NewOptions()) + assert.False(t, result.OK) +} + +func TestCommandsplatform_CmdAuthRevoke_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"key_id":"7","revoked":true}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + output := captureStdout(t, func() { + result := subsystem.cmdAuthRevoke(core.NewOptions(core.Option{Key: "_arg", Value: "7"})) + assert.True(t, result.OK) + }) + + assert.Contains(t, output, "revoked: 7") +} + func TestCommandsplatform_CmdFleetNodes_Good(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"data":[{"id":1,"agent_id":"charon","platform":"linux","models":["codex"],"status":"online"}],"total":1}`)) diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index e58995c..a96223f 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -120,6 +120,10 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("agent.sync.pull", s.handleSyncPull).Description = "Pull fleet context from the platform API" c.Action("agentic.sync.status", s.handleSyncStatus).Description = "Get fleet sync status from the platform API" c.Action("agent.sync.status", s.handleSyncStatus).Description = "Get fleet sync status from the platform API" + c.Action("agentic.auth.provision", s.handleAuthProvision).Description = "Provision a platform API key for an authenticated agent user" + 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.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 e38231f..4bb8756 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -489,6 +489,10 @@ func TestPrep_OnStartup_Good_RegistersPlatformActionAliases(t *testing.T) { require.True(t, s.OnStartup(context.Background()).OK) assert.True(t, c.Action("agentic.sync.push").Exists()) assert.True(t, c.Action("agent.sync.push").Exists()) + assert.True(t, c.Action("agentic.auth.provision").Exists()) + 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.fleet.register").Exists()) assert.True(t, c.Action("agent.fleet.register").Exists()) assert.True(t, c.Action("agentic.credits.balance").Exists()) @@ -506,6 +510,8 @@ func TestPrep_OnStartup_Good_RegistersPlatformCommandAlias(t *testing.T) { s.ServiceRuntime = core.NewServiceRuntime(c, AgentOptions{}) require.True(t, s.OnStartup(context.Background()).OK) + assert.Contains(t, c.Commands(), "auth/provision") + assert.Contains(t, c.Commands(), "auth/revoke") assert.Contains(t, c.Commands(), "subscription/budget/update") assert.Contains(t, c.Commands(), "subscription/update-budget") }