From 43c6bd02985f48d44c58722bd72bc1998f0692f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 01:00:19 +0100 Subject: [PATCH 01/14] feat(runtime): add scoped workload agent tools Give opted-in command executions isolated opaque credentials and scoped HTTP/MCP tools without inheriting dashboard or infrastructure privileges. Persist identities across reconnects and revoke them with execution lifecycle changes. Redact workload credentials from output. --- Makefile | 1 + apps/druid/adapters/cli/daemon.go | 26 ++ apps/druid/adapters/http/handlers/auth.go | 8 + .../adapters/http/handlers/command_tools.go | 206 +++++++++ .../http/handlers/command_tools_test.go | 372 ++++++++++++++++ apps/druid/adapters/http/handlers/routes.go | 11 + apps/druid/core/services/command_tools.go | 310 +++++++++++++ .../druid/core/services/command_tools_test.go | 417 ++++++++++++++++++ apps/druid/core/services/runtime_commands.go | 20 +- apps/druid/core/services/runtime_identity.go | 135 ++++++ apps/druid/core/services/runtime_lifecycle.go | 21 +- apps/druid/core/services/runtime_session.go | 2 + .../core/services/runtime_session_cache.go | 1 + .../core/services/runtime_session_commands.go | 10 + .../services/runtime_session_execution.go | 64 ++- .../core/services/runtime_session_queue.go | 29 +- .../core/services/runtime_session_runtime.go | 3 + .../druid/core/services/runtime_supervisor.go | 11 +- .../core/services/runtime_supervisor_test.go | 3 +- apps/druid/core/services/runtime_ui_test.go | 19 +- .../druid-cli/templates/deployment.yaml | 2 + .../helm-charts/druid-cli/templates/rbac.yaml | 2 +- config/helm-charts/druid-cli/values.yaml | 1 + go.mod | 5 + go.sum | 10 + internal/core/domain/command_identity.go | 21 + internal/core/domain/command_identity_test.go | 24 + internal/core/domain/command_status.go | 1 + internal/core/domain/scroll.go | 39 +- internal/core/ports/command_runtime.go | 18 + internal/core/ports/services_ports.go | 12 +- internal/runtime/backend.go | 10 + internal/runtime/docker/backend.go | 13 + internal/runtime/docker/command_identity.go | 22 + .../runtime/docker/command_identity_test.go | 26 ++ internal/runtime/docker/console_tools.go | 71 +++ internal/runtime/docker/procedures.go | 11 +- internal/runtime/docker/spec.go | 49 +- internal/runtime/kubernetes/backend.go | 20 + .../runtime/kubernetes/command_identity.go | 31 ++ .../kubernetes/command_identity_test.go | 36 ++ internal/runtime/kubernetes/console_tools.go | 53 +++ .../kubernetes/namespace_identities.go | 174 ++++++++ .../kubernetes/namespace_identities_test.go | 57 +++ internal/runtime/kubernetes/procedures.go | 11 +- internal/runtime/kubernetes/resources.go | 21 + internal/utils/logger/credential_encoder.go | 26 ++ .../utils/logger/credential_encoder_test.go | 25 ++ internal/utils/logger/development_encoder.go | 2 +- internal/utils/logger/production_encoder.go | 2 +- internal/utils/logger/reduced_encoder.go | 2 +- internal/workloadidentity/file_registry.go | 194 ++++++++ internal/workloadidentity/identity.go | 69 +++ internal/workloadidentity/identity_test.go | 277 ++++++++++++ .../workloadidentity/kubernetes_registry.go | 266 +++++++++++ test/integration/agent_ui_test.go | 258 +++++++++++ test/integration/workload_identity_test.go | 397 +++++++++++++++++ 57 files changed, 3864 insertions(+), 63 deletions(-) create mode 100644 apps/druid/adapters/http/handlers/command_tools.go create mode 100644 apps/druid/adapters/http/handlers/command_tools_test.go create mode 100644 apps/druid/core/services/command_tools.go create mode 100644 apps/druid/core/services/command_tools_test.go create mode 100644 apps/druid/core/services/runtime_identity.go create mode 100644 internal/core/domain/command_identity.go create mode 100644 internal/core/domain/command_identity_test.go create mode 100644 internal/core/ports/command_runtime.go create mode 100644 internal/runtime/docker/command_identity.go create mode 100644 internal/runtime/docker/command_identity_test.go create mode 100644 internal/runtime/docker/console_tools.go create mode 100644 internal/runtime/kubernetes/command_identity.go create mode 100644 internal/runtime/kubernetes/command_identity_test.go create mode 100644 internal/runtime/kubernetes/console_tools.go create mode 100644 internal/runtime/kubernetes/namespace_identities.go create mode 100644 internal/runtime/kubernetes/namespace_identities_test.go create mode 100644 internal/utils/logger/credential_encoder.go create mode 100644 internal/utils/logger/credential_encoder_test.go create mode 100644 internal/workloadidentity/file_registry.go create mode 100644 internal/workloadidentity/identity.go create mode 100644 internal/workloadidentity/identity_test.go create mode 100644 internal/workloadidentity/kubernetes_registry.go create mode 100644 test/integration/agent_ui_test.go create mode 100644 test/integration/workload_identity_test.go diff --git a/Makefile b/Makefile index 3673c385..95e26591 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ WSL_HOST_IP ?= $(word 1,$(shell hostname -I 2>/dev/null)) DRUID_WORKER_CALLBACK_URL_WINDOWS ?= http://$(WSL_HOST_IP):8083 DRUID_WATCH_ARGS ?= daemon --runtime kubernetes --listen 127.0.0.1:8081 --public-listen 127.0.0.1:8082 --worker-callback-listen $(DRUID_WORKER_CALLBACK_LISTEN) --worker-callback-url $(DRUID_WORKER_CALLBACK_URL) --unsafe-allow-unauthenticated-management --unsafe-allow-unauthenticated-public --k8s-namespace $(DRUID_K8S_NAMESPACE) --k8s-pull-image $(DRUID_K8S_PULL_IMAGE) export DRUID_K8S_UI_S3_BUCKET ?= druid-ui +export DRUID_WORKLOAD_API_URL ?= http://host.docker.internal:8082 export DRUID_K8S_UI_S3_PUBLIC_BASE_URL ?= http://127.0.0.1:9000/druid-ui export DRUID_K8S_UI_S3_REGION ?= us-east-1 export DRUID_K8S_UI_S3_ENDPOINT ?= http://host.docker.internal:9000 diff --git a/apps/druid/adapters/cli/daemon.go b/apps/druid/adapters/cli/daemon.go index 7209c1af..c92405ea 100644 --- a/apps/druid/adapters/cli/daemon.go +++ b/apps/druid/adapters/cli/daemon.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "net" "os" @@ -42,6 +43,7 @@ var k8sServiceAccountAudience string var k8sOperatorServiceAccount string var runtimeListen string var runtimePublicListen string +var runtimeWorkloadAPIURL string var runtimeAllowUnauthenticatedPublic bool var runtimeAllowUnauthenticatedManagement bool var runtimeWorkerTimeout time.Duration @@ -77,6 +79,7 @@ func init() { DaemonCommand.Flags().StringVar(&runtimeSocket, "socket", utils.DefaultRuntimeSocketPath(), "Runtime daemon Unix socket path") DaemonCommand.Flags().StringVar(&runtimeListen, "listen", "", "Optional management HTTP listen address, for example :8081") DaemonCommand.Flags().StringVar(&runtimePublicListen, "public-listen", "", "Optional public dashboard HTTP listen address, for example :8082") + DaemonCommand.Flags().StringVar(&runtimeWorkloadAPIURL, "workload-api-url", os.Getenv("DRUID_WORKLOAD_API_URL"), "Workload-reachable public API base URL (required for command identities)") DaemonCommand.Flags().BoolVar(&runtimeAllowUnauthenticatedPublic, "unsafe-allow-unauthenticated-public", false, "Allow unauthenticated public HTTP routes without --auth-jwks-url") DaemonCommand.Flags().BoolVar(&runtimeAllowUnauthenticatedManagement, "unsafe-allow-unauthenticated-management", false, "Allow unauthenticated management HTTP routes; local Docker development only") DaemonCommand.Flags().DurationVar(&runtimeWorkerTimeout, "worker-timeout", 20*time.Minute, "Maximum time for runtime materialization workers") @@ -142,12 +145,35 @@ func runRuntimeDaemon() error { if err != nil { return err } + defer runtime.Close() manager := services.NewRuntimeScrollManager(runtime.Store) supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, manager, runtime) if err != nil { return err } callbacks := appservices.NewWorkerCallbackManager() + if err := supervisor.SetWorkloadAPIURL(runtimeWorkloadAPIURL); err != nil { + return err + } + identityContext, cancelIdentityReconciliation := context.WithCancel(context.Background()) + defer cancelIdentityReconciliation() + if err := supervisor.ReconcileCommandIdentities(identityContext); err != nil { + return err + } + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-identityContext.Done(): + return + case <-ticker.C: + if err := supervisor.ReconcileCommandIdentities(identityContext); err != nil { + logger.Log().Warn("Command identity reconciliation unavailable") + } + } + } + }() supervisor.SetWorkerTimeout(runtimeWorkerTimeout) callbackConfig := ports.RuntimeWorkerCallbackConfig{ Listen: runtimeWorkerCallbackListen, diff --git a/apps/druid/adapters/http/handlers/auth.go b/apps/druid/adapters/http/handlers/auth.go index 616243c6..e574121a 100644 --- a/apps/druid/adapters/http/handlers/auth.go +++ b/apps/druid/adapters/http/handlers/auth.go @@ -4,11 +4,16 @@ import ( "github.com/gofiber/contrib/websocket" "github.com/gofiber/fiber/v2" "github.com/highcard-dev/daemon/internal/core/ports" + "github.com/highcard-dev/daemon/internal/workloadidentity" + "strings" ) const ownerLocal = "druid-owner-id" func (h *ScrollHandler) PublicAuth(c *fiber.Ctx) error { + if strings.HasPrefix(bearer(c.Get("Authorization")), workloadidentity.TokenPrefix) { + return fiber.NewError(fiber.StatusForbidden, "workload credentials cannot access this endpoint") + } if h.authorizer == nil { if h.allowUnauthenticatedPublic { return c.Next() @@ -51,6 +56,9 @@ func (h *ScrollHandler) authorizeRuntimeOwner(id string, subject string) error { } func (h *WebsocketHandler) PublicQueryAuth(c *websocket.Conn) bool { + if strings.HasPrefix(c.Query("token"), workloadidentity.TokenPrefix) || strings.HasPrefix(bearer(c.Headers("Authorization")), workloadidentity.TokenPrefix) { + return false + } if h.authorizer == nil { return h.allowUnauthenticatedPublic } diff --git a/apps/druid/adapters/http/handlers/command_tools.go b/apps/druid/adapters/http/handlers/command_tools.go new file mode 100644 index 00000000..4113dcc2 --- /dev/null +++ b/apps/druid/adapters/http/handlers/command_tools.go @@ -0,0 +1,206 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/google/jsonschema-go/jsonschema" + services "github.com/highcard-dev/daemon/apps/druid/core/services" + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/workloadidentity" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func commandToolStatus(err error) int { + var failure *services.ToolError + if errors.As(err, &failure) { + return failure.Status + } + if errors.Is(err, workloadidentity.ErrUnauthorized) { + return 401 + } + if errors.Is(err, workloadidentity.ErrUnavailable) { + return 503 + } + return 500 +} + +func commandToolMessage(err error) string { + if commandToolStatus(err) == 500 { + return "runtime tool failed" + } + return workloadidentity.Redact(err.Error()) +} + +func bearer(header string) string { + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "" + } + return parts[1] +} + +func (h *ScrollHandler) commandToolHTTP(name string) fiber.Handler { + return func(c *fiber.Ctx) error { + token := bearer(c.Get("Authorization")) + // Preserve the existing owner-authenticated endpoints. + if (name == "get_queue" || name == "publish_ui") && !strings.HasPrefix(token, workloadidentity.TokenPrefix) { + return c.Next() + } + input := services.ToolInput{} + if len(c.Body()) > 0 { + decoder := json.NewDecoder(bytes.NewReader(c.Body())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + return fiber.NewError(400, "invalid tool request") + } + if err := decoder.Decode(new(any)); err != io.EOF { + return fiber.NewError(400, "invalid tool request") + } + } + input.Command = c.Params("command", input.Command) + input.Console = c.Params("console", input.Console) + input.ExecutionID = c.Params("execution", input.ExecutionID) + input.Scope = c.Params("scope", input.Scope) + if c.Method() == "GET" { + input.TailLines = c.QueryInt("tail_lines", 0) + input.TimeoutSeconds = c.QueryInt("timeout_seconds", 0) + } + result, err := h.supervisor.CallCommandTool(c.UserContext(), token, c.Params("id"), name, input) + if err != nil { + return fiber.NewError(commandToolStatus(err), commandToolMessage(err)) + } + if name == "run_command" { + c.Status(202) + } + if name == "create_command" { + c.Status(201) + } + return c.JSON(result) + } +} + +func (h *ScrollHandler) registerCommandTools(app *fiber.App) { + app.Get("/:id/api/v1/commands", h.commandToolHTTP("list_commands")) + app.Post("/:id/api/v1/commands", h.commandToolHTTP("create_command")) + app.Get("/:id/api/v1/queue", h.commandToolHTTP("get_queue")) + app.Post("/:id/api/v1/commands/:command", h.commandToolHTTP("run_command")) + app.Get("/:id/api/v1/commands/:command/executions/:execution/wait", h.commandToolHTTP("wait_for_command")) + app.Get("/:id/api/v1/consoles/:console/logs", h.commandToolHTTP("read_logs")) + app.Post("/:id/api/v1/consoles/:console/logs/search", h.commandToolHTTP("search_logs")) + app.Post("/:id/api/v1/consoles/:console/stdin", h.commandToolHTTP("send_stdin")) + app.Get("/:id/api/v1/status", h.commandToolHTTP("get_deployment_status")) + app.Post("/:id/api/v1/ui/packages/:scope/publish", h.commandToolHTTP("publish_ui")) + app.All("/:id/mcp", func(c *fiber.Ctx) error { + deployment, token := c.Params("id"), bearer(c.Get("Authorization")) + record, err := h.supervisor.AuthenticateCommand(c.UserContext(), token, deployment) + if err != nil { + return fiber.NewError(commandToolStatus(err), commandToolMessage(err)) + } + handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { + server := mcp.NewServer(&mcp.Implementation{Name: "druid", Version: "1.0.0"}, nil) + for _, definition := range commandToolDefinitions() { + name := definition.Name + if !record.Allows(services.ToolPermissions[name]) { + continue + } + server.AddTool(definition, func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var input services.ToolInput + decoder := json.NewDecoder(bytes.NewReader(request.Params.Arguments)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "invalid tool request"}}}, nil + } + result, err := h.supervisor.CallCommandTool(ctx, token, deployment, name, input) + if err != nil { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: commandToolMessage(err)}}}, nil + } + data, err := json.Marshal(result) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: workloadidentity.Redact(string(data))}}}, nil + }) + } + return server + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + return adaptor.HTTPHandler(handler)(c) + }) +} + +// OpenHands drops items/properties for nullable type arrays. Optional fields can +// be omitted instead; advertise concrete types without duplicating domain types. +func concreteToolSchema(schema *jsonschema.Schema) { + if schema == nil { + return + } + if len(schema.Types) == 2 { + for index, kind := range schema.Types { + if kind == "null" { + schema.Type = schema.Types[1-index] + schema.Types = nil + break + } + } + } + for _, property := range schema.Properties { + concreteToolSchema(property) + } + concreteToolSchema(schema.Items) + concreteToolSchema(schema.AdditionalProperties) +} + +func commandToolDefinitions() []*mcp.Tool { + // Derive the vocabulary from the existing command type, not a second schema. + instructions, err := jsonschema.For[domain.CommandInstructionSet](nil) + if err != nil { + panic(err) // A programming error in the fixed command type. + } + delete(instructions.Properties, "identity") + concreteToolSchema(instructions) + procedures := instructions.Properties["procedures"] + minimumProcedures := 1 + procedures.MinItems = &minimumProcedures + // The runtime accepts omitted ignore_failure as false. + procedure := procedures.Items + procedure.Required = nil + procedure.Properties["type"].Enum = []any{string(domain.ProcedureTypeContainer), string(domain.ProcedureTypeSignal)} + run := instructions.Properties["run"] + run.Enum = []any{string(domain.RunModeAlways), string(domain.RunModeOnce), string(domain.RunModeRestart), string(domain.RunModePersistent)} + stringField := func(description string) map[string]any { + return map[string]any{"type": "string", "description": description} + } + integerField := func(max int) map[string]any { return map[string]any{"type": "integer", "minimum": 1, "maximum": max} } + tool := func(name, description string, properties map[string]any, required ...string) *mcp.Tool { + if properties == nil { + properties = map[string]any{} + } + schema := map[string]any{"type": "object", "properties": properties, "additionalProperties": false} + if len(required) > 0 { + schema["required"] = required + } + return &mcp.Tool{Name: name, Description: description, InputSchema: schema} + } + return []*mcp.Tool{ + tool("create_command", `Register a new persistent deployment command without executing it. Instructions define container images, arguments, environments and deployment-data mounts; this creates container workloads, not host-shell execution. Existing names and workload identity grants are rejected. Use run_command afterward. Example: {"command":"example-echo","instructions":{"procedures":[{"image":"busybox:1.37","command":["echo","hello"]}]}}`, map[string]any{"command": stringField("New command name"), "instructions": instructions}, "command", "instructions"), + tool("list_commands", "List existing Scroll commands and console IDs, without secrets.", nil), + tool("get_queue", "Read current command/procedure status and execution IDs.", nil), + tool("get_deployment_status", "Read deployment state, installed release and ports/endpoints.", nil), + tool("run_command", "Enqueue an existing named command; inspect completion using its execution ID.", map[string]any{"command": stringField("Existing command name")}, "command"), + tool("wait_for_command", "Wait up to 30 seconds for a specific execution; never switches to a replacement.", map[string]any{"command": stringField("Command name"), "execution_id": stringField("Execution ID returned by run_command"), "timeout_seconds": integerField(30)}, "command", "execution_id"), + tool("read_logs", "Read a finite recent console snapshot (default 200 lines, maximum 64 KiB). Application logs may contain secrets.", map[string]any{"console": stringField("Console ID from list_commands"), "tail_lines": integerField(1000)}, "console"), + tool("search_logs", "Case-insensitive literal search of recent logs; maximum 50 matches and 1 MiB scanned.", map[string]any{"console": stringField("Console ID"), "query": stringField("Literal search text"), "tail_lines": integerField(10000)}, "console", "query"), + tool("send_stdin", "Write exact text to an existing running console. Include a newline to submit a line. Does not start a shell.", map[string]any{"console": stringField("Console ID"), "text": stringField("Exact UTF-8 input, at most 8 KiB")}, "console", "text"), + tool("publish_ui", "Publish an already built Druid UI WASM/OpenUI pair for this deployment. First show focused previews, explain changes and scope, ask for publishing approval, end your turn and wait for a subsequent explicit customer reply. The original UI-change request is not approval. Revisions require renewed approval; failed previews must not be published automatically. For behavior-only changes, explain the preview limitation and still obtain approval. Do not bypass approval through HTTP, terminal commands, Developer Mode or live watchers. This is an agent instruction, not a server-enforced approval check. Does not build or publish npm/Scroll artifacts. Choose private or public explicitly; path defaults to /dist/app.wasm with an adjacent .openui document. Returns the published package only after upload succeeds.", map[string]any{ + "scope": map[string]any{"type": "string", "enum": []string{"private", "public"}, "description": "UI scope; use private unless the customer requests public UI"}, + "path": stringField("Optional scope-relative .wasm path; the matching .openui file must be adjacent"), + }, "scope"), + } +} diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go new file mode 100644 index 00000000..8185e3b3 --- /dev/null +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -0,0 +1,372 @@ +package handlers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/jsonschema-go/jsonschema" + appservices "github.com/highcard-dev/daemon/apps/druid/core/services" + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" + coreservices "github.com/highcard-dev/daemon/internal/core/services" + "github.com/highcard-dev/daemon/internal/runtime/docker" + "github.com/highcard-dev/daemon/internal/workloadidentity" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestPublishUIDescriptionRequiresSeparateCustomerApproval(t *testing.T) { + for _, definition := range commandToolDefinitions() { + if definition.Name != "publish_ui" { + continue + } + for _, instruction := range []string{"focused previews", "end your turn and wait", "subsequent explicit customer reply", "original UI-change request is not approval", "Revisions require renewed approval", "failed previews", "behavior-only changes", "Do not bypass approval", "not a server-enforced approval check"} { + if !strings.Contains(definition.Description, instruction) { + t.Errorf("publish_ui description missing %q", instruction) + } + } + return + } + t.Fatal("publish_ui tool is missing") +} + +func TestConcreteToolSchemaNormalizesMapValuesWithoutChangingRequiredFields(t *testing.T) { + schema := &jsonschema.Schema{Type: "object", Required: []string{"required"}, Properties: map[string]*jsonschema.Schema{ + "required": {Types: []string{"string", "null"}}, + "optional": {Type: "object", AdditionalProperties: &jsonschema.Schema{Types: []string{"null", "string"}}}, + }} + concreteToolSchema(schema) + if schema.Properties["required"].Type != "string" || schema.Properties["optional"].AdditionalProperties.Type != "string" { + t.Fatal("nullable scalar or map value lost its concrete type") + } + if len(schema.Required) != 1 || schema.Required[0] != "required" { + t.Fatal("schema normalization changed field optionality") + } +} + +func TestCreateCommandSchemaPreservesNestedTypesForOpenHands(t *testing.T) { + definition := commandToolDefinitions()[0] + if definition.Name != "create_command" { + t.Fatal("creation tool missing") + } + instructions := definition.InputSchema.(map[string]any)["properties"].(map[string]any)["instructions"].(*jsonschema.Schema) + var visit func(*jsonschema.Schema) + visit = func(schema *jsonschema.Schema) { + if schema == nil { + return + } + if len(schema.Types) > 0 { + t.Fatalf("OpenHands loses nested fields for nullable type %v", schema.Types) + } + for _, property := range schema.Properties { + visit(property) + } + visit(schema.Items) + visit(schema.AdditionalProperties) + } + visit(instructions) + procedures := instructions.Properties["procedures"] + if procedures.Type != "array" || procedures.MinItems == nil || *procedures.MinItems != 1 || procedures.Items.Type != "object" { + t.Fatal("procedures must advertise a nonempty object array") + } + procedure := procedures.Items + for _, field := range []string{"image", "id", "command", "env", "mounts", "expectedPorts", "target", "signal"} { + if procedure.Properties[field] == nil { + t.Fatalf("procedure field %s missing", field) + } + if procedure.Properties[field].Description == "" { + t.Fatalf("Scroll field %s lost its description", field) + } + } + if procedure.Properties["command"].Type != "array" || procedure.Properties["command"].Items.Type != "string" || procedure.Properties["env"].AdditionalProperties.Type != "string" { + t.Fatal("argument array or environment map schema lost") + } + if procedure.Properties["mounts"].Items.Properties["read_only"].Type != "boolean" || procedure.Properties["expectedPorts"].Items.Properties["name"].Type != "string" { + t.Fatal("mount or port-reference schema lost") + } + if instructions.Properties["identity"] != nil || len(procedure.Required) != 0 || len(instructions.Properties["run"].Enum) != 4 { + t.Fatal("identity exclusion, optional ignore_failure, or run modes changed") + } + if procedures.Description == "" || !strings.Contains(instructions.Properties["run"].Description, "NOT a shell command") { + t.Fatal("Scroll procedure or scheduling-mode documentation lost") + } + if !strings.Contains(definition.Description, "Example: ") { + t.Fatal("model-visible example missing") + } +} + +type toolRouteBackend struct { + ports.RuntimeBackendInterface + registry workloadidentity.Registry +} + +func (b *toolRouteBackend) WorkloadIdentities() workloadidentity.Registry { return b.registry } +func (b *toolRouteBackend) Name() string { return "fixture" } +func (b *toolRouteBackend) RunCommand(ports.RuntimeCommand) (*int, error) { return nil, nil } +func (b *toolRouteBackend) CreateUIPackageUpload(_ context.Context, action ports.RuntimeUIPackageUploadAction) (ports.RuntimeUIPackageUpload, error) { + return ports.RuntimeUIPackageUpload{UploadURL: "http://upload-private/wasm", DocumentUploadURL: "http://upload-private/document", URL: "http://packages/" + string(action.Scope) + "/app.wasm"}, nil +} +func (b *toolRouteBackend) StopCommand(string, string) error { return nil } +func (b *toolRouteBackend) ActiveExecutions(context.Context) (map[string]bool, error) { + return map[string]bool{"execution": true}, nil +} + +type authenticatedTransport struct{ token string } + +func (t authenticatedTransport) RoundTrip(request *http.Request) (*http.Response, error) { + copy := request.Clone(request.Context()) + copy.Header = request.Header.Clone() + copy.Header.Set("Authorization", "Bearer "+t.token) + return http.DefaultTransport.RoundTrip(copy) +} + +func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + registry, err := workloadidentity.NewFileRegistry(t.TempDir()) + if err != nil { + t.Fatal(err) + } + mount, err := registry.Ensure(ctx, workloadidentity.Record{ExecutionID: "execution", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list", "commands:create", "queue:read", "ui:publish"}}) + if err != nil { + t.Fatal(err) + } + token, err := os.ReadFile(mount.Path) + if err != nil { + t.Fatal(err) + } + store, err := docker.NewStateStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + runtime := &domain.RuntimeScroll{ID: "tools", Root: t.TempDir(), ScrollName: "tools", ScrollYAML: "name: tools\ndesc: Tools fixture\nversion: 0.1.0\napp_version: 1.0\ncommands:\n agent:\n run: persistent\n procedures:\n - image: busybox\n env: {SECRET: must-not-leak}\n"} + if err := store.CreateScroll(runtime); err != nil { + t.Fatal(err) + } + backend := &toolRouteBackend{registry: registry} + supervisor, err := appservices.NewRuntimeSupervisor(store, coreservices.NewRuntimeScrollManager(store), ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) + if err != nil { + t.Fatal(err) + } + scrolls := NewScrollHandler(supervisor) + scrolls.SetAllowUnauthenticatedPublic(true) + management := fiber.New(fiber.Config{DisableStartupMessage: true}) + RegisterManagementRoutes(management, RouteHandlers{Server: NewRuntimeServer(NewHealthHandler(), scrolls), Websocket: NewWebsocketHandler()}) + managementRequest := httptest.NewRequest("GET", "/api/v1/scrolls", nil) + managementRequest.Header.Set("Authorization", "Bearer "+string(token)) + managementResponse, err := management.Test(managementRequest) + if err != nil { + t.Fatal(err) + } + managementResponse.Body.Close() + if managementResponse.StatusCode != 403 { + t.Fatal("command credential reached management API without infrastructure authentication") + } + app := fiber.New(fiber.Config{DisableStartupMessage: true}) + RegisterPublicRoutes(app, RouteHandlers{Server: NewRuntimeServer(NewHealthHandler(), scrolls), Websocket: NewWebsocketHandler()}) + request := httptest.NewRequest("GET", "/tools/api/v1/commands", nil) + request.Header.Set("Authorization", "Bearer "+string(token)) + response, err := app.Test(request) + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.StatusCode != 200 || strings.Contains(string(data), "must-not-leak") { + t.Fatal("metadata HTTP endpoint failed or leaked secrets") + } + for _, path := range []string{"/other/api/v1/commands", "/tools/api/v1/token", "/tools/api/v1/scroll"} { + request := httptest.NewRequest("GET", path, nil) + request.Header.Set("Authorization", "Bearer "+string(token)) + response, err := app.Test(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode < 400 { + t.Fatalf("workload reached forbidden route %s", path) + } + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + response, err := app.Test(request, -1) + if err != nil { + http.Error(writer, "fixture HTTP failure", 500) + return + } + defer response.Body.Close() + for key, values := range response.Header { + writer.Header()[key] = values + } + writer.WriteHeader(response.StatusCode) + io.Copy(writer, response.Body) + })) + defer server.Close() + client := mcp.NewClient(&mcp.Implementation{Name: "druid-test", Version: "1"}, nil) + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: server.URL + "/tools/mcp", HTTPClient: &http.Client{Transport: authenticatedTransport{string(token)}}}, nil) + if err != nil { + t.Fatal(err) + } + defer session.Close() + listed, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatal(err) + } + if len(listed.Tools) != 5 { + t.Fatalf("permission-filtered discovery returned %d tools", len(listed.Tools)) + } + result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "list_commands", Arguments: map[string]any{}}) + if err != nil || result.IsError { + t.Fatal("MCP command listing failed") + } + publication, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "publish_ui", Arguments: map[string]any{"scope": "private"}}) + if err != nil || publication.IsError { + t.Fatalf("MCP publish failed: %v, %v", err, publication) + } + encodedPublication, _ := json.Marshal(publication) + if !strings.Contains(string(encodedPublication), "published") || strings.Contains(string(encodedPublication), "upload-private") { + t.Fatal("unsafe publication projection") + } + for _, test := range []struct { + path, body, token string + status int + }{ + {"/tools/api/v1/ui/packages/public/publish", `{}`, string(token), 200}, + {"/tools/api/v1/ui/packages/private/publish", `{"path":"public/app.wasm"}`, string(token), 400}, + {"/other/api/v1/ui/packages/private/publish", `{}`, string(token), 401}, + // Owner/local requests fall through to the unchanged owner endpoint. + {"/tools/api/v1/ui/packages/private/publish", `{}`, "", 200}, + } { + request := httptest.NewRequest("POST", test.path, strings.NewReader(test.body)) + request.Header.Set("Content-Type", "application/json") + if test.token != "" { + request.Header.Set("Authorization", "Bearer "+test.token) + } + response, err := app.Test(request, -1) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != test.status { + t.Fatalf("%s: got %d, want %d", test.path, response.StatusCode, test.status) + } + } + invalid, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "list_commands", Arguments: map[string]any{"environment": map[string]string{"SECRET": "must-not-be-applied"}}}) + if err == nil && !invalid.IsError { + t.Fatal("MCP accepted an unknown override field") + } + encoded, _ := json.Marshal(result) + if !strings.Contains(string(encoded), "agent") || strings.Contains(string(encoded), "must-not-leak") || strings.Contains(string(encoded), string(token)) { + t.Fatal("MCP metadata projection failed") + } + for _, test := range []struct { + body string + status int + }{ + {`{"command":"http-created","instructions":{"procedures":[{"image":"busybox","command":["true"]}]}}`, 201}, + {`{"command":"http-created","instructions":{"procedures":[{"image":"busybox"}]}}`, 409}, + {`{"command":"invalid","instructions":{"procedures":[]}}`, 400}, + {`{"command":"identity","instructions":{"identity":{},"procedures":[{"image":"busybox"}]}}`, 400}, + {`{"command":"unknown","instructions":{"arbitrary":true}}`, 400}, + } { + request := httptest.NewRequest("POST", "/tools/api/v1/commands", strings.NewReader(test.body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+string(token)) + response, err := app.Test(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != test.status { + t.Fatalf("HTTP create: expected %d, got %d", test.status, response.StatusCode) + } + } + var arguments map[string]any + _, example, found := strings.Cut(commandToolDefinitions()[0].Description, "Example: ") + if !found || json.Unmarshal([]byte(example), &arguments) != nil { + t.Fatal("advertised example is not valid JSON") + } + created, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "create_command", Arguments: arguments}) + if err != nil || created.IsError { + t.Fatalf("MCP creation failed: %v, %v", err, created) + } + duplicate, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "create_command", Arguments: arguments}) + if err == nil && !duplicate.IsError { + t.Fatal("MCP overwrote an existing command") + } + for _, instructions := range []map[string]any{ + {"procedures": []any{map[string]any{"image": "busybox", "command": "echo hello"}}}, + {"procedures": []any{map[string]any{"image": "busybox", "name": "server", "run": "echo hello"}}}, + {"procedures": []any{}}, + {"procedures": nil}, + } { + invalid, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "create_command", Arguments: map[string]any{"command": "malformed", "instructions": instructions}}) + if err == nil && !invalid.IsError { + t.Fatal("MCP accepted malformed command instructions") + } + } + signal, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "create_command", Arguments: map[string]any{"command": "signal-example", "instructions": map[string]any{"procedures": []any{map[string]any{"type": "signal", "target": "agent.0", "signal": "SIGTERM"}}}}}) + if err != nil || signal.IsError { + t.Fatalf("signal procedure schema regressed: %v, %v", err, signal) + } + // A credential with only commands:list must neither discover nor call creation. + limited, err := registry.Ensure(ctx, workloadidentity.Record{ExecutionID: "limited", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list"}}) + if err != nil { + t.Fatal(err) + } + limitedToken, err := os.ReadFile(limited.Path) + if err != nil { + t.Fatal(err) + } + limitedSession, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: server.URL + "/tools/mcp", HTTPClient: &http.Client{Transport: authenticatedTransport{string(limitedToken)}}}, nil) + if err != nil { + t.Fatal(err) + } + defer limitedSession.Close() + limitedTools, err := limitedSession.ListTools(ctx, nil) + if err != nil || len(limitedTools.Tools) != 1 || limitedTools.Tools[0].Name != "list_commands" { + t.Fatal("creation leaked through filtered discovery") + } + deniedPublish, err := limitedSession.CallTool(ctx, &mcp.CallToolParams{Name: "publish_ui", Arguments: map[string]any{"scope": "private"}}) + if err == nil && !deniedPublish.IsError { + t.Fatal("MCP accepted publishing without permission") + } + request = httptest.NewRequest("POST", "/tools/api/v1/ui/packages/private/publish", strings.NewReader(`{}`)) + request.Header.Set("Authorization", "Bearer "+string(limitedToken)) + response, err = app.Test(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 403 { + t.Fatal("HTTP accepted publishing without permission") + } + deniedCreation, err := limitedSession.CallTool(ctx, &mcp.CallToolParams{Name: "create_command", Arguments: arguments}) + if err == nil && !deniedCreation.IsError { + t.Fatal("MCP accepted creation without its permission") + } + for _, path := range []string{"/tools/api/v1/commands", "/other/api/v1/commands"} { + request := httptest.NewRequest("POST", path, strings.NewReader(`{"command":"denied","instructions":{"procedures":[{"image":"busybox"}]}}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+string(limitedToken)) + response, err := app.Test(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 401 && response.StatusCode != 403 { + t.Fatalf("unauthorized creation returned %d", response.StatusCode) + } + } + if err := registry.Revoke(ctx, "execution"); err != nil { + t.Fatal(err) + } + if _, err := session.ListTools(ctx, nil); err == nil { + t.Fatal("MCP discovery accepted a revoked credential") + } +} diff --git a/apps/druid/adapters/http/handlers/routes.go b/apps/druid/adapters/http/handlers/routes.go index adb9fc97..503aa052 100644 --- a/apps/druid/adapters/http/handlers/routes.go +++ b/apps/druid/adapters/http/handlers/routes.go @@ -6,6 +6,8 @@ import ( "github.com/gofiber/fiber/v2/middleware/cors" "github.com/highcard-dev/daemon/internal/api" "github.com/highcard-dev/daemon/internal/core/ports" + "github.com/highcard-dev/daemon/internal/workloadidentity" + "strings" ) type RouteHandlers struct { @@ -29,6 +31,12 @@ func RegisterRoutes(app *fiber.App, handlers RouteHandlers) { } func RegisterManagementRoutes(app *fiber.App, handlers RouteHandlers) { + app.Use(func(ctx *fiber.Ctx) error { + if strings.HasPrefix(bearer(ctx.Get("Authorization")), workloadidentity.TokenPrefix) { + return fiber.NewError(fiber.StatusForbidden, "command credentials cannot access management endpoints") + } + return ctx.Next() + }) if handlers.Authorizer != nil { app.Use(func(ctx *fiber.Ctx) error { if _, err := handlers.Authorizer.CheckHeader(ctx); err != nil { @@ -57,6 +65,9 @@ func RegisterPublicRoutes(app *fiber.App, handlers RouteHandlers) { app.Get("/health", handlers.Server.GetHealthAuth) app.Get("/.well-known/jwks.json", RuntimeJWKS(authorizer)) app.Get("/:id/ws/v1/serve/:console", websocket.New(handlers.Websocket.AttachScrollConsole)) + if handlers.Server != nil && handlers.Server.ScrollHandler != nil { + handlers.Server.ScrollHandler.registerCommandTools(app) + } if handlers.Server != nil && handlers.Server.ScrollHandler != nil { app.Use("/:id", handlers.Server.PublicAuth) } diff --git a/apps/druid/core/services/command_tools.go b/apps/druid/core/services/command_tools.go new file mode 100644 index 00000000..5bb2262d --- /dev/null +++ b/apps/druid/core/services/command_tools.go @@ -0,0 +1,310 @@ +package services + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" + coreservices "github.com/highcard-dev/daemon/internal/core/services" + "github.com/highcard-dev/daemon/internal/workloadidentity" +) + +type ToolInput struct { + Scope string `json:"scope,omitempty"` + Path string `json:"path,omitempty"` + Instructions *domain.CommandInstructionSet `json:"instructions,omitempty"` + Command string `json:"command,omitempty"` + ExecutionID string `json:"execution_id,omitempty"` + Console string `json:"console,omitempty"` + Text string `json:"text,omitempty"` + Query string `json:"query,omitempty"` + TailLines int `json:"tail_lines,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` +} + +var ToolPermissions = map[string]string{ + "publish_ui": "ui:publish", + "create_command": "commands:create", + "list_commands": "commands:list", "get_queue": "queue:read", "run_command": "commands:run", "wait_for_command": "queue:read", + "read_logs": "logs:read", "search_logs": "logs:read", "send_stdin": "console:write", "get_deployment_status": "runtime:read", +} + +type ToolError struct { + Status int + Message string +} + +func (e *ToolError) Error() string { return e.Message } +func toolError(status int, message string) error { return &ToolError{Status: status, Message: message} } + +// CallCommandTool is the single permission/execution seam shared by HTTP and MCP. +func (s *RuntimeSupervisor) CallCommandTool(ctx context.Context, token, deployment, name string, input ToolInput) (any, error) { + record, err := s.AuthenticateCommand(ctx, token, deployment) + if err != nil { + return nil, err + } + permission, known := ToolPermissions[name] + if !known { + return nil, toolError(404, "unknown tool") + } + if !record.Allows(permission) { + return nil, toolError(403, "workload permission denied") + } + switch name { + case "publish_ui": + scope, path, err := normalizeUIPackageRequest(input.Scope, input.Path) + if err != nil { + return nil, toolError(400, err.Error()) + } + updated, err := s.PublishUIPackage(deployment, string(scope), path) + if err != nil { + return nil, err + } + return map[string]any{"scope": scope, "published": true, "package": updated.UIPackages[scope]}, nil + case "create_command": + return s.createToolCommand(deployment, input) + case "list_commands": + file, err := s.ScrollFile(deployment) + if err != nil { + return nil, err + } + names := make([]string, 0, len(file.Commands)) + for name := range file.Commands { + names = append(names, name) + } + sort.Strings(names) + commands := make([]map[string]any, 0, len(names)) + for _, name := range names { + command := file.Commands[name] + consoles := []string{} + for index, procedure := range command.Procedures { + if procedure.IsContainer() { + consoles = append(consoles, domain.ProcedureName(name, index, procedure)) + } + } + commands = append(commands, map[string]any{"name": name, "run": command.Run, "needs": command.Needs, "consoles": consoles}) + } + return map[string]any{"commands": commands}, nil + case "get_queue": + return s.Queue(deployment) + case "run_command": + return s.enqueueToolCommand(deployment, input.Command) + case "wait_for_command": + return s.waitToolCommand(ctx, token, deployment, input) + case "get_deployment_status": + runtime, err := s.Get(deployment) + if err != nil { + return nil, err + } + ports, err := s.Ports(deployment) + if err != nil { + return nil, err + } + // Deliberately project fields: RuntimeScroll includes private host paths. + publicPorts := make([]map[string]any, 0, len(ports)) + for _, port := range ports { + publicPorts = append(publicPorts, map[string]any{"name": port.Name, "procedure": port.Procedure, "port": port.Port, "protocol": port.Protocol, "bound": port.Bound}) + } + errorSummary := "" + if runtime.LastError != "" { + errorSummary = "Deployment runtime reported an error" + } + return map[string]any{"deployment_id": runtime.ID, "status": runtime.Status, "release": map[string]string{"artifact": runtime.Artifact, "digest": runtime.ArtifactDigest}, "ports": publicPorts, "endpoints": runtime.Routing, "error": errorSummary}, nil + case "read_logs", "search_logs", "send_stdin": + return s.consoleTool(ctx, deployment, name, input) + } + return nil, toolError(404, "unknown tool") +} + +func (s *RuntimeSupervisor) createToolCommand(deployment string, input ToolInput) (any, error) { + if input.Command == "" || input.Instructions == nil { + return nil, toolError(400, "command and instructions are required") + } + if input.Instructions.Identity != nil { + return nil, toolError(400, "created commands cannot request workload identity") + } + unlock := s.lockRuntimeOperation(deployment) + defer unlock() + session, err := s.sessionFor(deployment) + if err != nil { + return nil, err + } + session.mu.Lock() + _, exists := session.scrollService.GetFile().Commands[input.Command] + session.mu.Unlock() + if exists { + return nil, toolError(409, "command already exists") + } + if err := session.AddCommand(input.Command, input.Instructions); err != nil { + return nil, toolError(400, err.Error()) + } + return map[string]any{"command": input.Command, "created": true}, nil +} + +func (s *RuntimeSupervisor) enqueueToolCommand(deployment, command string) (any, error) { + if command == "" { + return nil, toolError(400, "command is required") + } + unlock := s.lockRuntimeOperation(deployment) + defer unlock() + session, err := s.sessionFor(deployment) + if err != nil { + return nil, err + } + definition, err := session.scrollService.GetCommand(command) + if err != nil { + return nil, toolError(404, "command is unavailable") + } + session.Start() + session.rememberDoneDependencies(definition, map[string]bool{}) + execution := uuid.NewString() + _, err = session.enqueueExecution(command, coreservices.AddItemOptions{}, nil, execution) + if errors.Is(err, coreservices.ErrAlreadyInQueue) || errors.Is(err, coreservices.ErrCommandDoneOnce) { + return nil, toolError(409, err.Error()) + } + if err != nil { + return nil, err + } + return map[string]any{"command": command, "execution_id": execution, "accepted": true}, nil +} + +func (s *RuntimeSupervisor) waitToolCommand(ctx context.Context, token, deployment string, input ToolInput) (any, error) { + if input.Command == "" || input.ExecutionID == "" { + return nil, toolError(400, "command and execution_id are required") + } + timeout := input.TimeoutSeconds + if timeout == 0 { + timeout = 10 + } + if timeout < 1 || timeout > 30 { + return nil, toolError(400, "timeout_seconds must be between 1 and 30") + } + deadline := time.Now().Add(time.Duration(timeout) * time.Second) + for { + record, err := s.AuthenticateCommand(ctx, token, deployment) + if err != nil { + return nil, err + } + if !record.Allows("queue:read") { + return nil, toolError(403, "workload permission denied") + } + queue, err := s.Queue(deployment) + if err != nil { + return nil, err + } + statuses := queue[input.Command] + if len(statuses) == 0 { + return nil, toolError(410, "execution stopped or unavailable") + } + state := domain.ScrollLockStatusDone + var exitCode *int + for _, status := range statuses { + if status.ExecutionID != input.ExecutionID { + return nil, toolError(409, "execution was replaced") + } + if status.Status == domain.ScrollLockStatusError { + state = domain.ScrollLockStatusError + exitCode = status.ExitCode + break + } + if status.Status != domain.ScrollLockStatusDone { + state = status.Status + } + } + if state == domain.ScrollLockStatusDone { + zero := 0 + exitCode = &zero + } + terminal := state == domain.ScrollLockStatusDone || state == domain.ScrollLockStatusError + timedOut := !terminal && !time.Now().Before(deadline) + if terminal || timedOut { + return map[string]any{"command": input.Command, "execution_id": input.ExecutionID, "status": state, "exit_code": exitCode, "timed_out": timedOut}, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} + +func (s *RuntimeSupervisor) consoleTool(ctx context.Context, deployment, name string, input ToolInput) (any, error) { + if input.Console == "" { + return nil, toolError(400, "console is required") + } + backend, ok := s.runtimeBackend.(ports.CommandConsoleBackend) + if !ok { + return nil, toolError(503, "runtime console tools are unavailable") + } + consoles, err := s.Consoles(deployment) + if err != nil { + return nil, err + } + if _, ok := consoles[input.Console]; !ok { + return nil, toolError(404, "console is unavailable") + } + runtime, err := s.Get(deployment) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if name == "send_stdin" { + if len(input.Text) > 8192 || !utf8.ValidString(input.Text) { + return nil, toolError(400, "stdin must be valid UTF-8 and at most 8 KiB") + } + count, err := backend.WriteConsoleInput(ctx, runtime.Root, input.Console, []byte(input.Text)) + if err != nil { + return nil, toolError(409, "console input is unavailable") + } + return map[string]any{"console": input.Console, "bytes_accepted": count}, nil + } + tail, maxTail, maxBytes := input.TailLines, 1000, int64(64*1024) + if name == "search_logs" { + maxTail = 10000 + maxBytes = 1024 * 1024 + if tail == 0 { + tail = 1000 + } + if input.Query == "" || len(input.Query) > 4096 { + return nil, toolError(400, "query is required and must be at most 4 KiB") + } + } + if tail == 0 { + tail = 200 + } + if tail < 1 || tail > maxTail { + return nil, toolError(400, fmt.Sprintf("tail_lines must be between 1 and %d", maxTail)) + } + output, truncated, err := backend.ReadConsoleLogs(ctx, runtime.Root, input.Console, tail, maxBytes) + if err != nil { + return nil, toolError(409, "console output is unavailable") + } + output = workloadidentity.Redact(output) + if name == "read_logs" { + return map[string]any{"console": input.Console, "output": output, "truncated": truncated}, nil + } + matches := []map[string]any{} + bytes := 0 + query := strings.ToLower(input.Query) + lines := strings.Split(output, "\n") + for index, line := range lines { + if !strings.Contains(strings.ToLower(line), query) { + continue + } + if len(matches) >= 50 || bytes+len(line) > 64*1024 { + truncated = true + break + } + matches = append(matches, map[string]any{"line": index + 1, "text": line}) + bytes += len(line) + } + return map[string]any{"console": input.Console, "matches": matches, "scanned_lines": len(lines), "truncated": truncated}, nil +} diff --git a/apps/druid/core/services/command_tools_test.go b/apps/druid/core/services/command_tools_test.go new file mode 100644 index 00000000..f2d527da --- /dev/null +++ b/apps/druid/core/services/command_tools_test.go @@ -0,0 +1,417 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + "sync" + "testing" + + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" + coreservices "github.com/highcard-dev/daemon/internal/core/services" + "github.com/highcard-dev/daemon/internal/workloadidentity" +) + +func TestCreateCommandPersistsWithoutRunningAndCanExecute(t *testing.T) { + s, _, token := commandToolsFixture(t, "commands:create", "commands:list", "commands:run", "queue:read") + ctx := context.Background() + input := ToolInput{Command: "created", Instructions: &domain.CommandInstructionSet{Procedures: []*domain.Procedure{{Image: "busybox", Command: []string{"true"}}}}} + result, err := s.CallCommandTool(ctx, token, "tools", "create_command", input) + if err != nil || !strings.Contains(fmtJSON(t, result), `"created":true`) { + t.Fatalf("creation failed: %v", err) + } + if _, exists := s.sessions["tools"].Snapshot()["created"]; exists { + t.Fatal("creation started an execution") + } + stored, err := s.store.GetScroll("tools") + if err != nil { + t.Fatal(err) + } + reloaded, err := NewRuntimeSession(s.store, stored, s.sessions["tools"].runtimeBackend) + if err != nil { + t.Fatal(err) + } + if reloaded.scrollService.GetFile().Commands["created"] == nil || reloaded.scrollService.GetFile().Commands["created"].Identity != nil { + t.Fatal("definition did not persist without identity") + } + listed, err := s.CallCommandTool(ctx, token, "tools", "list_commands", ToolInput{}) + if err != nil || !strings.Contains(fmtJSON(t, listed), "created") { + t.Fatal("new command was not listed") + } + run, err := s.CallCommandTool(ctx, token, "tools", "run_command", ToolInput{Command: "created"}) + if err != nil { + t.Fatal(err) + } + _, err = s.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "created", ExecutionID: run.(map[string]any)["execution_id"].(string), TimeoutSeconds: 1}) + if err != nil { + t.Fatal(err) + } +} + +func TestCreateCommandRejectionsAndConcurrentDuplicates(t *testing.T) { + s, backend, token := commandToolsFixture(t, "commands:create") + ctx := context.Background() + definition := &domain.CommandInstructionSet{Procedures: []*domain.Procedure{{Image: "busybox", Command: []string{"true"}}}} + for _, test := range []struct { + input ToolInput + status int + }{ + {ToolInput{Command: "agent", Instructions: definition}, 409}, + {ToolInput{Instructions: definition}, 400}, + {ToolInput{Command: "missing"}, 400}, + {ToolInput{Command: "invalid", Instructions: &domain.CommandInstructionSet{}}, 400}, + {ToolInput{Command: "identity", Instructions: &domain.CommandInstructionSet{Procedures: definition.Procedures, Identity: &domain.CommandIdentity{}}}, 400}, + } { + _, err := s.CallCommandTool(ctx, token, "tools", "create_command", test.input) + var failure *ToolError + if !errors.As(err, &failure) || failure.Status != test.status { + t.Fatalf("%s: expected %d, got %v", test.input.Command, test.status, err) + } + } + if _, err := backend.registry.Lookup(ctx, token); err != nil { + t.Fatal("duplicate creation disturbed existing execution identity") + } + if _, err := s.CallCommandTool(ctx, token, "other", "create_command", ToolInput{Command: "new", Instructions: definition}); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("cross-deployment creation accepted") + } + var wg sync.WaitGroup + errorsCh := make(chan error, 8) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + _, err := s.CallCommandTool(ctx, token, "tools", "create_command", ToolInput{Command: "concurrent", Instructions: definition}) + errorsCh <- err + }() + } + wg.Wait() + close(errorsCh) + created := 0 + for err := range errorsCh { + if err == nil { + created++ + } else { + var failure *ToolError + if !errors.As(err, &failure) || failure.Status != 409 { + t.Fatalf("unexpected concurrent error: %v", err) + } + } + } + if created != 1 { + t.Fatalf("created %d commands, expected exactly one", created) + } + denied, _, deniedToken := commandToolsFixture(t, "commands:run") + if _, err := denied.CallCommandTool(ctx, deniedToken, "tools", "create_command", ToolInput{Command: "new", Instructions: definition}); err == nil { + t.Fatal("commands:run granted command creation") + } +} + +func fmtJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestPublishUIToolPermissionsProjectionAndPaths(t *testing.T) { + s, backend, token := commandToolsFixture(t, "ui:publish") + s.sessions["tools"].Start() + ctx := context.Background() + var commands []string + backend.runCommand = func(command ports.RuntimeCommand) (*int, error) { + commands = append(commands, command.Name) + return nil, nil + } + for _, input := range []ToolInput{{Scope: "private"}, {Scope: "public"}, {Scope: "private", Path: "private/custom/widget.wasm"}} { + result, err := s.CallCommandTool(ctx, token, "tools", "publish_ui", input) + if err != nil { + t.Fatal(err) + } + data := fmtJSON(t, result) + if !strings.Contains(data, `"published":true`) || !strings.Contains(data, `"updated_at"`) || strings.Contains(data, "uploads/") || strings.Contains(data, "ScrollYAML") || strings.Contains(data, token) { + t.Fatalf("unsafe or incomplete publication response: %s", data) + } + wantPath := input.Path + if wantPath == "" { + wantPath = input.Scope + "/dist/app.wasm" + } + if !strings.Contains(data, wantPath) { + t.Fatal("wrong published path") + } + } + for _, command := range commands { + if !strings.HasPrefix(command, "ui_publish_") { + t.Fatalf("publication unexpectedly built: %s", command) + } + } + for _, input := range []ToolInput{{}, {Scope: "other"}, {Scope: "private", Path: "../outside.wasm"}, {Scope: "private", Path: "public/app.wasm"}, {Scope: "private", Path: "data/private/app.wasm"}, {Scope: "private", Path: "private/app.js"}} { + _, err := s.CallCommandTool(ctx, token, "tools", "publish_ui", input) + var failure *ToolError + if !errors.As(err, &failure) || failure.Status != 400 { + t.Fatalf("invalid request accepted: %#v, %v", input, err) + } + } + if _, err := s.CallCommandTool(ctx, token, "other", "publish_ui", ToolInput{Scope: "private"}); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("cross-deployment publish accepted") + } + previous, _ := s.UIPackages("tools") + backend.runCommand = func(ports.RuntimeCommand) (*int, error) { return nil, errors.New("fixture upload failed") } + if _, err := s.CallCommandTool(ctx, token, "tools", "publish_ui", ToolInput{Scope: "private"}); err == nil { + t.Fatal("upload failure ignored") + } + current, _ := s.UIPackages("tools") + if current[domain.RuntimeUIPackageScopePrivate] != previous[domain.RuntimeUIPackageScopePrivate] { + t.Fatal("failed publish replaced previous package") + } + if err := backend.registry.Revoke(ctx, "agent-execution"); err != nil { + t.Fatal(err) + } + if _, err := s.CallCommandTool(ctx, token, "tools", "publish_ui", ToolInput{Scope: "private"}); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("revoked publish accepted") + } + denied, _, deniedToken := commandToolsFixture(t, "commands:run") + if _, err := denied.CallCommandTool(ctx, deniedToken, "tools", "publish_ui", ToolInput{Scope: "private"}); err == nil { + t.Fatal("commands:run granted publishing") + } +} + +type toolTestBackend struct { + *fakeWorkerBackend + registry workloadidentity.Registry + logs string + input string +} + +func (b *toolTestBackend) WorkloadIdentities() workloadidentity.Registry { return b.registry } +func (b *toolTestBackend) ActiveExecutions(context.Context) (map[string]bool, error) { + return map[string]bool{}, nil +} +func (b *toolTestBackend) ReadConsoleLogs(_ context.Context, _, _ string, _ int, _ int64) (string, bool, error) { + return b.logs, false, nil +} +func (b *toolTestBackend) WriteConsoleInput(_ context.Context, _, _ string, data []byte) (int, error) { + b.input = string(data) + return len(data), nil +} +func (b *toolTestBackend) Create(observer ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { + b.procedureStatusObserver = observer + return b, nil +} + +func commandToolsFixture(t *testing.T, permissions ...string) (*RuntimeSupervisor, *toolTestBackend, string) { + t.Helper() + registry, err := workloadidentity.NewFileRegistry(t.TempDir()) + if err != nil { + t.Fatal(err) + } + backend := &toolTestBackend{fakeWorkerBackend: &fakeWorkerBackend{}, registry: registry, logs: "hello\nERROR fixture\n"} + store := newTestStateStore(t) + runtime := &domain.RuntimeScroll{ID: "tools", Root: t.TempDir(), ScrollName: "tools", ScrollYAML: `name: tools +desc: Tools fixture +version: 0.1.0 +app_version: "1.0" +commands: + agent: + run: persistent + identity: + permissions: [commands:list] + procedures: + - id: agent-console + image: busybox + env: {SECRET: never-expose-this} + command: [sh, -c, private-shell-argument] + echo: + procedures: + - image: busybox + command: [true] +`, Procedures: domain.ProcedureStatusMap{"agent": {"agent-console": {ExecutionID: "agent-execution", Status: domain.ScrollLockStatusRunning}}}} + if err := store.CreateScroll(runtime); err != nil { + t.Fatal(err) + } + supervisor := newRuntimeSupervisorForTest(t, store, coreservices.NewRuntimeScrollManager(store), backend) + session, err := NewRuntimeSession(store, runtime, backend) + if err != nil { + t.Fatal(err) + } + supervisor.sessions[runtime.ID] = session + t.Cleanup(session.stopDeploymentQueue) + mount, err := registry.Ensure(context.Background(), workloadidentity.Record{ExecutionID: "agent-execution", DeploymentID: "tools", Command: "agent", Permissions: permissions}) + if err != nil { + t.Fatal(err) + } + token, err := os.ReadFile(mount.Path) + if err != nil { + t.Fatal(err) + } + return supervisor, backend, string(token) +} + +func TestCommandToolsPermissionsProjectionAndConsoles(t *testing.T) { + supervisor, backend, token := commandToolsFixture(t, "commands:list", "logs:read", "console:write") + ctx := context.Background() + listed, err := supervisor.CallCommandTool(ctx, token, "tools", "list_commands", ToolInput{}) + if err != nil { + t.Fatal(err) + } + text := fmtJSON(t, listed) + if strings.Contains(text, "never-expose") || strings.Contains(text, "private-shell") || strings.Contains(text, token) { + t.Fatal("private command fields exposed") + } + if _, err := supervisor.CallCommandTool(ctx, token, "other", "list_commands", ToolInput{}); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("cross-deployment request accepted") + } + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "get_queue", ToolInput{}); err == nil { + t.Fatal("missing permission accepted") + } + backend.logs += token + result, err := supervisor.CallCommandTool(ctx, token, "tools", "read_logs", ToolInput{Console: "agent-console"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(fmtJSON(t, result), token) || !strings.Contains(fmtJSON(t, result), "[REDACTED]") { + t.Fatal("token not redacted") + } + result, err = supervisor.CallCommandTool(ctx, token, "tools", "search_logs", ToolInput{Console: "agent-console", Query: "error"}) + if err != nil || !strings.Contains(fmtJSON(t, result), "ERROR fixture") { + t.Fatal("literal search failed") + } + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "send_stdin", ToolInput{Console: "agent-console", Text: "list\n"}); err != nil || backend.input != "list\n" { + t.Fatal("exact stdin bytes were not written") + } + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "read_logs", ToolInput{Console: "agent-console", TailLines: 1001}); err == nil { + t.Fatal("unbounded tail accepted") + } + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "send_stdin", ToolInput{Console: "agent-console", Text: strings.Repeat("a", 8193)}); err == nil { + t.Fatal("oversized stdin accepted") + } + if err := backend.registry.Revoke(ctx, "agent-execution"); err != nil { + t.Fatal(err) + } + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "list_commands", ToolInput{}); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("revoked token accepted") + } +} + +func TestCommandToolsEnqueueWaitAndReplacedExecution(t *testing.T) { + supervisor, _, token := commandToolsFixture(t, "commands:run", "queue:read") + ctx := context.Background() + result, err := supervisor.CallCommandTool(ctx, token, "tools", "run_command", ToolInput{Command: "echo"}) + if err != nil { + t.Fatal(err) + } + execution := result.(map[string]any)["execution_id"].(string) + if execution == "" { + t.Fatal("missing execution ID") + } + waited, err := supervisor.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "echo", ExecutionID: execution, TimeoutSeconds: 1}) + if err != nil || waited.(map[string]any)["timed_out"] != false { + t.Fatalf("execution did not complete: %v", err) + } + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "echo", ExecutionID: "different", TimeoutSeconds: 1}); err == nil { + t.Fatal("replaced execution accepted") + } + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "run_command", ToolInput{Command: "unknown"}); err == nil { + t.Fatal("unknown command accepted") + } +} + +func TestLateProcedureEventCannotOverwriteReplacementExecution(t *testing.T) { + supervisor, _, _ := commandToolsFixture(t) + session := supervisor.sessions["tools"] + session.beginExecution("agent", "replacement") + supervisor.ObserveProcedureStatus(ports.ProcedureStatusUpdate{RuntimeID: "tools", Command: "agent", Procedure: "agent-console", ExecutionID: "agent-execution", Status: domain.ScrollLockStatusError}) + status := session.Snapshot()["agent"]["agent-console"] + if status.ExecutionID != "replacement" || status.Status != domain.ScrollLockStatusWaiting { + t.Fatal("stale event overwrote replacement") + } +} + +func TestReplacingCommandRevokesOldIdentityWithoutUpgradingItsGrants(t *testing.T) { + supervisor, backend, token := commandToolsFixture(t, "commands:list") + definition, err := supervisor.ScrollFile("tools") + if err != nil { + t.Fatal(err) + } + replacement := *definition.Commands["agent"] + replacement.Identity = &domain.CommandIdentity{Permissions: []string{"commands:list", "console:write"}} + if err := supervisor.AddCommand("tools", "agent", &replacement); err != nil { + t.Fatal(err) + } + if _, err := backend.registry.Lookup(context.Background(), token); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("replacement retained old credential") + } + if len(supervisor.sessions["tools"].Snapshot()["agent"]) != 0 { + t.Fatal("replacement kept old execution status") + } +} + +func TestCommandIdentityRevokedOnStopDeletionAndStaleResourceReconciliation(t *testing.T) { + for _, operation := range []string{"stop", "delete", "reconcile"} { + t.Run(operation, func(t *testing.T) { + supervisor, backend, token := commandToolsFixture(t, "commands:list") + var err error + switch operation { + case "stop": + _, err = supervisor.Stop("tools") + case "delete": + err = supervisor.DeleteWithPolicy("tools", false) + case "reconcile": + err = supervisor.ReconcileCommandIdentities(context.Background()) + } + if err != nil { + t.Fatal(err) + } + if _, err := backend.registry.Lookup(context.Background(), token); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("credential survived " + operation) + } + }) + } +} + +func TestCommandIdentityFailedProvisioningRevokesAndRemovesCredential(t *testing.T) { + supervisor, backend, _ := commandToolsFixture(t) + session := supervisor.sessions["tools"] + session.workloadAPIURL = "http://workload.test" + command := &domain.CommandInstructionSet{Identity: &domain.CommandIdentity{Permissions: []string{"logs:read"}}, Procedures: []*domain.Procedure{{Image: "busybox"}}} + if err := supervisor.AddCommand("tools", "fail", command); err != nil { + t.Fatal(err) + } + var tokenPath, token string + backend.runCommand = func(command ports.RuntimeCommand) (*int, error) { + identity := command.Command.Procedures[0].RuntimeIdentity + if identity == nil { + t.Fatal("identity not provisioned") + } + tokenPath = identity.TokenPath + data, err := os.ReadFile(tokenPath) + if err != nil { + t.Error(err) + } + token = string(data) + for _, env := range command.ProcedureEnv { + for key, value := range env { + if value == token { + t.Errorf("raw token injected in environment %s", key) + } + } + } + return nil, errors.New("fixture provisioning failure") + } + if _, err := supervisor.RunAndWait("tools", "fail"); err == nil { + t.Fatal("provisioning failure swallowed") + } + if token == "" { + t.Fatal("provisioning path not exercised") + } + if _, err := backend.registry.Lookup(context.Background(), token); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("failed provisioning left credential active") + } + if _, err := os.Stat(tokenPath); !os.IsNotExist(err) { + t.Fatal("failed provisioning left raw credential file") + } +} diff --git a/apps/druid/core/services/runtime_commands.go b/apps/druid/core/services/runtime_commands.go index 04882fa3..3bf49e27 100644 --- a/apps/druid/core/services/runtime_commands.go +++ b/apps/druid/core/services/runtime_commands.go @@ -2,6 +2,7 @@ package services import ( "fmt" + "reflect" "strings" "github.com/highcard-dev/daemon/internal/core/domain" @@ -12,6 +13,8 @@ import ( // AddCommand persists a normal runtime command and makes it available to the // command scheduler. It is intentionally independent from development mode. func (s *RuntimeSupervisor) AddCommand(id string, command string, instruction *domain.CommandInstructionSet) error { + unlock := s.lockRuntimeOperation(id) + defer unlock() session, err := s.sessionFor(id) if err != nil { return err @@ -20,6 +23,8 @@ func (s *RuntimeSupervisor) AddCommand(id string, command string, instruction *d } func (s *RuntimeSupervisor) RemoveCommand(id string, command string) error { + unlock := s.lockRuntimeOperation(id) + defer unlock() session, err := s.sessionFor(id) if err != nil { return err @@ -36,7 +41,7 @@ func (s *RuntimeSession) AddCommand(command string, instruction *domain.CommandI return fmt.Errorf("command instruction is required") } s.mu.Lock() - defer s.mu.Unlock() + existing := s.scrollService.GetFile().Commands[command] file := *s.scrollService.GetFile() file.Commands = make(map[string]*domain.CommandInstructionSet, len(s.scrollService.GetFile().Commands)+1) for name, existing := range s.scrollService.GetFile().Commands { @@ -46,6 +51,7 @@ func (s *RuntimeSession) AddCommand(command string, instruction *domain.CommandI file.Commands = map[string]*domain.CommandInstructionSet{} } file.Commands[command] = instruction + s.mu.Unlock() data, err := yaml.Marshal(file) if err != nil { return err @@ -54,6 +60,15 @@ func (s *RuntimeSession) AddCommand(command string, instruction *domain.CommandI if err != nil { return err } + // A changed definition is a replacement, never an upgrade of the existing + // execution's permissions. Validate first, then stop and revoke that run. + if existing != nil && !reflect.DeepEqual(existing, instruction) { + if err := s.RemoveCommand(command); err != nil { + return err + } + } + s.mu.Lock() + defer s.mu.Unlock() s.runtimeScroll.ScrollYAML = string(data) if err := s.store.UpdateScroll(s.runtimeScroll); err != nil { return err @@ -66,6 +81,9 @@ func (s *RuntimeSession) RemoveCommand(command string) error { if command == "" { return fmt.Errorf("command is required") } + if err := s.revokeCommandIdentities(command); err != nil { + return err + } s.runMu.Lock() defer s.runMu.Unlock() diff --git a/apps/druid/core/services/runtime_identity.go b/apps/druid/core/services/runtime_identity.go new file mode 100644 index 00000000..0d1f3e96 --- /dev/null +++ b/apps/druid/core/services/runtime_identity.go @@ -0,0 +1,135 @@ +package services + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" + "github.com/highcard-dev/daemon/internal/workloadidentity" +) + +func commandRegistry(backend ports.RuntimeBackendInterface) workloadidentity.Registry { + if provider, ok := backend.(ports.CommandIdentityBackend); ok { + return provider.WorkloadIdentities() + } + return nil +} + +func (s *RuntimeSupervisor) SetWorkloadAPIURL(base string) error { + if base != "" { + parsed, err := url.Parse(base) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("workload API URL must be an explicit HTTP(S) base URL") + } + } + s.workloadAPIURL = strings.TrimRight(base, "/") + return nil +} + +func (s *RuntimeSession) beginExecution(command, execution string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.runtimeScroll.Procedures == nil { + s.runtimeScroll.Procedures = domain.ProcedureStatusMap{} + } + definition := s.scrollService.GetFile().Commands[command] + if definition == nil { + return fmt.Errorf("command is unavailable") + } + statuses := map[string]domain.LockStatus{} + for index, procedure := range definition.Procedures { + statuses[domain.ProcedureName(command, index, procedure)] = domain.LockStatus{ExecutionID: execution, Status: domain.ScrollLockStatusWaiting, LastStatusChange: time.Now().Unix()} + } + s.runtimeScroll.Procedures[command] = statuses + return s.store.UpdateScroll(s.runtimeScroll) +} + +func (s *RuntimeSession) assignExecutionIdentity(command, execution string) error { + s.mu.Lock() + defer s.mu.Unlock() + for procedure, status := range s.runtimeScroll.Procedures[command] { + status.ExecutionID = execution + s.runtimeScroll.Procedures[command][procedure] = status + } + return s.store.UpdateScroll(s.runtimeScroll) +} + +func (s *RuntimeSession) revokeExecution(execution string) error { + if registry := commandRegistry(s.runtimeBackend); registry != nil && execution != "" { + return registry.Revoke(context.Background(), execution) + } + return nil +} + +func (s *RuntimeSession) revokeCommandIdentities(command string) error { + seen := map[string]bool{} + for name, procedures := range s.Snapshot() { + if command != "" && name != command { + continue + } + definition := s.scrollService.GetFile().Commands[name] + if definition == nil || definition.Identity == nil { + continue + } + for _, status := range procedures { + if status.ExecutionID != "" && !seen[status.ExecutionID] { + seen[status.ExecutionID] = true + if err := s.revokeExecution(status.ExecutionID); err != nil { + return err + } + } + } + } + return nil +} + +func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token string, deployment string) (workloadidentity.Record, error) { + registry := commandRegistry(s.runtimeBackend) + if registry == nil { + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + } + record, err := registry.Lookup(ctx, token) + if err != nil { + return workloadidentity.Record{}, err + } + if record.DeploymentID != deployment { + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + } + return record, nil +} + +// ReconcileCommandIdentities is safe to retry: registry removal precedes secret +// cleanup, and only the runtime resources determine surviving executions. +func (s *RuntimeSupervisor) ReconcileCommandIdentities(ctx context.Context) error { + backend, ok := s.runtimeBackend.(ports.CommandIdentityBackend) + if !ok || backend.WorkloadIdentities() == nil { + return nil + } + // Snapshot records first: credentials provisioned after this snapshot must + // not be revoked using an older resource/provisioning snapshot. + records, err := backend.WorkloadIdentities().Records(ctx) + if err != nil { + return err + } + active, err := backend.ActiveExecutions(ctx) + if err != nil { + return err + } + s.mu.Lock() + for _, session := range s.sessions { + session.provisioning.Range(func(key, value any) bool { active[key.(string)] = true; return true }) + } + s.mu.Unlock() + for _, record := range records { + if !active[record.ExecutionID] { + if err := backend.WorkloadIdentities().Revoke(ctx, record.ExecutionID); err != nil { + return err + } + } + } + return backend.WorkloadIdentities().Prune(ctx, active) +} diff --git a/apps/druid/core/services/runtime_lifecycle.go b/apps/druid/core/services/runtime_lifecycle.go index e6870c30..151bd75b 100644 --- a/apps/druid/core/services/runtime_lifecycle.go +++ b/apps/druid/core/services/runtime_lifecycle.go @@ -1,6 +1,9 @@ package services -import "github.com/highcard-dev/daemon/internal/core/domain" +import ( + "context" + "github.com/highcard-dev/daemon/internal/core/domain" +) func (s *RuntimeSupervisor) Delete(id string) error { return s.DeleteWithPolicy(id, false) @@ -14,6 +17,9 @@ func (s *RuntimeSupervisor) DeleteWithPolicy(id string, purgeData bool) error { delete(s.sessions, id) s.mu.Unlock() if session != nil { + if err := session.revokeCommandIdentities(""); err != nil { + return err + } session.stopDeploymentQueue() } @@ -22,6 +28,19 @@ func (s *RuntimeSupervisor) DeleteWithPolicy(id string, purgeData bool) error { return err } if runtimeScroll.Root != "" { + if registry := commandRegistry(s.runtimeBackend); registry != nil { + records, err := registry.Records(context.Background()) + if err != nil { + return err + } + for _, record := range records { + if record.DeploymentID == id { + if err := registry.Revoke(context.Background(), record.ExecutionID); err != nil { + return err + } + } + } + } if err := s.runtimeBackend.DeleteRuntime(runtimeScroll.Root, purgeData); err != nil { return err } diff --git a/apps/druid/core/services/runtime_session.go b/apps/druid/core/services/runtime_session.go index 52f2cacd..af18042d 100644 --- a/apps/druid/core/services/runtime_session.go +++ b/apps/druid/core/services/runtime_session.go @@ -14,6 +14,8 @@ import ( // the command queue and cached scroll.yaml; storage and containers stay behind // the runtime backend. type RuntimeSession struct { + workloadAPIURL string + provisioning sync.Map store ports.RuntimeScrollStore runtimeScroll *domain.RuntimeScroll scrollService *coreservices.ScrollService diff --git a/apps/druid/core/services/runtime_session_cache.go b/apps/druid/core/services/runtime_session_cache.go index 19145426..1fd58c50 100644 --- a/apps/druid/core/services/runtime_session_cache.go +++ b/apps/druid/core/services/runtime_session_cache.go @@ -64,6 +64,7 @@ func (s *RuntimeSupervisor) startSession(runtimeScroll *domain.RuntimeScroll) (* if err != nil { return nil, err } + session.workloadAPIURL = s.workloadAPIURL s.mu.Lock() if existing := s.sessions[runtimeScroll.ID]; existing != nil { diff --git a/apps/druid/core/services/runtime_session_commands.go b/apps/druid/core/services/runtime_session_commands.go index 0b2e8ff2..5a17eee5 100644 --- a/apps/druid/core/services/runtime_session_commands.go +++ b/apps/druid/core/services/runtime_session_commands.go @@ -230,6 +230,7 @@ func (s *RuntimeSession) persistCommandStatus(command string, status domain.Scro for idx, procedure := range commandDefinition.Procedures { procedureName := domain.ProcedureName(command, idx, procedure) s.runtimeScroll.Procedures[command][procedureName] = domain.LockStatus{ + ExecutionID: s.runtimeScroll.Procedures[command][procedureName].ExecutionID, Status: status, ExitCode: exitCode, LastStatusChange: time.Now().Unix(), @@ -252,6 +253,10 @@ func (s *RuntimeSession) SetCommandStatus(command string, status domain.ScrollLo } func (s *RuntimeSession) persistProcedureStatus(command string, procedure string, status domain.ScrollLockStatus, exitCode *int) { + s.persistProcedureExecutionStatus(command, procedure, status, exitCode, "") +} + +func (s *RuntimeSession) persistProcedureExecutionStatus(command string, procedure string, status domain.ScrollLockStatus, exitCode *int, executionID string) { s.mu.Lock() defer s.mu.Unlock() commands := s.scrollService.GetFile().Commands @@ -264,7 +269,12 @@ func (s *RuntimeSession) persistProcedureStatus(command string, procedure string if s.runtimeScroll.Procedures[command] == nil { s.runtimeScroll.Procedures[command] = map[string]domain.LockStatus{} } + currentExecution := s.runtimeScroll.Procedures[command][procedure].ExecutionID + if executionID != "" && currentExecution != "" && executionID != currentExecution { + return + } s.runtimeScroll.Procedures[command][procedure] = domain.LockStatus{ + ExecutionID: currentExecution, Status: status, ExitCode: exitCode, LastStatusChange: time.Now().Unix(), diff --git a/apps/druid/core/services/runtime_session_execution.go b/apps/druid/core/services/runtime_session_execution.go index e455d0b8..ef31c5ae 100644 --- a/apps/druid/core/services/runtime_session_execution.go +++ b/apps/druid/core/services/runtime_session_execution.go @@ -1,12 +1,17 @@ package services import ( + "context" "errors" + "fmt" + "github.com/google/uuid" + "net/url" "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/ports" coreservices "github.com/highcard-dev/daemon/internal/core/services" "github.com/highcard-dev/daemon/internal/utils/logger" + "github.com/highcard-dev/daemon/internal/workloadidentity" "go.uber.org/zap" ) @@ -68,17 +73,74 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin procedureEnv[procedure][key] = value } } + executionID := "" + s.queueMu.Lock() + recovering := s.queue[cmd] != nil && s.queue[cmd].recovered + s.queueMu.Unlock() + for _, status := range s.Snapshot()[cmd] { + if status.ExecutionID != "" { + executionID = status.ExecutionID + break + } + } + if executionID == "" { + if recovering && command.Identity != nil { + return fmt.Errorf("identity-enabled command requires a fresh execution") + } + executionID = uuid.NewString() + if err := s.assignExecutionIdentity(cmd, executionID); err != nil { + return err + } + } + identity := &domain.RuntimeCommandIdentity{ExecutionID: executionID} + s.provisioning.Store(executionID, true) + defer s.provisioning.Delete(executionID) + if command.Identity != nil { + registry := commandRegistry(s.runtimeBackend) + if registry == nil || s.workloadAPIURL == "" { + return fmt.Errorf("command identity requires a registry and workload API URL") + } + mount, err := registry.Ensure(context.Background(), workloadidentity.Record{RuntimeRoot: root, Recover: recovering, ExecutionID: executionID, DeploymentID: scrollID, Command: cmd, Permissions: command.Identity.Permissions}) + if err != nil { + return err + } + identity.SecretName, identity.TokenPath = mount.Secret, mount.Path + } + // Never mutate the persisted Scroll or leak transient mount references. + copyCommand := *command + copyCommand.Procedures = make([]*domain.Procedure, len(command.Procedures)) + for index, procedure := range command.Procedures { + copyProcedure := *procedure + copyProcedure.RuntimeIdentity = identity + copyCommand.Procedures[index] = ©Procedure + name := domain.ProcedureName(cmd, index, procedure) + if procedureEnv[name] == nil { + procedureEnv[name] = map[string]string{} + } + procedureEnv[name]["DRUID_EXECUTION_ID"] = executionID + if command.Identity != nil { + procedureEnv[name]["DRUID_TOKEN_FILE"] = workloadidentity.TokenFile + procedureEnv[name]["DRUID_API_URL"] = s.workloadAPIURL + "/" + url.PathEscape(scrollID) + } + } + if command.Identity != nil && command.Run != domain.RunModePersistent { + defer s.revokeExecution(executionID) + } exitCode, err := s.runtimeBackend.RunCommand(ports.RuntimeCommand{ + ExecutionID: executionID, Name: cmd, ScrollID: scrollID, - Command: command, + Command: ©Command, Root: root, Ports: runtimePorts, Routing: routing, ProcedureEnv: procedureEnv, }) if err != nil { + if command.Identity != nil { + _ = s.revokeExecution(executionID) + } s.setCommandProcedureStatus(cmd, command, domain.ScrollLockStatusError, exitCode) return err } diff --git a/apps/druid/core/services/runtime_session_queue.go b/apps/druid/core/services/runtime_session_queue.go index d1104195..2b241488 100644 --- a/apps/druid/core/services/runtime_session_queue.go +++ b/apps/druid/core/services/runtime_session_queue.go @@ -3,6 +3,7 @@ package services import ( "context" "errors" + "github.com/google/uuid" "time" "github.com/highcard-dev/daemon/internal/core/domain" @@ -12,6 +13,8 @@ import ( ) type runtimeQueueItem struct { + recovered bool + executionID string err error doneChan chan struct{} inFlight bool @@ -67,6 +70,10 @@ func (s *RuntimeSession) addQueueItemWithEnv(cmd string, options coreservices.Ad // enqueueQueueItem separates admission from waiting. Supervisors serialize // admission with maintenance without holding a lock while a command runs. func (s *RuntimeSession) enqueueQueueItem(cmd string, options coreservices.AddItemOptions, procedureEnv map[string]map[string]string) (func() error, error) { + return s.enqueueExecution(cmd, options, procedureEnv, uuid.NewString()) +} + +func (s *RuntimeSession) enqueueExecution(cmd string, options coreservices.AddItemOptions, procedureEnv map[string]map[string]string, executionID string) (func() error, error) { logger.Log().Debug("Running command", zap.String("cmd", cmd)) command, err := s.scrollService.GetCommand(cmd) @@ -93,7 +100,11 @@ func (s *RuntimeSession) enqueueQueueItem(cmd string, options coreservices.AddIt if options.Wait { doneChan = make(chan struct{}) } - item = &runtimeQueueItem{doneChan: doneChan, procedureEnv: procedureEnv} + item = &runtimeQueueItem{doneChan: doneChan, procedureEnv: procedureEnv, executionID: executionID} + if err := s.beginExecution(cmd, item.executionID); err != nil { + s.queueMu.Unlock() + return nil, err + } s.queue[cmd] = item s.setQueueStatusLocked(cmd, item, domain.ScrollLockStatusWaiting, nil) s.queueMu.Unlock() @@ -153,6 +164,13 @@ func (s *RuntimeSession) rememberHydratedItem(cmd string) { item.err = nil item.rememberDone = false item.runRequested = true + item.recovered = true + for _, status := range s.Snapshot()[cmd] { + if status.ExecutionID != "" { + item.executionID = status.ExecutionID + break + } + } } func (s *RuntimeSession) triggerRunQueue() { @@ -275,6 +293,15 @@ func (s *RuntimeSession) RunQueue() { if isPersistentMode { logger.Log().Info("Persistent command running", zap.String("command", c)) } else if isRestartMode { + s.queueMu.Lock() + i.executionID = uuid.NewString() + i.recovered = false + identityErr := s.beginExecution(c, i.executionID) + s.queueMu.Unlock() + if identityErr != nil { + s.setQueueError(c, identityErr) + return + } s.setQueueStatus(c, domain.ScrollLockStatusWaiting, nil) s.queueMu.Lock() if time.Since(startedAt) < 30*time.Second { diff --git a/apps/druid/core/services/runtime_session_runtime.go b/apps/druid/core/services/runtime_session_runtime.go index a834ddc9..21fa6201 100644 --- a/apps/druid/core/services/runtime_session_runtime.go +++ b/apps/druid/core/services/runtime_session_runtime.go @@ -88,6 +88,9 @@ func (s *RuntimeSession) StopRuntimeForMaintenance() error { } func (s *RuntimeSession) stopRuntime(resumeQueue bool) error { + if err := s.revokeCommandIdentities(""); err != nil { + return err + } s.mu.Lock() root := s.runtimeScroll.Root started := s.started diff --git a/apps/druid/core/services/runtime_supervisor.go b/apps/druid/core/services/runtime_supervisor.go index 2cfa7461..72e16fe6 100644 --- a/apps/druid/core/services/runtime_supervisor.go +++ b/apps/druid/core/services/runtime_supervisor.go @@ -20,6 +20,7 @@ import ( // truth and session lifetimes; Docker/Kubernetes resource details stay behind // the runtime backend. type RuntimeSupervisor struct { + workloadAPIURL string store ports.RuntimeScrollStore manager *coreservices.RuntimeScrollManager runtimeBackend ports.RuntimeBackendInterface @@ -48,6 +49,7 @@ var developerPorts = []domain.Port{ {Name: "webdav", Port: 8084, Protocol: "http"}, {Name: "vscode", Port: 3333, Protocol: "http"}, {Name: "ssh", Port: 2222, Protocol: "tcp"}, + {Name: "openhands", Port: 8000, Protocol: "http"}, } func fixedDeveloperPorts() []domain.Port { @@ -88,7 +90,14 @@ func (s *RuntimeSupervisor) ObserveProcedureStatus(update ports.ProcedureStatusU logger.Log().Warn("Ignoring procedure status for an unknown runtime", zap.String("runtime", update.RuntimeID), zap.String("command", update.Command), zap.String("procedure", update.Procedure), zap.String("status", string(update.Status))) return } - session.persistProcedureStatus(update.Command, update.Procedure, update.Status, update.ExitCode) + session.persistProcedureExecutionStatus(update.Command, update.Procedure, update.Status, update.ExitCode, update.ExecutionID) + command := session.scrollService.GetFile().Commands[update.Command] + if command != nil && command.Identity != nil && update.ExecutionID != "" && (update.Status == domain.ScrollLockStatusDone || update.Status == domain.ScrollLockStatusError) { + status, ok := deriveCommandStatus(session.Snapshot()[update.Command], update.Command, session.scrollService.GetFile().Commands[update.Command]) + if ok && (status == domain.ScrollLockStatusDone || status == domain.ScrollLockStatusError) { + _ = session.revokeExecution(update.ExecutionID) + } + } } func (s *RuntimeSupervisor) SetWorkerCallbacks(callbacks *WorkerCallbackManager, callbackURL string) { diff --git a/apps/druid/core/services/runtime_supervisor_test.go b/apps/druid/core/services/runtime_supervisor_test.go index 26a509d6..9d62a566 100644 --- a/apps/druid/core/services/runtime_supervisor_test.go +++ b/apps/druid/core/services/runtime_supervisor_test.go @@ -697,7 +697,7 @@ func TestRuntimeSupervisorCreateCanCreate(t *testing.T) { if runtimeScroll.Status != domain.RuntimeScrollStatusCreated { t.Fatalf("status = %s, want created", runtimeScroll.Status) } - if got := runtimeScroll.ReservedPorts; len(got) != 3 || got[0] != (domain.Port{Name: "webdav", Port: 8084, Protocol: "http"}) || got[1] != (domain.Port{Name: "vscode", Port: 3333, Protocol: "http"}) || got[2] != (domain.Port{Name: "ssh", Port: 2222, Protocol: "tcp"}) { + if got := runtimeScroll.ReservedPorts; len(got) != 4 || got[3] != (domain.Port{Name: "openhands", Port: 8000, Protocol: "http"}) || got[0] != (domain.Port{Name: "webdav", Port: 8084, Protocol: "http"}) || got[1] != (domain.Port{Name: "vscode", Port: 3333, Protocol: "http"}) || got[2] != (domain.Port{Name: "ssh", Port: 2222, Protocol: "tcp"}) { t.Fatalf("reserved developer ports = %#v", got) } if len(runtimeScroll.Procedures) != 0 { @@ -1355,6 +1355,7 @@ func TestRuntimeSupervisorStartResumesMaintenanceStoppedQueue(t *testing.T) { if err != nil { t.Fatal(err) } + t.Cleanup(session.stopDeploymentQueue) if err := session.StopRuntimeForMaintenance(); err != nil { t.Fatal(err) } diff --git a/apps/druid/core/services/runtime_ui_test.go b/apps/druid/core/services/runtime_ui_test.go index cd8272d5..eccae72b 100644 --- a/apps/druid/core/services/runtime_ui_test.go +++ b/apps/druid/core/services/runtime_ui_test.go @@ -123,6 +123,9 @@ func TestUIPublishRequiresBothAssetsAndStopsOnUploadFailure(t *testing.T) { wantUploads int }{ {"missing document", false, false, true, 0}, + {"missing wasm", true, false, true, 0}, + {"empty wasm", true, false, true, 0}, + {"empty document", true, false, true, 0}, {"document upload fails", true, true, true, 1}, {"complete package", true, false, false, 2}, } { @@ -131,11 +134,21 @@ func TestUIPublishRequiresBothAssetsAndStopsOnUploadFailure(t *testing.T) { if err := os.Mkdir(filepath.Join(dir, "private"), 0755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "private", "app.wasm"), []byte("wasm"), 0644); err != nil { - t.Fatal(err) + if test.name != "missing wasm" { + data := []byte("wasm") + if test.name == "empty wasm" { + data = nil + } + if err := os.WriteFile(filepath.Join(dir, "private", "app.wasm"), data, 0644); err != nil { + t.Fatal(err) + } } if test.document { - if err := os.WriteFile(filepath.Join(dir, "private", "app.openui"), []byte("root = Text(\"Test\")"), 0644); err != nil { + data := []byte("root = Text(\"Test\")") + if test.name == "empty document" { + data = nil + } + if err := os.WriteFile(filepath.Join(dir, "private", "app.openui"), data, 0644); err != nil { t.Fatal(err) } } diff --git a/config/helm-charts/druid-cli/templates/deployment.yaml b/config/helm-charts/druid-cli/templates/deployment.yaml index 5c1cd173..d282ae23 100644 --- a/config/helm-charts/druid-cli/templates/deployment.yaml +++ b/config/helm-charts/druid-cli/templates/deployment.yaml @@ -56,6 +56,8 @@ spec: containerPort: {{ .Values.service.workerCallbackPort }} protocol: TCP env: + - name: DRUID_WORKLOAD_API_URL + value: {{ .Values.runtime.workloadApiUrl | quote }} - name: DRUID_K8S_NAMESPACE value: {{ default .Release.Namespace .Values.runtime.namespaces.single | quote }} - name: DRUID_K8S_STORAGE_CLASS diff --git a/config/helm-charts/druid-cli/templates/rbac.yaml b/config/helm-charts/druid-cli/templates/rbac.yaml index 95ab87a0..23411f1b 100644 --- a/config/helm-charts/druid-cli/templates/rbac.yaml +++ b/config/helm-charts/druid-cli/templates/rbac.yaml @@ -14,7 +14,7 @@ rules: verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: [""] resources: ["secrets"] - verbs: ["get", "create", "update", "patch", "delete"] + verbs: ["get", "list", "create", "update", "patch", "delete"] - apiGroups: [""] resources: ["persistentvolumeclaims", "services", "pods"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] diff --git a/config/helm-charts/druid-cli/values.yaml b/config/helm-charts/druid-cli/values.yaml index 44354aa9..b1bf37d4 100644 --- a/config/helm-charts/druid-cli/values.yaml +++ b/config/helm-charts/druid-cli/values.yaml @@ -50,6 +50,7 @@ ingress: tls: [] runtime: + workloadApiUrl: "" backend: kubernetes stateDir: /var/lib/druid/runtime namespaces: diff --git a/go.mod b/go.mod index a3462967..d634fc39 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gofiber/contrib/websocket v1.3.4 github.com/gofiber/fiber/v2 v2.52.9 github.com/google/uuid v1.6.0 + github.com/modelcontextprotocol/go-sdk v1.3.1 github.com/opencontainers/image-spec v1.1.1 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 @@ -83,6 +84,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.2 github.com/json-iterator/go v1.1.12 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/spdystream v0.5.0 // indirect @@ -105,9 +107,12 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect diff --git a/go.sum b/go.sum index e5bbfabf..688e4a61 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -163,6 +165,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= +github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -218,6 +222,10 @@ github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsF github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8= github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -254,6 +262,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= diff --git a/internal/core/domain/command_identity.go b/internal/core/domain/command_identity.go new file mode 100644 index 00000000..0ca1e744 --- /dev/null +++ b/internal/core/domain/command_identity.go @@ -0,0 +1,21 @@ +package domain + +// CommandIdentity is an opt-in permission request, never a credential. +type CommandIdentity struct { + Permissions []string `yaml:"permissions" json:"permissions"` +} + +// RuntimeCommandIdentity is transient mount metadata, never serialized in a Scroll. +type RuntimeCommandIdentity struct { + ExecutionID string + SecretName string + TokenPath string +} + +func ValidCommandPermission(permission string) bool { + switch permission { + case "commands:list", "commands:run", "commands:create", "queue:read", "logs:read", "console:write", "runtime:read", "ui:publish": + return true + } + return false +} diff --git a/internal/core/domain/command_identity_test.go b/internal/core/domain/command_identity_test.go new file mode 100644 index 00000000..9edc4fd3 --- /dev/null +++ b/internal/core/domain/command_identity_test.go @@ -0,0 +1,24 @@ +package domain + +import ( + "strings" + "testing" +) + +func TestCommandIdentityValidation(t *testing.T) { + for _, permission := range []string{"commands:list", "queue:read", "commands:run", "commands:create", "logs:read", "console:write", "runtime:read", "ui:publish"} { + if !ValidCommandPermission(permission) { + t.Fatalf("known permission rejected: %s", permission) + } + } + if ValidCommandPermission("owner:all") { + t.Fatal("unknown permission accepted") + } + scroll, err := NewScrollFromBytes("", []byte("name: identity\ndesc: Identity fixture\nversion: 0.1.0\napp_version: 1.0\ncommands:\n automation:\n identity:\n permissions: [owner:all]\n procedures:\n - image: busybox\n")) + if err == nil { + err = scroll.Validate(false) + } + if err == nil || !strings.Contains(err.Error(), "unknown identity permission") { + t.Fatalf("unknown permission was not rejected: %v", err) + } +} diff --git a/internal/core/domain/command_status.go b/internal/core/domain/command_status.go index a44f2af1..e2535ff3 100644 --- a/internal/core/domain/command_status.go +++ b/internal/core/domain/command_status.go @@ -12,6 +12,7 @@ const ( ) type LockStatus struct { + ExecutionID string `json:"execution_id,omitempty"` Status ScrollLockStatus `json:"status"` ExitCode *int `json:"exit_code"` LastStatusChange int64 `json:"last_status_change"` diff --git a/internal/core/domain/scroll.go b/internal/core/domain/scroll.go index 2b492135..24b63647 100644 --- a/internal/core/domain/scroll.go +++ b/internal/core/domain/scroll.go @@ -83,18 +83,19 @@ const ( ) type Procedure struct { - Type ProcedureType `yaml:"type,omitempty" json:"type,omitempty"` - Id *string `yaml:"id,omitempty" json:"id,omitempty"` - IgnoreFailure bool `yaml:"ignore_failure" json:"ignore_failure"` - Image string `yaml:"image,omitempty" json:"image,omitempty"` - Command []string `yaml:"command,omitempty" json:"command,omitempty"` - WorkingDir string `yaml:"working_dir,omitempty" json:"working_dir,omitempty"` - Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` - ExpectedPorts []ExpectedPort `yaml:"expectedPorts,omitempty" json:"expectedPorts,omitempty"` - Mounts []Mount `yaml:"mounts,omitempty" json:"mounts,omitempty"` - Target string `yaml:"target,omitempty" json:"target,omitempty"` - Signal string `yaml:"signal,omitempty" json:"signal,omitempty"` - TTY bool `yaml:"tty,omitempty" json:"tty,omitempty"` + RuntimeIdentity *RuntimeCommandIdentity `yaml:"-" json:"-"` + Type ProcedureType `yaml:"type,omitempty" json:"type,omitempty" jsonschema:"Procedure kind; omitted means container."` + Id *string `yaml:"id,omitempty" json:"id,omitempty" jsonschema:"Optional unique procedure identifier, not name. Omission uses command.index."` + IgnoreFailure bool `yaml:"ignore_failure" json:"ignore_failure"` + Image string `yaml:"image,omitempty" json:"image,omitempty" jsonschema:"Container image, required for container procedures."` + Command []string `yaml:"command,omitempty" json:"command,omitempty" jsonschema:"Executable and arguments as a string array, not a command string. For shell syntax, explicitly invoke sh -c."` + WorkingDir string `yaml:"working_dir,omitempty" json:"working_dir,omitempty"` + Env map[string]string `yaml:"env,omitempty" json:"env,omitempty" jsonschema:"Environment variables as a string-to-string map."` + ExpectedPorts []ExpectedPort `yaml:"expectedPorts,omitempty" json:"expectedPorts,omitempty" jsonschema:"References existing deployment port names; this does not declare or expose new ports."` + Mounts []Mount `yaml:"mounts,omitempty" json:"mounts,omitempty" jsonschema:"Mount deployment data into a container; sub_path is relative to deployment data, not a host path."` + Target string `yaml:"target,omitempty" json:"target,omitempty" jsonschema:"Existing procedure identifier to signal; required for signal procedures."` + Signal string `yaml:"signal,omitempty" json:"signal,omitempty" jsonschema:"Signal to send, e.g. SIGTERM; required for signal procedures."` + TTY bool `yaml:"tty,omitempty" json:"tty,omitempty"` Mode string `yaml:"mode,omitempty" json:"-"` Wait interface{} `yaml:"wait,omitempty" json:"-"` @@ -199,9 +200,10 @@ func ParseKeepAliveTraffic(value string) (*TrafficThreshold, error) { } type CommandInstructionSet struct { - Procedures []*Procedure `yaml:"procedures" json:"procedures"` - Needs []string `yaml:"needs,omitempty" json:"needs,omitempty"` - Run RunMode `yaml:"run,omitempty" json:"run,omitempty"` + Identity *CommandIdentity `yaml:"identity,omitempty" json:"identity,omitempty"` + Procedures []*Procedure `yaml:"procedures" json:"procedures" jsonschema:"One or more procedures. Container procedures require image; signal procedures require target and signal."` + Needs []string `yaml:"needs,omitempty" json:"needs,omitempty" jsonschema:"Existing command names to schedule as dependencies."` + Run RunMode `yaml:"run,omitempty" json:"run,omitempty" jsonschema:"Scheduling mode, NOT a shell command. Omitted means always; persistent survives daemon restart."` } var ErrScrollDoesNotExist = fmt.Errorf("scroll does not exist") @@ -312,6 +314,13 @@ func (sc *Scroll) Validate(strict bool) error { if cis == nil { return fmt.Errorf("command instruction set is required") } + if cis.Identity != nil { + for _, permission := range cis.Identity.Permissions { + if !ValidCommandPermission(permission) { + return fmt.Errorf("command %s requests unknown identity permission %q", cmd, permission) + } + } + } if len(cis.Procedures) == 0 { return fmt.Errorf("command procedures are required") } diff --git a/internal/core/ports/command_runtime.go b/internal/core/ports/command_runtime.go new file mode 100644 index 00000000..8cc947fe --- /dev/null +++ b/internal/core/ports/command_runtime.go @@ -0,0 +1,18 @@ +package ports + +import ( + "context" + "github.com/highcard-dev/daemon/internal/workloadidentity" +) + +// CommandIdentityBackend is implemented by both concrete command runtimes. +// It does not replace infrastructure ServiceAccount authentication. +type CommandIdentityBackend interface { + WorkloadIdentities() workloadidentity.Registry + ActiveExecutions(context.Context) (map[string]bool, error) +} + +type CommandConsoleBackend interface { + ReadConsoleLogs(context.Context, string, string, int, int64) (string, bool, error) + WriteConsoleInput(context.Context, string, string, []byte) (int, error) +} diff --git a/internal/core/ports/services_ports.go b/internal/core/ports/services_ports.go index cc6cfa2f..827295cb 100644 --- a/internal/core/ports/services_ports.go +++ b/internal/core/ports/services_ports.go @@ -95,6 +95,7 @@ type RuntimeScrollStore interface { } type RuntimeCommand struct { + ExecutionID string // Name identifies the command within the Scroll. Name string // ScrollID identifies the runtime Scroll whose command is being executed. @@ -113,11 +114,12 @@ type RuntimeCommand struct { // ProcedureStatusUpdate identifies one backend-reported procedure transition. type ProcedureStatusUpdate struct { - RuntimeID string - Command string - Procedure string - Status domain.ScrollLockStatus - ExitCode *int + ExecutionID string + RuntimeID string + Command string + Procedure string + Status domain.ScrollLockStatus + ExitCode *int } // ProcedureStatusObserver receives procedure transitions from a runtime backend. diff --git a/internal/runtime/backend.go b/internal/runtime/backend.go index 144672b0..ba145d17 100644 --- a/internal/runtime/backend.go +++ b/internal/runtime/backend.go @@ -2,6 +2,8 @@ package runtime import ( "fmt" + "io" + "path/filepath" "github.com/highcard-dev/daemon/internal/core/ports" "github.com/highcard-dev/daemon/internal/runtime/docker" @@ -14,6 +16,13 @@ type Runtime struct { create ports.RuntimeBackendFactoryFunc } +func (r *Runtime) Close() error { + if closer, ok := r.Backend.(io.Closer); ok { + return closer.Close() + } + return nil +} + type Options struct { Kubernetes runtimekubernetes.Config Docker docker.Config @@ -56,6 +65,7 @@ func NewRuntime(name string, stateDir string, opts ...Option) (*Runtime, error) if err != nil { return nil, err } + options.Docker.IdentityStateDir = filepath.Join(store.StateDir(), "identities") return &Runtime{ Store: dockerRuntimeStore{RuntimeScrollStore: store, config: options.Docker.WithDefaults()}, create: func(observer ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { diff --git a/internal/runtime/docker/backend.go b/internal/runtime/docker/backend.go index a8731a56..7a352657 100644 --- a/internal/runtime/docker/backend.go +++ b/internal/runtime/docker/backend.go @@ -8,9 +8,11 @@ import ( "github.com/docker/docker/client" "github.com/highcard-dev/daemon/internal/core/ports" + "github.com/highcard-dev/daemon/internal/workloadidentity" ) type Backend struct { + identities workloadidentity.Registry client *client.Client procedureStatusObserver ports.ProcedureStatusObserver config Config @@ -19,6 +21,7 @@ type Backend struct { } type Config struct { + IdentityStateDir string WorkerImage string Network string Storage string @@ -114,7 +117,15 @@ func NewWithConfig(config Config, observer ports.ProcedureStatusObserver) (*Back if err != nil { return nil, err } + var identities workloadidentity.Registry + if config.IdentityStateDir != "" { + identities, err = workloadidentity.NewFileRegistry(config.IdentityStateDir) + if err != nil { + return nil, err + } + } return &Backend{ + identities: identities, client: cli, procedureStatusObserver: observer, config: config, @@ -122,6 +133,8 @@ func NewWithConfig(config Config, observer ports.ProcedureStatusObserver) (*Back }, nil } +func (b *Backend) WorkloadIdentities() workloadidentity.Registry { return b.identities } + func (b *Backend) Name() string { return "docker" } diff --git a/internal/runtime/docker/command_identity.go b/internal/runtime/docker/command_identity.go new file mode 100644 index 00000000..b73d0fb8 --- /dev/null +++ b/internal/runtime/docker/command_identity.go @@ -0,0 +1,22 @@ +package docker + +import ( + "context" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/highcard-dev/daemon/internal/workloadidentity" +) + +func (b *Backend) ActiveExecutions(ctx context.Context) (map[string]bool, error) { + items, err := b.client.ContainerList(ctx, container.ListOptions{All: true, Filters: filters.NewArgs(filters.Arg("label", workloadidentity.ExecutionLabel))}) + if err != nil { + return nil, err + } + active := map[string]bool{} + for _, item := range items { + if item.State == "running" || item.State == "restarting" || item.State == "created" || item.State == "paused" { + active[item.Labels[workloadidentity.ExecutionLabel]] = true + } + } + return active, nil +} diff --git a/internal/runtime/docker/command_identity_test.go b/internal/runtime/docker/command_identity_test.go new file mode 100644 index 00000000..93532dce --- /dev/null +++ b/internal/runtime/docker/command_identity_test.go @@ -0,0 +1,26 @@ +package docker + +import ( + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/workloadidentity" + "testing" +) + +func TestCommandCredentialMountIsReadOnlyAndOutsideData(t *testing.T) { + procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: &domain.RuntimeCommandIdentity{ExecutionID: "execution", TokenPath: "/protected/identities/execution"}} + config, host, err := containerSpec("automation", procedure, "docker-volume://data", nil, nil, map[string]string{"DRUID_TOKEN_FILE": workloadidentity.TokenFile}) + if err != nil { + t.Fatal(err) + } + if config.Labels[workloadidentity.ExecutionLabel] != "execution" { + t.Fatal("execution label missing") + } + if len(host.Mounts) != 1 || host.Mounts[0].Target != workloadidentity.TokenFile || !host.Mounts[0].ReadOnly || host.Mounts[0].Source != "/protected/identities/execution" { + t.Fatal("invalid credential mount") + } + procedure.RuntimeIdentity = nil + _, host, err = containerSpec("ordinary", procedure, "docker-volume://data", nil, nil, nil) + if err != nil || len(host.Mounts) != 0 { + t.Fatal("non-opted-in command received a credential mount") + } +} diff --git a/internal/runtime/docker/console_tools.go b/internal/runtime/docker/console_tools.go new file mode 100644 index 00000000..431772ba --- /dev/null +++ b/internal/runtime/docker/console_tools.go @@ -0,0 +1,71 @@ +package docker + +import ( + "context" + "fmt" + "io" + "strconv" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/pkg/stdcopy" +) + +func (b *Backend) toolConsole(ctx context.Context, root, procedure string) (container.InspectResponse, error) { + items, err := b.client.ContainerList(ctx, container.ListOptions{All: true, Filters: filters.NewArgs( + filters.Arg("label", dockerLabelRole+"="+dockerRoleProcedure), + filters.Arg("label", dockerLabelRootHash+"="+rootHash(root)), + filters.Arg("label", dockerLabelProcedure+"="+procedure), + )}) + if err != nil { + return container.InspectResponse{}, err + } + if len(items) == 0 { + return container.InspectResponse{}, fmt.Errorf("console is unavailable") + } + return b.client.ContainerInspect(ctx, selectConsoleContainer(items).ID) +} + +func (b *Backend) ReadConsoleLogs(ctx context.Context, root, procedure string, tail int, maxBytes int64) (string, bool, error) { + selected, err := b.toolConsole(ctx, root, procedure) + if err != nil { + return "", false, err + } + logs, err := b.client.ContainerLogs(ctx, selected.ID, container.LogsOptions{ShowStdout: true, ShowStderr: true, Tail: strconv.Itoa(tail), Follow: false}) + if err != nil { + return "", false, err + } + defer logs.Close() + var output io.Reader = logs + if selected.Config != nil && !selected.Config.Tty { + reader, writer := io.Pipe() + defer reader.Close() + go func() { _, err := stdcopy.StdCopy(writer, writer, logs); _ = writer.CloseWithError(err) }() + output = reader + } + data, err := io.ReadAll(io.LimitReader(output, maxBytes+1)) + if err != nil { + return "", false, err + } + truncated := int64(len(data)) > maxBytes + if truncated { + data = data[:maxBytes] + } + return string(data), truncated, nil +} + +func (b *Backend) WriteConsoleInput(ctx context.Context, root, procedure string, data []byte) (int, error) { + selected, err := b.toolConsole(ctx, root, procedure) + if err != nil { + return 0, err + } + if selected.State == nil || !selected.State.Running { + return 0, fmt.Errorf("console is not running") + } + attach, err := b.client.ContainerAttach(ctx, selected.ID, container.AttachOptions{Stream: true, Stdin: true}) + if err != nil { + return 0, err + } + defer attach.Close() + return attach.Conn.Write(data) +} diff --git a/internal/runtime/docker/procedures.go b/internal/runtime/docker/procedures.go index a937e9d1..08db1917 100644 --- a/internal/runtime/docker/procedures.go +++ b/internal/runtime/docker/procedures.go @@ -77,11 +77,12 @@ func (b *Backend) observeProcedureStatus(command ports.RuntimeCommand, procedure return } b.procedureStatusObserver.ObserveProcedureStatus(ports.ProcedureStatusUpdate{ - RuntimeID: command.ScrollID, - Command: command.Name, - Procedure: procedure, - Status: status, - ExitCode: exitCode, + ExecutionID: command.ExecutionID, + RuntimeID: command.ScrollID, + Command: command.Name, + Procedure: procedure, + Status: status, + ExitCode: exitCode, }) } diff --git a/internal/runtime/docker/spec.go b/internal/runtime/docker/spec.go index 564092af..c9347dd0 100644 --- a/internal/runtime/docker/spec.go +++ b/internal/runtime/docker/spec.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker/api/types/mount" "github.com/docker/go-connections/nat" "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/workloadidentity" ) func containerSpec(commandName string, procedure *domain.Procedure, root string, globalPorts []domain.Port, routing []domain.RuntimeRouteAssignment, env map[string]string) (*container.Config, *container.HostConfig, error) { @@ -47,26 +48,34 @@ func containerSpec(commandName string, procedure *domain.Procedure, root string, mounts = append(mounts, dockerMount) } - return &container.Config{ - Image: procedure.Image, - Cmd: procedure.Command, - WorkingDir: procedure.WorkingDir, - Env: envArgs(env), - ExposedPorts: exposedPorts, - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - OpenStdin: true, - Tty: procedure.TTY, - Labels: map[string]string{ - "druid.command": commandName, - "druid.root-hash": rootHash(root), - }, - }, &container.HostConfig{ - Mounts: mounts, - PortBindings: portBindings, - ExtraHosts: dockerExtraHosts(), - }, nil + config := &container.Config{ + Image: procedure.Image, + Cmd: procedure.Command, + WorkingDir: procedure.WorkingDir, + Env: envArgs(env), + ExposedPorts: exposedPorts, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + OpenStdin: true, + Tty: procedure.TTY, + Labels: map[string]string{ + "druid.command": commandName, + "druid.root-hash": rootHash(root), + }, + } + hostConfig := &container.HostConfig{ + Mounts: mounts, + PortBindings: portBindings, + ExtraHosts: dockerExtraHosts(), + } + if identity := procedure.RuntimeIdentity; identity != nil { + config.Labels[workloadidentity.ExecutionLabel] = identity.ExecutionID + if identity.TokenPath != "" { + hostConfig.Mounts = append(hostConfig.Mounts, mount.Mount{Type: mount.TypeBind, Source: identity.TokenPath, Target: workloadidentity.TokenFile, ReadOnly: true}) + } + } + return config, hostConfig, nil } func routeAssignmentForPort(portName string, routing []domain.RuntimeRouteAssignment) (domain.RuntimeRouteAssignment, bool) { diff --git a/internal/runtime/kubernetes/backend.go b/internal/runtime/kubernetes/backend.go index 4ff74b63..b49eca2d 100644 --- a/internal/runtime/kubernetes/backend.go +++ b/internal/runtime/kubernetes/backend.go @@ -14,10 +14,13 @@ import ( "github.com/highcard-dev/daemon/internal/core/ports" "github.com/highcard-dev/daemon/internal/utils/logger" + "github.com/highcard-dev/daemon/internal/workloadidentity" "go.uber.org/zap" ) type Backend struct { + closeIdentities context.CancelFunc + identities workloadidentity.Registry client k8sclient.Interface restConfig *rest.Config httpClient *http.Client @@ -75,12 +78,29 @@ func New(config Config, observer ports.ProcedureStatusObserver) (*Backend, error jobExits: make(map[string]recentJobExit), } backend.statsReader = backend.readNodeStatsSummary + identityContext, cancelIdentities := context.WithCancel(context.Background()) + backend.identities, err = newNamespaceIdentities(identityContext, client, namespace) + if err != nil { + cancelIdentities() + return nil, err + } + backend.closeIdentities = cancelIdentities if config.PullImage == "" { logger.Log().Warn("Kubernetes cluster materialization requires --k8s-pull-image or DRUID_K8S_PULL_IMAGE") } return backend, nil } +// Close releases daemon-side watches without stopping surviving workloads. +func (b *Backend) Close() error { + if b.closeIdentities != nil { + b.closeIdentities() + } + return nil +} + +func (b *Backend) WorkloadIdentities() workloadidentity.Registry { return b.identities } + func runtimeRESTConfig(config Config) (*rest.Config, string, string, bool, error) { restConfig, inClusterErr := rest.InClusterConfig() if inClusterErr == nil { diff --git a/internal/runtime/kubernetes/command_identity.go b/internal/runtime/kubernetes/command_identity.go new file mode 100644 index 00000000..0cced980 --- /dev/null +++ b/internal/runtime/kubernetes/command_identity.go @@ -0,0 +1,31 @@ +package kubernetes + +import ( + "context" + "github.com/highcard-dev/daemon/internal/workloadidentity" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (b *Backend) ActiveExecutions(ctx context.Context) (map[string]bool, error) { + selector := metav1.ListOptions{LabelSelector: workloadidentity.ExecutionLabel} + jobs, err := b.client.BatchV1().Jobs("").List(ctx, selector) + if err != nil { + return nil, err + } + active := map[string]bool{} + for _, job := range jobs.Items { + if job.Status.Succeeded == 0 && !kubernetesJobFailed(&job) && job.DeletionTimestamp == nil { + active[job.Labels[workloadidentity.ExecutionLabel]] = true + } + } + sets, err := b.client.AppsV1().StatefulSets("").List(ctx, selector) + if err != nil { + return nil, err + } + for _, set := range sets.Items { + if set.DeletionTimestamp == nil && (set.Spec.Replicas == nil || *set.Spec.Replicas > 0) { + active[set.Labels[workloadidentity.ExecutionLabel]] = true + } + } + return active, nil +} diff --git a/internal/runtime/kubernetes/command_identity_test.go b/internal/runtime/kubernetes/command_identity_test.go new file mode 100644 index 00000000..4e2310c0 --- /dev/null +++ b/internal/runtime/kubernetes/command_identity_test.go @@ -0,0 +1,36 @@ +package kubernetes + +import ( + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/workloadidentity" + "testing" +) + +func TestCommandIdentityMountedInJobsAndPersistentProcedures(t *testing.T) { + identity := &domain.RuntimeCommandIdentity{ExecutionID: "execution", SecretName: "druid-workload-execution"} + procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: identity} + job, err := procedureJobSpec("runtime", ref("runtime", "data"), "automation", "automation.0", "job", 1, procedure, nil, "", "") + if err != nil { + t.Fatal(err) + } + if job.Labels[workloadidentity.ExecutionLabel] != "execution" || job.Spec.Template.Labels[workloadidentity.ExecutionLabel] != "execution" { + t.Fatal("execution label missing") + } + pod := job.Spec.Template.Spec + if len(pod.Volumes) != 2 || pod.Volumes[1].Secret == nil || pod.Volumes[1].Secret.SecretName != identity.SecretName { + t.Fatal("execution Secret not mounted") + } + if mounts := pod.Containers[0].VolumeMounts; len(mounts) != 1 || mounts[0].MountPath != "/run/secrets/druid" || !mounts[0].ReadOnly { + t.Fatal("credential mount is not protected") + } + set, err := persistentStatefulSetSpec("runtime", ref("runtime", "data"), "automation", "set", &domain.CommandInstructionSet{Procedures: []*domain.Procedure{procedure, procedure}}, nil, "") + if err != nil { + t.Fatal(err) + } + if len(set.Spec.Template.Spec.InitContainers[0].VolumeMounts) != 1 || len(set.Spec.Template.Spec.Containers[0].VolumeMounts) != 1 { + t.Fatal("persistent command procedures did not share the execution credential") + } + if _, ok := set.Spec.Selector.MatchLabels[workloadidentity.ExecutionLabel]; ok { + t.Fatal("execution identity changed immutable StatefulSet selector") + } +} diff --git a/internal/runtime/kubernetes/console_tools.go b/internal/runtime/kubernetes/console_tools.go new file mode 100644 index 00000000..250e961b --- /dev/null +++ b/internal/runtime/kubernetes/console_tools.go @@ -0,0 +1,53 @@ +package kubernetes + +import ( + "context" + "fmt" + "io" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" +) + +func (b *Backend) ReadConsoleLogs(ctx context.Context, root, procedure string, tail int, maxBytes int64) (string, bool, error) { + namespace, pvc, err := parseRef(root) + if err != nil { + return "", false, err + } + pod, name, _, err := b.waitForConsoleContainer(ctx, namespace, labels.SelectorFromSet(baseLabels(pvc)).String(), procedure) + if err != nil { + return "", false, err + } + tailLines, limit := int64(tail), maxBytes+1 + stream, err := b.client.CoreV1().Pods(namespace).GetLogs(pod.Name, &corev1.PodLogOptions{Container: name, TailLines: &tailLines, LimitBytes: &limit, Follow: false}).Stream(ctx) + if err != nil { + return "", false, err + } + defer stream.Close() + data, err := io.ReadAll(io.LimitReader(stream, limit)) + if err != nil { + return "", false, err + } + truncated := int64(len(data)) > maxBytes + if truncated { + data = data[:maxBytes] + } + return string(data), truncated, nil +} + +func (b *Backend) WriteConsoleInput(ctx context.Context, root, procedure string, data []byte) (int, error) { + namespace, pvc, err := parseRef(root) + if err != nil { + return 0, err + } + pod, name, running, err := b.waitForConsoleContainer(ctx, namespace, labels.SelectorFromSet(baseLabels(pvc)).String(), procedure) + if err != nil { + return 0, err + } + if !running { + return 0, fmt.Errorf("console is not running") + } + if err := b.attachToPod(ctx, namespace, pod.Name, name, string(data)); err != nil { + return 0, err + } + return len(data), nil +} diff --git a/internal/runtime/kubernetes/namespace_identities.go b/internal/runtime/kubernetes/namespace_identities.go new file mode 100644 index 00000000..166eb8ef --- /dev/null +++ b/internal/runtime/kubernetes/namespace_identities.go @@ -0,0 +1,174 @@ +package kubernetes + +import ( + "context" + "errors" + "sync" + + "github.com/highcard-dev/daemon/internal/workloadidentity" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubeclient "k8s.io/client-go/kubernetes" +) + +// Runtime roots may select different namespaces. Keep each namespace's tokens +// and shared authorization ConfigMap beside its workloads, never in the daemon's +// default namespace. Authorization itself still uses synchronized local caches. +type namespaceIdentities struct { + ctx context.Context + client kubeclient.Interface + defaultNamespace string + mu sync.Mutex + registries map[string]workloadidentity.Registry + cancel map[string]context.CancelFunc + revoked map[string]bool +} + +func newNamespaceIdentities(ctx context.Context, client kubeclient.Interface, namespace string) (*namespaceIdentities, error) { + r := &namespaceIdentities{ctx: ctx, client: client, defaultNamespace: namespace, registries: map[string]workloadidentity.Registry{}, cancel: map[string]context.CancelFunc{}, revoked: map[string]bool{}} + if _, err := r.namespace(namespace); err != nil { + return nil, err + } + if err := r.discover(ctx); err != nil { + return nil, err + } + return r, nil +} + +func (r *namespaceIdentities) namespace(namespace string) (workloadidentity.Registry, error) { + r.mu.Lock() + defer r.mu.Unlock() + if registry := r.registries[namespace]; registry != nil { + return registry, nil + } + ctx, cancel := context.WithCancel(r.ctx) + registry, err := workloadidentity.NewKubernetesRegistry(ctx, r.client, namespace) + if err != nil { + cancel() + return nil, err + } + r.registries[namespace] = registry + r.cancel[namespace] = cancel + return registry, nil +} + +func (r *namespaceIdentities) discover(ctx context.Context) error { + maps, err := r.client.CoreV1().ConfigMaps("").List(ctx, metav1.ListOptions{FieldSelector: "metadata.name=" + workloadidentity.RegistryConfigMap}) + if err != nil { + return err + } + present := map[string]bool{} + for _, cm := range maps.Items { + if cm.Name != workloadidentity.RegistryConfigMap { + continue + } + if _, err := r.namespace(cm.Namespace); err != nil { + return err + } + present[cm.Namespace] = true + } + // Deleted runtime namespaces must not leave failed watches blocking cleanup + // in unrelated namespaces. A healthy newly-created watch is kept even if it + // appeared after the list snapshot. + r.mu.Lock() + defer r.mu.Unlock() + for namespace, registry := range r.registries { + if namespace == r.defaultNamespace || present[namespace] { + continue + } + if _, err := registry.Records(ctx); errors.Is(err, workloadidentity.ErrUnavailable) { + r.cancel[namespace]() + delete(r.cancel, namespace) + delete(r.registries, namespace) + } + } + return nil +} + +func (r *namespaceIdentities) snapshot() []workloadidentity.Registry { + r.mu.Lock() + defer r.mu.Unlock() + registries := make([]workloadidentity.Registry, 0, len(r.registries)) + for _, registry := range r.registries { + registries = append(registries, registry) + } + return registries +} + +func (r *namespaceIdentities) Ensure(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { + namespace := r.defaultNamespace + if record.RuntimeRoot != "" { + var err error + namespace, _, err = parseRef(record.RuntimeRoot) + if err != nil { + return workloadidentity.CredentialMount{}, err + } + } + registry, err := r.namespace(namespace) + if err != nil { + return workloadidentity.CredentialMount{}, err + } + r.mu.Lock() + revoked := r.revoked[record.ExecutionID] + r.mu.Unlock() + if revoked { + return workloadidentity.CredentialMount{}, workloadidentity.ErrUnauthorized + } + return registry.Ensure(ctx, record) +} + +func (r *namespaceIdentities) Lookup(ctx context.Context, token string) (workloadidentity.Record, error) { + var unavailable bool + for _, registry := range r.snapshot() { + record, err := registry.Lookup(ctx, token) + if err == nil { + r.mu.Lock() + revoked := r.revoked[record.ExecutionID] + r.mu.Unlock() + if revoked { + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + } + return record, nil + } + unavailable = unavailable || errors.Is(err, workloadidentity.ErrUnavailable) + } + if unavailable { + return workloadidentity.Record{}, workloadidentity.ErrUnavailable + } + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized +} + +func (r *namespaceIdentities) Records(ctx context.Context) ([]workloadidentity.Record, error) { + if err := r.discover(ctx); err != nil { + return nil, err + } + var records []workloadidentity.Record + for _, registry := range r.snapshot() { + values, err := registry.Records(ctx) + if err != nil { + return nil, err + } + records = append(records, values...) + } + return records, nil +} + +func (r *namespaceIdentities) Revoke(ctx context.Context, execution string) error { + r.mu.Lock() + r.revoked[execution] = true + r.mu.Unlock() + for _, registry := range r.snapshot() { + if err := registry.Revoke(ctx, execution); err != nil { + return err + } + } + return nil +} + +func (r *namespaceIdentities) Prune(ctx context.Context, active map[string]bool) error { + for _, registry := range r.snapshot() { + if err := registry.Prune(ctx, active); err != nil { + return err + } + } + return nil +} diff --git a/internal/runtime/kubernetes/namespace_identities_test.go b/internal/runtime/kubernetes/namespace_identities_test.go new file mode 100644 index 00000000..4b8acff5 --- /dev/null +++ b/internal/runtime/kubernetes/namespace_identities_test.go @@ -0,0 +1,57 @@ +package kubernetes + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/highcard-dev/daemon/internal/workloadidentity" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestCommandIdentityFollowsRuntimeNamespaceAndSurvivesReattachment(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + client := fake.NewSimpleClientset() + registry, err := newNamespaceIdentities(ctx, client, "daemon") + if err != nil { + t.Fatal(err) + } + mount, err := registry.Ensure(ctx, workloadidentity.Record{RuntimeRoot: "k8s://deployment/data", ExecutionID: "execution", DeploymentID: "deployment", Command: "agent", Permissions: []string{"logs:read"}}) + if err != nil { + t.Fatal(err) + } + secret, err := client.CoreV1().Secrets("deployment").Get(ctx, mount.Secret, metav1.GetOptions{}) + if err != nil { + t.Fatal("credential not mounted in workload namespace", err) + } + if secrets, _ := client.CoreV1().Secrets("daemon").List(ctx, metav1.ListOptions{}); len(secrets.Items) != 0 { + t.Fatal("credential placed in daemon namespace") + } + reopened, err := newNamespaceIdentities(ctx, client, "daemon") + if err != nil { + t.Fatal(err) + } + token := string(secret.Data["token"]) + if record, err := reopened.Lookup(ctx, token); err != nil || record.DeploymentID != "deployment" { + t.Fatal("namespace registry not recovered", err) + } + if err := reopened.Revoke(ctx, "execution"); err != nil { + t.Fatal(err) + } + if _, err := reopened.Ensure(ctx, workloadidentity.Record{RuntimeRoot: "k8s://new-namespace/data", ExecutionID: "execution", DeploymentID: "deployment", Command: "agent"}); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("late provisioning in a new namespace resurrected a revoked execution") + } + deadline := time.Now().Add(time.Second) + for { + if _, err := registry.Lookup(ctx, token); err != nil { + break + } + if time.Now().After(deadline) { + t.Fatal("cross-daemon revocation did not propagate") + } + time.Sleep(time.Millisecond) + } +} diff --git a/internal/runtime/kubernetes/procedures.go b/internal/runtime/kubernetes/procedures.go index 5a41f789..951f651e 100644 --- a/internal/runtime/kubernetes/procedures.go +++ b/internal/runtime/kubernetes/procedures.go @@ -119,11 +119,12 @@ func (b *Backend) observeProcedureStatus(command ports.RuntimeCommand, procedure return } b.procedureStatusObserver.ObserveProcedureStatus(ports.ProcedureStatusUpdate{ - RuntimeID: command.ScrollID, - Command: command.Name, - Procedure: procedure, - Status: status, - ExitCode: exitCode, + ExecutionID: command.ExecutionID, + RuntimeID: command.ScrollID, + Command: command.Name, + Procedure: procedure, + Status: status, + ExitCode: exitCode, }) } diff --git a/internal/runtime/kubernetes/resources.go b/internal/runtime/kubernetes/resources.go index f026be10..784e771c 100644 --- a/internal/runtime/kubernetes/resources.go +++ b/internal/runtime/kubernetes/resources.go @@ -14,6 +14,7 @@ import ( "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/ports" + "github.com/highcard-dev/daemon/internal/workloadidentity" ) func pvcSpec(namespace string, name string, storageClass string, storageRequest string) *corev1.PersistentVolumeClaim { @@ -227,6 +228,7 @@ func procedureJobSpec(namespace string, root string, commandName string, procedu Containers: []corev1.Container{container}, Volumes: []corev1.Volume{pvcVolume("data", pvc)}, } + applyCommandIdentity(&podSpec, labels, procedure.RuntimeIdentity) if registrySecret != "" { podSpec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: registrySecret}} } @@ -335,6 +337,7 @@ func persistentStatefulSetSpec(namespace string, root string, commandName string Containers: []corev1.Container{container}, Volumes: []corev1.Volume{pvcVolume("data", pvc)}, } + applyCommandIdentity(&podSpec, labels, mainProcedure.RuntimeIdentity) if registrySecret != "" { podSpec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: registrySecret}} } @@ -356,6 +359,24 @@ func persistentStatefulSetSpec(namespace string, root string, commandName string }, nil } +func applyCommandIdentity(pod *corev1.PodSpec, labels map[string]string, identity *domain.RuntimeCommandIdentity) { + if identity == nil { + return + } + labels[workloadidentity.ExecutionLabel] = identity.ExecutionID + if identity.SecretName == "" { + return + } + pod.Volumes = append(pod.Volumes, corev1.Volume{Name: "command-identity", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: identity.SecretName}}}) + mount := corev1.VolumeMount{Name: "command-identity", MountPath: "/run/secrets/druid", ReadOnly: true} + for index := range pod.Containers { + pod.Containers[index].VolumeMounts = append(pod.Containers[index].VolumeMounts, mount) + } + for index := range pod.InitContainers { + pod.InitContainers[index].VolumeMounts = append(pod.InitContainers[index].VolumeMounts, mount) + } +} + func persistentInitContainerName(procedureName string) string { return dnsLabel("init-" + procedureName) } diff --git a/internal/utils/logger/credential_encoder.go b/internal/utils/logger/credential_encoder.go new file mode 100644 index 00000000..90982f54 --- /dev/null +++ b/internal/utils/logger/credential_encoder.go @@ -0,0 +1,26 @@ +package logger + +import ( + "github.com/highcard-dev/daemon/internal/workloadidentity" + "go.uber.org/zap/buffer" + "go.uber.org/zap/zapcore" +) + +// Redact after encoding so messages, errors, nested fields and inherited fields +// all receive the same protection, including application output in failures. +type credentialEncoder struct{ zapcore.Encoder } + +func (e credentialEncoder) Clone() zapcore.Encoder { + return credentialEncoder{e.Encoder.Clone()} +} + +func (e credentialEncoder) EncodeEntry(entry zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) { + encoded, err := e.Encoder.EncodeEntry(entry, fields) + if err != nil { + return encoded, err + } + redacted := workloadidentity.Redact(encoded.String()) + encoded.Reset() + encoded.AppendString(redacted) + return encoded, nil +} diff --git a/internal/utils/logger/credential_encoder_test.go b/internal/utils/logger/credential_encoder_test.go new file mode 100644 index 00000000..0a6801c2 --- /dev/null +++ b/internal/utils/logger/credential_encoder_test.go @@ -0,0 +1,25 @@ +package logger + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/highcard-dev/daemon/internal/workloadidentity" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func TestCredentialRedactionIncludesInheritedAndNestedLogFields(t *testing.T) { + token, err := workloadidentity.NewToken() + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + log := zap.New(NewProductionEncoderForWriter(zapcore.InfoLevel, zapcore.AddSync(&output))).With(zap.String("inherited", token)) + log.Error("failure "+token, zap.Error(errors.New(token)), zap.Any("nested", map[string]string{"token": token})) + if strings.Contains(output.String(), token) || strings.Count(output.String(), "[REDACTED]") != 4 { + t.Fatal("issued credentials leaked through a log field") + } +} diff --git a/internal/utils/logger/development_encoder.go b/internal/utils/logger/development_encoder.go index de2b5ff7..61114981 100644 --- a/internal/utils/logger/development_encoder.go +++ b/internal/utils/logger/development_encoder.go @@ -18,5 +18,5 @@ func NewDevelopmentEncoder(level zapcore.Level) zapcore.Core { errEncoder.MessageKey = "message" errEncoder.EncodeTime = zapcore.RFC3339TimeEncoder - return zapcore.NewCore(zapcore.NewConsoleEncoder(errEncoder), zapcore.AddSync(os.Stderr), level) + return zapcore.NewCore(credentialEncoder{zapcore.NewConsoleEncoder(errEncoder)}, zapcore.AddSync(os.Stderr), level) } diff --git a/internal/utils/logger/production_encoder.go b/internal/utils/logger/production_encoder.go index 0a110cac..e2e84cf7 100644 --- a/internal/utils/logger/production_encoder.go +++ b/internal/utils/logger/production_encoder.go @@ -20,5 +20,5 @@ func NewProductionEncoderForWriter(level zapcore.Level, writer zapcore.WriteSync structuredEncoder.LevelKey = "severity" structuredEncoder.MessageKey = "message" structuredEncoder.EncodeTime = zapcore.ISO8601TimeEncoder - return zapcore.NewCore(zapcore.NewJSONEncoder(structuredEncoder), writer, level) + return zapcore.NewCore(credentialEncoder{zapcore.NewJSONEncoder(structuredEncoder)}, writer, level) } diff --git a/internal/utils/logger/reduced_encoder.go b/internal/utils/logger/reduced_encoder.go index 15123c64..8ef3e90f 100644 --- a/internal/utils/logger/reduced_encoder.go +++ b/internal/utils/logger/reduced_encoder.go @@ -29,7 +29,7 @@ func NewReducedEncoder(level zapcore.Level) zapcore.Core { }), } - return zapcore.NewCore(reducedEncoder, zapcore.AddSync(colorable.NewColorableStdout()), level) + return zapcore.NewCore(credentialEncoder{reducedEncoder}, zapcore.AddSync(colorable.NewColorableStdout()), level) } func (e *ReducedEncoder) Clone() zapcore.Encoder { diff --git a/internal/workloadidentity/file_registry.go b/internal/workloadidentity/file_registry.go new file mode 100644 index 00000000..8ee50a41 --- /dev/null +++ b/internal/workloadidentity/file_registry.go @@ -0,0 +1,194 @@ +package workloadidentity + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// FileRegistry is daemon-owned state, separate from runtime data and backups. +type FileRegistry struct { + revoked map[string]bool + dir string + mu sync.RWMutex + records map[string]Record +} + +func NewFileRegistry(dir string) (*FileRegistry, error) { + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + if err := os.Chmod(dir, 0700); err != nil { + return nil, err + } + r := &FileRegistry{dir: dir, records: map[string]Record{}, revoked: map[string]bool{}} + data, err := os.ReadFile(filepath.Join(dir, "registry.json")) + if err == nil { + err = json.Unmarshal(data, &r.records) + } + if err != nil && !os.IsNotExist(err) { + return nil, err + } + return r, nil +} + +func (r *FileRegistry) save(records map[string]Record) error { + data, err := json.Marshal(records) + if err != nil { + return err + } + file, err := os.CreateTemp(r.dir, ".registry-") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err := file.Write(data); err != nil { + file.Close() + return err + } + if err := file.Sync(); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + if err := os.Rename(file.Name(), filepath.Join(r.dir, "registry.json")); err != nil { + return err + } + r.records = records + return nil +} + +func (r *FileRegistry) Ensure(_ context.Context, record Record) (CredentialMount, error) { + r.mu.Lock() + defer r.mu.Unlock() + // IDs are generated by the daemon, but validate before using them as paths. + if record.ExecutionID == "" || filepath.Base(record.ExecutionID) != record.ExecutionID || record.ExecutionID == "." || record.ExecutionID == ".." { + return CredentialMount{}, fmt.Errorf("invalid execution ID") + } + path := filepath.Join(r.dir, record.ExecutionID) + if r.revoked[record.ExecutionID] { + return CredentialMount{}, ErrUnauthorized + } + for hash, existing := range r.records { + if existing.ExecutionID == record.ExecutionID { + if existing.DeploymentID != record.DeploymentID || existing.Command != record.Command { + return CredentialMount{}, ErrUnauthorized + } + token, err := os.ReadFile(path) + if err != nil || Hash(string(token)) != hash { + return CredentialMount{}, ErrUnauthorized + } + return CredentialMount{Path: path}, nil + } + } + if record.Recover { + return CredentialMount{}, ErrUnauthorized + } + token, err := NewToken() + if err != nil { + return CredentialMount{}, err + } + // Parent directory is 0700. The bind-mounted file must also be readable by + // non-root image users; it is mounted read-only into the execution. + if err := r.writeToken(path, token); err != nil { + return CredentialMount{}, err + } + updated := make(map[string]Record, len(r.records)+1) + for key, value := range r.records { + updated[key] = value + } + updated[Hash(token)] = record + if err := r.save(updated); err != nil { + os.Remove(path) + return CredentialMount{}, err + } + return CredentialMount{Path: path}, nil +} + +func (r *FileRegistry) writeToken(path, token string) error { + file, err := os.CreateTemp(r.dir, ".token-") + if err != nil { + return err + } + defer os.Remove(file.Name()) + defer file.Close() + if _, err := file.WriteString(token); err != nil { + return err + } + if err := file.Chmod(0444); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(file.Name(), path) +} + +func (r *FileRegistry) Lookup(_ context.Context, token string) (Record, error) { + r.mu.RLock() + defer r.mu.RUnlock() + record, ok := r.records[Hash(token)] + if !ok || r.revoked[record.ExecutionID] { + return Record{}, ErrUnauthorized + } + return record, nil +} + +func (r *FileRegistry) Records(_ context.Context) ([]Record, error) { + r.mu.RLock() + defer r.mu.RUnlock() + records := make([]Record, 0, len(r.records)) + for _, record := range r.records { + records = append(records, record) + } + return records, nil +} + +func (r *FileRegistry) Revoke(_ context.Context, execution string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.revoked[execution] = true + updated := make(map[string]Record, len(r.records)) + for key, record := range r.records { + if record.ExecutionID != execution { + updated[key] = record + } + } + if err := r.save(updated); err != nil { + return err + } + if err := os.Remove(filepath.Join(r.dir, execution)); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (r *FileRegistry) Prune(_ context.Context, active map[string]bool) error { + r.mu.Lock() + defer r.mu.Unlock() + files, err := os.ReadDir(r.dir) + if err != nil { + return err + } + known := map[string]bool{} + for _, record := range r.records { + known[record.ExecutionID] = true + } + for _, file := range files { + if file.IsDir() || !executionFileName.MatchString(file.Name()) || known[file.Name()] || active[file.Name()] { + continue + } + if err := os.Remove(filepath.Join(r.dir, file.Name())); err != nil { + return err + } + } + return nil +} diff --git a/internal/workloadidentity/identity.go b/internal/workloadidentity/identity.go new file mode 100644 index 00000000..1c14852b --- /dev/null +++ b/internal/workloadidentity/identity.go @@ -0,0 +1,69 @@ +// Package workloadidentity implements command-execution credentials, distinct +// from owner JWTs and infrastructure Kubernetes ServiceAccount identities. +package workloadidentity + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "regexp" +) + +const TokenPrefix = "druid_wl_" +const TokenFile = "/run/secrets/druid/token" +const ExecutionLabel = "druid.execution-id" + +var ErrUnauthorized = errors.New("invalid workload credential") +var ErrUnavailable = errors.New("workload identity registry is not synchronized") +var tokenPattern = regexp.MustCompile(`druid_wl_[A-Za-z0-9_-]{43}`) +var executionFileName = regexp.MustCompile(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`) + +type Record struct { + RuntimeRoot string `json:"-"` // Provisioning hint; never an authorization field. + Recover bool `json:"-"` + ExecutionID string `json:"execution_id"` + DeploymentID string `json:"deployment_id"` + Command string `json:"command"` + Permissions []string `json:"permissions"` +} + +func (r Record) Allows(permission string) bool { + for _, allowed := range r.Permissions { + if allowed == permission { + return true + } + } + return false +} + +// CredentialMount contains only a backend-owned reference, never raw token data. +type CredentialMount struct { + Secret string + Path string +} + +type Registry interface { + Ensure(context.Context, Record) (CredentialMount, error) + Lookup(context.Context, string) (Record, error) + Records(context.Context) ([]Record, error) + Revoke(context.Context, string) error + Prune(context.Context, map[string]bool) error +} + +func NewToken() (string, error) { + var bytes [32]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", err + } + return TokenPrefix + base64.RawURLEncoding.EncodeToString(bytes[:]), nil +} + +func Hash(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +func Redact(value string) string { return tokenPattern.ReplaceAllString(value, "[REDACTED]") } diff --git a/internal/workloadidentity/identity_test.go b/internal/workloadidentity/identity_test.go new file mode 100644 index 00000000..b6173038 --- /dev/null +++ b/internal/workloadidentity/identity_test.go @@ -0,0 +1,277 @@ +package workloadidentity + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +func TestKubernetesRegistryWatchesFreshCollectionRevision(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: RegistryConfigMap, Namespace: "runtime", ResourceVersion: "1"}} + client := fake.NewSimpleClientset(cm) + client.PrependReactor("list", "configmaps", func(ktesting.Action) (bool, runtime.Object, error) { + return true, &corev1.ConfigMapList{ListMeta: metav1.ListMeta{ResourceVersion: "1000"}, Items: []corev1.ConfigMap{*cm}}, nil + }) + client.PrependWatchReactor("configmaps", func(action ktesting.Action) (bool, watch.Interface, error) { + if action.(ktesting.WatchAction).GetWatchRestrictions().ResourceVersion != "1000" { + return true, nil, apierrors.NewResourceExpired("object revision was compacted") + } + return true, watch.NewRaceFreeFake(), nil + }) + registry, err := NewKubernetesRegistry(ctx, client, "runtime") + if err != nil { + t.Fatalf("idle ConfigMap failed to synchronize: %v", err) + } + if _, err := registry.Records(ctx); err != nil { + t.Fatal(err) + } +} + +func TestKubernetesRegistryRequiresInitialSyncAndRecoversAfterWatchLoss(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + client := fake.NewSimpleClientset() + var unavailable atomic.Bool + streams := make(chan *watch.RaceFreeFakeWatcher, 8) + client.PrependWatchReactor("configmaps", func(ktesting.Action) (bool, watch.Interface, error) { + if unavailable.Load() { + return true, nil, errors.New("watch disconnected") + } + stream := watch.NewRaceFreeFake() + streams <- stream + return true, stream, nil + }) + registry, err := NewKubernetesRegistry(ctx, client, "runtime") + if err != nil { + t.Fatal(err) + } + stream := <-streams + mount, err := registry.Ensure(ctx, Record{ExecutionID: "execution", DeploymentID: "deployment"}) + if err != nil { + t.Fatal(err) + } + secret, err := client.CoreV1().Secrets("runtime").Get(ctx, mount.Secret, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + token := string(secret.Data["token"]) + cm, err := client.CoreV1().ConfigMaps("runtime").Get(ctx, RegistryConfigMap, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + stream.Modify(cm) + wait := func(healthy bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + _, err := registry.Lookup(ctx, token) + if (healthy && err == nil) || (!healthy && errors.Is(err, ErrUnavailable)) { + return + } + if time.Now().After(deadline) { + t.Fatalf("cache healthy=%v not reached: %v", healthy, err) + } + time.Sleep(time.Millisecond) + } + } + wait(true) + unavailable.Store(true) + stream.Stop() + wait(false) + unavailable.Store(false) + wait(true) + + blocked := fake.NewSimpleClientset() + blocked.PrependWatchReactor("configmaps", func(ktesting.Action) (bool, watch.Interface, error) { return true, nil, errors.New("no watch") }) + short, stop := context.WithTimeout(ctx, 50*time.Millisecond) + defer stop() + if _, err := NewKubernetesRegistry(short, blocked, "runtime"); err == nil { + t.Fatal("registry accepted requests before initial watch synchronization") + } +} + +func TestKubernetesRegistryPrunesOnlyStaleOrphanCredentials(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + client := fake.NewSimpleClientset() + registry, err := NewKubernetesRegistry(ctx, client, "runtime") + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"old", "fresh"} { + created := metav1.NewTime(time.Now().Add(-2 * time.Minute)) + if id == "fresh" { + created = metav1.Now() + } + _, err := client.CoreV1().Secrets("runtime").Create(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "druid-workload-" + id, CreationTimestamp: created, Labels: map[string]string{ExecutionLabel: id}}}, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + } + if err := registry.Prune(ctx, map[string]bool{}); err != nil { + t.Fatal(err) + } + if _, err := client.CoreV1().Secrets("runtime").Get(ctx, "druid-workload-old", metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatal("stale orphan retained") + } + if _, err := client.CoreV1().Secrets("runtime").Get(ctx, "druid-workload-fresh", metav1.GetOptions{}); err != nil { + t.Fatal("fresh provisioner raced by pruning") + } +} + +func TestFileRegistryPreservesRevokesAndSeparatesCredentials(t *testing.T) { + ctx := context.Background() + directory := t.TempDir() + registry, err := NewFileRegistry(directory) + if err != nil { + t.Fatal(err) + } + record := Record{ExecutionID: "first", DeploymentID: "deployment", Command: "automation", Permissions: []string{"queue:read"}} + mount, err := registry.Ensure(ctx, record) + if err != nil { + t.Fatal(err) + } + token, err := os.ReadFile(mount.Path) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(token), TokenPrefix) || len(token) != len(TokenPrefix)+43 { + t.Fatal("invalid token format") + } + data, err := os.ReadFile(filepath.Join(directory, "registry.json")) + if err != nil || strings.Contains(string(data), string(token)) { + t.Fatal("raw credential in authorization registry") + } + reopened, err := NewFileRegistry(directory) + if err != nil { + t.Fatal(err) + } + identity, err := reopened.Lookup(ctx, string(token)) + if err != nil || identity.DeploymentID != record.DeploymentID || !identity.Allows("queue:read") || identity.Allows("commands:run") { + t.Fatal("identity was not preserved") + } + remount, err := reopened.Ensure(ctx, record) + if err != nil || remount.Path != mount.Path { + t.Fatal("reattachment replaced the credential") + } + if err := reopened.Revoke(ctx, record.ExecutionID); err != nil { + t.Fatal(err) + } + if _, err := reopened.Lookup(ctx, string(token)); !errors.Is(err, ErrUnauthorized) { + t.Fatal("revoked token accepted") + } + if _, err := reopened.Ensure(ctx, record); !errors.Is(err, ErrUnauthorized) { + t.Fatal("late provisioning resurrected a revoked execution") + } + if _, err := os.Stat(mount.Path); !os.IsNotExist(err) { + t.Fatal("revoked credential file retained") + } + if Redact("echo "+string(token)) != "echo [REDACTED]" { + t.Fatal("credential was not redacted") + } + if _, err := reopened.Ensure(ctx, Record{ExecutionID: "../escape"}); err == nil { + t.Fatal("unsafe credential path accepted") + } +} + +func TestKubernetesRegistryConcurrentWritesCacheAndRevocation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + client := fake.NewSimpleClientset() + registry, err := NewKubernetesRegistry(ctx, client, "runtime") + if err != nil { + t.Fatal(err) + } + var once sync.Once + client.PrependReactor("update", "configmaps", func(action ktesting.Action) (bool, runtime.Object, error) { + conflict := false + once.Do(func() { conflict = true }) + if conflict { + return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "configmaps"}, RegistryConfigMap, errors.New("fixture conflict")) + } + return false, nil, nil + }) + var group sync.WaitGroup + for _, execution := range []string{"one", "two", "three", "four"} { + group.Add(1) + go func(id string) { + defer group.Done() + if _, err := registry.Ensure(ctx, Record{ExecutionID: id, DeploymentID: "deployment", Command: "agent", Permissions: []string{"logs:read"}}); err != nil { + t.Errorf("ensure: %v", err) + } + }(execution) + } + group.Wait() + deadline := time.Now().Add(3 * time.Second) + for { + records, err := registry.Records(ctx) + if err == nil && len(records) == 4 { + break + } + if time.Now().After(deadline) { + t.Fatal("watch did not synchronize concurrent updates") + } + time.Sleep(time.Millisecond) + } + cm, err := client.CoreV1().ConfigMaps("runtime").Get(ctx, RegistryConfigMap, metav1.GetOptions{}) + if err != nil || len(cm.Data) != 4 { + t.Fatal("registry updates were lost") + } + secret, err := client.CoreV1().Secrets("runtime").Get(ctx, "druid-workload-one", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + token := string(secret.Data["token"]) + for _, value := range cm.Data { + if strings.Contains(value, token) { + t.Fatal("raw credential exposed in ConfigMap") + } + } + client.ClearActions() + for index := 0; index < 10; index++ { + if _, err := registry.Lookup(ctx, token); err != nil { + t.Fatal(err) + } + } + if len(client.Actions()) != 0 { + t.Fatal("authorization performed per-request API lookups") + } + if err := registry.Revoke(ctx, "one"); err != nil { + t.Fatal(err) + } + for { + _, err := registry.Lookup(ctx, token) + if errors.Is(err, ErrUnauthorized) { + break + } + if time.Now().After(deadline) { + t.Fatal("revocation did not propagate") + } + time.Sleep(time.Millisecond) + } + registry.setCache(nil, false) + if _, err := registry.Lookup(ctx, token); !errors.Is(err, ErrUnavailable) { + t.Fatal("unhealthy cache did not fail closed") + } + registry.setCache(map[string]string{}, true) + if _, err := registry.Lookup(ctx, token); !errors.Is(err, ErrUnauthorized) { + t.Fatal("recovered cache accepted revoked credential") + } +} diff --git a/internal/workloadidentity/kubernetes_registry.go b/internal/workloadidentity/kubernetes_registry.go new file mode 100644 index 00000000..874d920a --- /dev/null +++ b/internal/workloadidentity/kubernetes_registry.go @@ -0,0 +1,266 @@ +package workloadidentity + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/util/retry" +) + +const RegistryConfigMap = "druid-workload-identities" + +type KubernetesRegistry struct { + revoked map[string]bool + writeMu sync.Mutex + client kubernetes.Interface + namespace string + mu sync.RWMutex + records map[string]Record + healthy bool +} + +func NewKubernetesRegistry(ctx context.Context, client kubernetes.Interface, namespace string) (*KubernetesRegistry, error) { + r := &KubernetesRegistry{client: client, namespace: namespace, revoked: map[string]bool{}} + _, err := client.CoreV1().ConfigMaps(namespace).Create(ctx, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: RegistryConfigMap}, Data: map[string]string{}}, metav1.CreateOptions{}) + if err != nil && !apierrors.IsAlreadyExists(err) { + return nil, err + } + ready := make(chan struct{}) + go r.watch(ctx, ready) + select { + case <-ready: + return r, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(15 * time.Second): + return nil, ErrUnavailable + } +} + +func decodeRecords(data map[string]string) (map[string]Record, error) { + records := make(map[string]Record, len(data)) + for key, value := range data { + var record Record + if err := json.Unmarshal([]byte(value), &record); err != nil { + return nil, err + } + records[key] = record + } + return records, nil +} + +func (r *KubernetesRegistry) setCache(data map[string]string, healthy bool) { + records, err := decodeRecords(data) + r.mu.Lock() + defer r.mu.Unlock() + r.records = records + r.healthy = healthy && err == nil +} + +// Use the collection revision: an idle object's revision may already have been +// compacted. Every disconnect fails closed until the next list/watch sync. +func (r *KubernetesRegistry) watch(ctx context.Context, ready chan struct{}) { + var initial sync.Once + for ctx.Err() == nil { + maps, err := r.client.CoreV1().ConfigMaps(r.namespace).List(ctx, metav1.ListOptions{FieldSelector: "metadata.name=" + RegistryConfigMap}) + if err == nil && len(maps.Items) == 1 { + var stream watch.Interface + stream, err = r.client.CoreV1().ConfigMaps(r.namespace).Watch(ctx, metav1.ListOptions{FieldSelector: "metadata.name=" + RegistryConfigMap, ResourceVersion: maps.ResourceVersion, TimeoutSeconds: ptrInt64(300)}) + if err == nil { + r.setCache(maps.Items[0].Data, true) + initial.Do(func() { close(ready) }) + watching: + for { + select { + case <-ctx.Done(): + break watching + case event, ok := <-stream.ResultChan(): + if !ok || event.Type == watch.Error || event.Type == watch.Deleted { + break watching + } + if updated, ok := event.Object.(*corev1.ConfigMap); ok { + r.setCache(updated.Data, true) + } + } + } + stream.Stop() + } + } + r.setCache(nil, false) + select { + case <-ctx.Done(): + return + case <-time.After(200 * time.Millisecond): + } + } + r.setCache(nil, false) +} + +func ptrInt64(value int64) *int64 { return &value } + +func (r *KubernetesRegistry) mutate(ctx context.Context, change func(map[string]string) error) error { + r.writeMu.Lock() + defer r.writeMu.Unlock() + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + cm, err := r.client.CoreV1().ConfigMaps(r.namespace).Get(ctx, RegistryConfigMap, metav1.GetOptions{}) + if err != nil { + return err + } + if cm.Data == nil { + cm.Data = map[string]string{} + } + if err := change(cm.Data); err != nil { + return err + } + _, err = r.client.CoreV1().ConfigMaps(r.namespace).Update(ctx, cm, metav1.UpdateOptions{}) + return err + }) +} + +func (r *KubernetesRegistry) Ensure(ctx context.Context, record Record) (CredentialMount, error) { + name := "druid-workload-" + record.ExecutionID + secret, err := r.client.CoreV1().Secrets(r.namespace).Get(ctx, name, metav1.GetOptions{}) + created := false + if apierrors.IsNotFound(err) { + if record.Recover { + return CredentialMount{}, ErrUnauthorized + } + token, tokenErr := NewToken() + if tokenErr != nil { + return CredentialMount{}, tokenErr + } + secret, err = r.client.CoreV1().Secrets(r.namespace).Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{ExecutionLabel: record.ExecutionID}}, + Data: map[string][]byte{"token": []byte(token)}, + }, metav1.CreateOptions{}) + created = err == nil + if apierrors.IsAlreadyExists(err) { + secret, err = r.client.CoreV1().Secrets(r.namespace).Get(ctx, name, metav1.GetOptions{}) + } + } + if err != nil { + return CredentialMount{}, err + } + if len(secret.Data["token"]) == 0 { + return CredentialMount{}, fmt.Errorf("execution credential is missing") + } + hash := Hash(string(secret.Data["token"])) + err = r.mutate(ctx, func(data map[string]string) error { + if r.revoked[record.ExecutionID] { + return ErrUnauthorized + } + if existing, ok := data[hash]; ok { + var old Record + if err := json.Unmarshal([]byte(existing), &old); err != nil { + return err + } + if old.ExecutionID != record.ExecutionID || old.DeploymentID != record.DeploymentID || old.Command != record.Command { + return ErrUnauthorized + } + return nil // Persisted grants, not a new request, remain authoritative. + } + if !created { + return ErrUnauthorized + } // Never resurrect a revoked credential. + value, err := json.Marshal(record) + if err != nil { + return err + } + data[hash] = string(value) + return nil + }) + if err != nil { + if created { + _ = r.client.CoreV1().Secrets(r.namespace).Delete(ctx, name, metav1.DeleteOptions{}) + } + return CredentialMount{}, err + } + return CredentialMount{Secret: name}, nil +} + +func (r *KubernetesRegistry) Lookup(_ context.Context, token string) (Record, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if !r.healthy { + return Record{}, ErrUnavailable + } + record, ok := r.records[Hash(token)] + if !ok { + return Record{}, ErrUnauthorized + } + return record, nil +} + +func (r *KubernetesRegistry) Records(_ context.Context) ([]Record, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if !r.healthy { + return nil, ErrUnavailable + } + records := make([]Record, 0, len(r.records)) + for _, record := range r.records { + records = append(records, record) + } + return records, nil +} + +func (r *KubernetesRegistry) Revoke(ctx context.Context, execution string) error { + if err := r.mutate(ctx, func(data map[string]string) error { + r.revoked[execution] = true + for key, value := range data { + var record Record + if err := json.Unmarshal([]byte(value), &record); err != nil { + return err + } + if record.ExecutionID == execution { + delete(data, key) + } + } + return nil + }); err != nil { + return err + } + err := r.client.CoreV1().Secrets(r.namespace).Delete(ctx, "druid-workload-"+execution, metav1.DeleteOptions{}) + if apierrors.IsNotFound(err) { + return nil + } + return err +} + +func (r *KubernetesRegistry) Prune(ctx context.Context, active map[string]bool) error { + records, err := r.Records(ctx) + if err != nil { + return err + } + known := map[string]bool{} + for _, record := range records { + known[record.ExecutionID] = true + } + secrets, err := r.client.CoreV1().Secrets(r.namespace).List(ctx, metav1.ListOptions{LabelSelector: ExecutionLabel}) + if err != nil { + return err + } + for _, secret := range secrets.Items { + execution := secret.Labels[ExecutionLabel] + // Secret creation precedes registry publication. Never race a fresh + // provisioner (including another daemon) or its watch propagation. + if time.Since(secret.CreationTimestamp.Time) < time.Minute { + continue + } + if known[execution] || active[execution] || secret.Name != "druid-workload-"+execution { + continue + } + if err := r.client.CoreV1().Secrets(r.namespace).Delete(ctx, secret.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + return err + } + } + return nil +} diff --git a/test/integration/agent_ui_test.go b/test/integration/agent_ui_test.go new file mode 100644 index 00000000..691d92d5 --- /dev/null +++ b/test/integration/agent_ui_test.go @@ -0,0 +1,258 @@ +//go:build integration + +package integration_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/highcard-dev/daemon/apps/druid/adapters/http/handlers" + appservices "github.com/highcard-dev/daemon/apps/druid/core/services" + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" + coreservices "github.com/highcard-dev/daemon/internal/core/services" + runtimebackend "github.com/highcard-dev/daemon/internal/runtime" + runtimedocker "github.com/highcard-dev/daemon/internal/runtime/docker" + "github.com/highcard-dev/daemon/internal/workloadidentity" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Opt-in, disposable Docker deployment with an in-memory upload target. It uses +// the real runtime, build toolchain, MCP transport and existing publish worker. +func TestAgentUIBuildAndPublish(t *testing.T) { + project := os.Getenv("DRUID_UI_TEST_PROJECT") + buildImage := os.Getenv("DRUID_UI_TEST_BUILD_IMAGE") + if os.Getenv("DRUID_UI_TEST_DOCKER") != "1" || project == "" || buildImage == "" { + t.Skip("set DRUID_UI_TEST_DOCKER=1, DRUID_UI_TEST_PROJECT and DRUID_UI_TEST_BUILD_IMAGE for the project's toolchain") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + id := "agent-ui-test-" + uuid.NewString()[:8] + var mu sync.Mutex + objects := map[string][]byte{} + finish := make(chan struct{}, 1) + uploads := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + if r.URL.Path == "/__finish" { + select { + case finish <- struct{}{}: + default: + } + return + } + mu.Lock() + defer mu.Unlock() + if r.Method == "PUT" { + data, err := io.ReadAll(io.LimitReader(r.Body, 32<<20)) + if err != nil { + http.Error(w, "upload failed", 500) + return + } + objects[r.URL.Path] = data + return + } + data, ok := objects[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + if strings.HasSuffix(r.URL.Path, ".wasm") { + w.Header().Set("Content-Type", "application/wasm") + } else { + w.Header().Set("Content-Type", "text/plain") + } + _, _ = w.Write(data) + })) + listener, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatal(err) + } + uploads.Listener = listener + uploads.Start() + defer uploads.Close() + port := listener.Addr().(*net.TCPAddr).Port + base := fmt.Sprintf("http://127.0.0.1:%d", port) + state := t.TempDir() + runtime, err := runtimebackend.NewRuntime("docker", state, runtimebackend.WithDockerConfig(runtimedocker.Config{ + Storage: runtimedocker.StorageBind, BindRoot: filepath.Join(state, "data"), + UIS3Bucket: "fixture", UIS3Region: "us-east-1", UIS3AccessKey: "fixture", UIS3SecretKey: "fixture", + UIS3Endpoint: fmt.Sprintf("http://host.docker.internal:%d", port), UIS3PublicBaseURL: base + "/fixture", + })) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, coreservices.NewRuntimeScrollManager(runtime.Store), runtime) + if err != nil { + t.Fatal(err) + } + root := runtime.Backend.RootRef(id, "") + directory := strings.TrimPrefix(root, "docker-bind://") + if err := os.MkdirAll(filepath.Join(directory, "data"), 0755); err != nil { + t.Fatal(err) + } + // Copy sources only. Native caches/output in the caller's project are never modified. + err = filepath.WalkDir(project, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && (entry.Name() == "node_modules" || entry.Name() == "target" || entry.Name() == "dist" || entry.Name() == ".git") { + return filepath.SkipDir + } + rel, err := filepath.Rel(project, path) + if err != nil { + return err + } + target := filepath.Join(directory, "private", rel) + if entry.IsDir() { + return os.MkdirAll(target, 0755) + } + if entry.Type()&os.ModeSymlink != 0 { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if entry.Name() == "package-lock.json" { + data = []byte(strings.ReplaceAll(string(data), "http://127.0.0.1:4873", "http://host.docker.internal:4873")) + } + return os.WriteFile(target, data, 0644) + }) + if err != nil { + t.Fatal(err) + } + document := `root = Stack([Card("Published component", [TextContent("Real MCP publication verified")], "Disposable integration fixture")])` + if err := os.WriteFile(filepath.Join(directory, "private/src/view.openui"), []byte(document), 0644); err != nil { + t.Fatal(err) + } + if err := runtime.Store.CreateScroll(&domain.RuntimeScroll{ID: id, Root: root, ScrollName: id, ScrollYAML: `name: agent-ui-test +desc: Disposable UI publishing fixture +version: 0.1.0 +app_version: test +commands: + agent: + run: persistent + identity: + permissions: [commands:create, commands:run, queue:read, logs:read, ui:publish] + procedures: + - image: alpine:3.20 + command: [sleep, "900"] +`}); err != nil { + t.Fatal(err) + } + defer func() { + if err := supervisor.DeleteWithPolicy(id, false); err != nil { + t.Errorf("cleanup: %v", err) + } + }() + if err := supervisor.SetWorkloadAPIURL("http://host.docker.internal:8082"); err != nil { + t.Fatal(err) + } + if _, err := supervisor.Run(id, "agent"); err != nil { + t.Fatal(err) + } + registry := runtime.Backend.(ports.CommandIdentityBackend).WorkloadIdentities() + var token string + for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + records, _ := registry.Records(ctx) + for _, record := range records { + if record.DeploymentID == id { + data, _ := os.ReadFile(filepath.Join(state, "identities", record.ExecutionID)) + token = string(data) + } + } + if token != "" { + break + } + } + if !strings.HasPrefix(token, workloadidentity.TokenPrefix) { + t.Fatal("identity not provisioned") + } + app := fiber.New(fiber.Config{DisableStartupMessage: true}) + handlers.RegisterPublicRoutes(app, handlers.RouteHandlers{Server: handlers.NewRuntimeServer(handlers.NewHealthHandler(), handlers.NewScrollHandler(supervisor)), Websocket: handlers.NewWebsocketHandler()}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response, err := app.Test(r, -1) + if err != nil { + http.Error(w, "harness failed", 500) + return + } + defer response.Body.Close() + for key, values := range response.Header { + w.Header()[key] = values + } + w.WriteHeader(response.StatusCode) + _, _ = io.Copy(w, response.Body) + })) + defer server.Close() + client := mcp.NewClient(&mcp.Implementation{Name: "agent-ui-test", Version: "1"}, nil) + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: server.URL + "/" + id + "/mcp", HTTPClient: &http.Client{Transport: workloadTransport{token}}}, nil) + if err != nil { + t.Fatal(err) + } + defer session.Close() + call := func(name string, args map[string]any) map[string]any { + t.Helper() + result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil || result.IsError { + t.Fatalf("%s: %v %v", name, err, result) + } + var data map[string]any + if err := json.Unmarshal([]byte(result.Content[0].(*mcp.TextContent).Text), &data); err != nil { + t.Fatal(err) + } + return data + } + call("create_command", map[string]any{"command": "build_fixture", "instructions": map[string]any{"procedures": []any{map[string]any{ + "id": "ui-builder", "image": buildImage, "working_dir": "/workspace/private", + "mounts": []any{map[string]any{"path": "/workspace", "sub_path": "."}}, + "command": []string{"sh", "-ec", "npm ci --include=optional && npm run build && test -s dist/app.wasm && test -s dist/app.openui"}, + }}}}) + accepted := call("run_command", map[string]any{"command": "build_fixture"}) + for { + wait := call("wait_for_command", map[string]any{"command": "build_fixture", "execution_id": accepted["execution_id"], "timeout_seconds": 30}) + if wait["timed_out"] == true { + continue + } + if wait["status"] != "done" { + logs := call("read_logs", map[string]any{"console": "ui-builder"}) + t.Fatalf("build failed: %v", logs) + } + break + } + publication := call("publish_ui", map[string]any{"scope": "private"}) + pkg := publication["package"].(map[string]any) + url := pkg["url"].(string) + for _, asset := range []string{url, strings.TrimSuffix(url, ".wasm") + ".openui"} { + response, err := http.Get(asset) + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.StatusCode != 200 || len(data) == 0 { + t.Fatal("published asset unavailable") + } + } + t.Logf("BUILD_AND_REAL_MCP_PUBLISH_OK url=%s finish=%s/__finish", url, base) + if os.Getenv("DRUID_UI_TEST_BROWSER") == "1" { + select { + case <-finish: + case <-ctx.Done(): + t.Fatal("browser verification timed out") + } + } +} diff --git a/test/integration/workload_identity_test.go b/test/integration/workload_identity_test.go new file mode 100644 index 00000000..c272ca82 --- /dev/null +++ b/test/integration/workload_identity_test.go @@ -0,0 +1,397 @@ +//go:build integration + +package integration_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/highcard-dev/daemon/apps/druid/adapters/http/handlers" + appservices "github.com/highcard-dev/daemon/apps/druid/core/services" + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" + coreservices "github.com/highcard-dev/daemon/internal/core/services" + runtimebackend "github.com/highcard-dev/daemon/internal/runtime" + runtimedocker "github.com/highcard-dev/daemon/internal/runtime/docker" + runtimekubernetes "github.com/highcard-dev/daemon/internal/runtime/kubernetes" + "github.com/highcard-dev/daemon/internal/workloadidentity" + "github.com/modelcontextprotocol/go-sdk/mcp" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubeclient "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +type workloadTransport struct{ token string } + +func (t workloadTransport) RoundTrip(request *http.Request) (*http.Response, error) { + copy := request.Clone(request.Context()) + copy.Header = request.Header.Clone() + copy.Header.Set("Authorization", "Bearer "+t.token) + return http.DefaultTransport.RoundTrip(copy) +} + +// Explicit opt-in keeps this test away from whichever production context the +// developer happens to have selected. It uses only its own temporary namespace +// or containers, and a short-lived HTTP harness, not a second development stack. +func TestWorkloadIdentityRealRuntime(t *testing.T) { + backendName := os.Getenv("DRUID_WORKLOAD_TEST_RUNTIME") + if backendName != "docker" && backendName != "kubernetes" { + t.Skip("set DRUID_WORKLOAD_TEST_RUNTIME=docker or kubernetes") + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + id := "identity-test-" + uuid.NewString()[:8] + state := t.TempDir() + options := []runtimebackend.Option{runtimebackend.WithDockerConfig(runtimedocker.Config{Storage: runtimedocker.StorageBind, BindRoot: filepath.Join(state, "data")})} + var kubernetes *kubeclient.Clientset + if backendName == "kubernetes" { + loading := clientcmd.NewDefaultClientConfigLoadingRules() + config := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loading, &clientcmd.ConfigOverrides{CurrentContext: "k3d-druid-gs"}) + rest, err := config.ClientConfig() + if err != nil { + t.Fatal(err) + } + kubernetes, err = kubeclient.NewForConfig(rest) + if err != nil { + t.Fatal(err) + } + if _, err := kubernetes.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: id}}, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = kubernetes.CoreV1().Namespaces().Delete(context.Background(), id, metav1.DeleteOptions{}) }) + // A dedicated kubeconfig prevents the backend from using a different + // current context than the explicitly selected local test client. + raw, err := config.RawConfig() + if err != nil { + t.Fatal(err) + } + raw.CurrentContext = "k3d-druid-gs" + kubeconfig := filepath.Join(state, "kubeconfig") + if err := clientcmd.WriteToFile(raw, kubeconfig); err != nil { + t.Fatal(err) + } + options = []runtimebackend.Option{runtimebackend.WithKubernetesConfig(runtimekubernetes.Config{Namespace: id, Kubeconfig: kubeconfig, PullImage: "druid:local"})} + } + runtime, err := runtimebackend.NewRuntime(backendName, state, options...) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = runtime.Close() }) + supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, coreservices.NewRuntimeScrollManager(runtime.Store), runtime) + if err != nil { + t.Fatal(err) + } + root := runtime.Backend.RootRef(id, "") + if backendName == "kubernetes" { + pvc := strings.Split(strings.TrimPrefix(root, "k8s://"), "/")[1] + _, err := kubernetes.CoreV1().PersistentVolumeClaims(id).Create(ctx, &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: pvc}, Spec: corev1.PersistentVolumeClaimSpec{AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("32Mi")}}}}, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + } else if err := os.MkdirAll(strings.TrimPrefix(root, "docker-bind://"), 0755); err != nil { + t.Fatal(err) + } + scroll := &domain.RuntimeScroll{ID: id, Root: root, ScrollName: id, ScrollYAML: `name: workload-test +desc: Isolated workload identity integration test +version: 0.1.0 +app_version: "test" +commands: + agent: + run: persistent + identity: + permissions: [commands:list, commands:create, queue:read, commands:run, logs:read, console:write, runtime:read] + procedures: + - id: agent-console + image: node:22-alpine + command: [node, -e, 'const fs=require("node:fs"); const token=fs.readFileSync(process.env.DRUID_TOKEN_FILE,"utf8"); try {fs.writeFileSync(process.env.DRUID_TOKEN_FILE,"bad"); process.exit(9)} catch {} fetch(process.env.DRUID_API_URL+"/api/v1/commands", {headers:{Authorization:"Bearer "+token}}).then(r=>{if(!r.ok)process.exit(8); console.log("MOUNT_API_OK")}).catch(()=>process.exit(7)); require("node:readline").createInterface({input:process.stdin}).on("line",line=>console.log("INPUT:"+line)); console.log("READY");'] +`} + if err := runtime.Store.CreateScroll(scroll); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := supervisor.DeleteWithPolicy(id, false); err != nil { + t.Errorf("cleanup test runtime: %v", err) + } + }) + app := fiber.New(fiber.Config{DisableStartupMessage: true}) + handlers.RegisterPublicRoutes(app, handlers.RouteHandlers{Server: handlers.NewRuntimeServer(handlers.NewHealthHandler(), handlers.NewScrollHandler(supervisor)), Websocket: handlers.NewWebsocketHandler()}) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response, err := app.Test(r, -1) + if err != nil { + http.Error(w, "harness error", 500) + return + } + defer response.Body.Close() + for key, values := range response.Header { + w.Header()[key] = values + } + w.WriteHeader(response.StatusCode) + _, _ = io.Copy(w, response.Body) + })) + listener, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatal(err) + } + server.Listener = listener + server.Start() + defer server.Close() + port := listener.Addr().(*net.TCPAddr).Port + if err := supervisor.SetWorkloadAPIURL(fmt.Sprintf("http://host.docker.internal:%d", port)); err != nil { + t.Fatal(err) + } + if _, err := supervisor.Run(id, "agent"); err != nil { + t.Fatal(err) + } + registry := runtime.Backend.(ports.CommandIdentityBackend).WorkloadIdentities() + var token, execution string + eventually := func(label string, check func() bool) { + t.Helper() + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) && ctx.Err() == nil { + if check() { + return + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("timed out: %s", label) + } + eventually("credential provisioning", func() bool { + records, err := registry.Records(ctx) + if err != nil { + return false + } + execution = "" + for _, record := range records { + if record.DeploymentID == id && record.Command == "agent" { + execution = record.ExecutionID + break + } + } + if execution == "" { + return false + } + if kubernetes != nil { + secret, err := kubernetes.CoreV1().Secrets(id).Get(ctx, "druid-workload-"+execution, metav1.GetOptions{}) + if err != nil { + return false + } + token = string(secret.Data["token"]) + } else { + data, err := os.ReadFile(filepath.Join(state, "identities", execution)) + if err != nil { + return false + } + token = string(data) + } + return token != "" + }) + call := func(name string, input appservices.ToolInput) (any, error) { + return supervisor.CallCommandTool(ctx, token, id, name, input) + } + eventually("read-only mount and workload-to-host HTTP", func() bool { + result, err := call("read_logs", appservices.ToolInput{Console: "agent-console"}) + if err != nil { + return false + } + data, _ := json.Marshal(result) + return bytes.Contains(data, []byte("MOUNT_API_OK")) + }) + client := mcp.NewClient(&mcp.Implementation{Name: "runtime-integration", Version: "1"}, nil) + connect := func() *mcp.ClientSession { + t.Helper() + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: fmt.Sprintf("http://127.0.0.1:%d/%s/mcp", port, id), HTTPClient: &http.Client{Transport: workloadTransport{token}}}, nil) + if err != nil { + t.Fatal(err) + } + return session + } + session := connect() + tools, err := session.ListTools(ctx, nil) + if err != nil || len(tools.Tools) != 9 { + t.Fatalf("discovery failed: %v", err) + } + for _, name := range []string{"list_commands", "get_queue", "get_deployment_status"} { + result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: map[string]any{}}) + if err != nil || result.IsError { + t.Fatalf("MCP %s failed: %v", name, err) + } + } + if _, err := call("send_stdin", appservices.ToolInput{Console: "agent-console", Text: "exact input\n"}); err != nil { + t.Fatal(err) + } + eventually("stdin echoed and literal log search", func() bool { + result, err := call("search_logs", appservices.ToolInput{Console: "agent-console", Query: "INPUT:exact input"}) + if err != nil { + return false + } + data, _ := json.Marshal(result) + return bytes.Contains(data, []byte("INPUT:exact input")) + }) + created, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "create_command", Arguments: map[string]any{ + "command": "echo", "instructions": map[string]any{"procedures": []any{map[string]any{ + "id": "echo-console", "image": "node:22-alpine", "command": []string{"node", "-e", `setTimeout(()=>console.log("COMPLETED_AFTER_DISCONNECT"),1000)`}, + }}}, + }}) + if err != nil || created.IsError { + t.Fatalf("real MCP command creation failed: %v, %v", err, created) + } + file, err := supervisor.ScrollFile(id) + if err != nil || file.Commands["echo"] == nil || file.Commands["echo"].Identity != nil { + t.Fatal("created command missing or granted identity") + } + accepted, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "run_command", Arguments: map[string]any{"command": "echo"}}) + if err != nil || accepted.IsError { + t.Fatal("MCP enqueue failed") + } + var run struct { + ExecutionID string `json:"execution_id"` + } + if err := json.Unmarshal([]byte(accepted.Content[0].(*mcp.TextContent).Text), &run); err != nil { + t.Fatal(err) + } + _ = session.Close() // Accepted work must survive the caller going away. + if _, err := call("wait_for_command", appservices.ToolInput{Command: "echo", ExecutionID: run.ExecutionID, TimeoutSeconds: 30}); err != nil { + t.Fatal(err) + } + result, err := call("read_logs", appservices.ToolInput{Console: "echo-console"}) + if err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(result) + if !bytes.Contains(data, []byte("COMPLETED_AFTER_DISCONNECT")) { + t.Fatal("completed container logs unavailable") + } + eventually("healthy identity reconciliation", func() bool { return supervisor.ReconcileCommandIdentities(ctx) == nil }) + if _, err := registry.Lookup(ctx, token); err != nil { + t.Fatal("surviving execution lost its credential") + } + if launcher := os.Getenv("DRUID_WORKLOAD_TEST_LAUNCHER"); launcher != "" { + bridgeConsole := "bridge-console" + bridge := &domain.CommandInstructionSet{Run: domain.RunModePersistent, Identity: &domain.CommandIdentity{Permissions: []string{"commands:list", "queue:read"}}, Procedures: []*domain.Procedure{{Id: &bridgeConsole, Image: "node:22-alpine", Command: []string{"node", "-e", launcher}}}} + if err := supervisor.AddCommand(id, "bridge", bridge); err != nil { + t.Fatal(err) + } + if _, err := supervisor.Run(id, "bridge"); err != nil { + t.Fatal(err) + } + // Exercise the exact SPA-supplied npx launcher and pinned mcp-remote + // without an LLM. Stdin/stdout carry normal MCP JSON-RPC messages. + eventually("MCP bridge ready", func() bool { + result, err := call("read_logs", appservices.ToolInput{Console: "bridge-console"}) + if err != nil { + return false + } + data, _ := json.Marshal(result) + return bytes.Contains(data, []byte("Proxy established successfully")) + }) + message := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"integration","version":"1"}}}` + "\n" + if _, err := call("send_stdin", appservices.ToolInput{Console: "bridge-console", Text: message}); err != nil { + t.Fatal(err) + } + eventually("MCP bridge initialize response", func() bool { + result, err := call("read_logs", appservices.ToolInput{Console: "bridge-console"}) + if err != nil { + return false + } + data, _ := json.Marshal(result) + return bytes.Contains(data, []byte("protocolVersion")) + }) + message = `{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" + `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}` + "\n" + if _, err := call("send_stdin", appservices.ToolInput{Console: "bridge-console", Text: message}); err != nil { + t.Fatal(err) + } + eventually("MCP bridge tool discovery", func() bool { + result, err := call("read_logs", appservices.ToolInput{Console: "bridge-console"}) + if err != nil { + return false + } + data, _ := json.Marshal(result) + return bytes.Contains(data, []byte("list_commands")) + }) + t.Log("verified exact SPA launcher with pinned mcp-remote, mounted token, MCP initialize and tool discovery") + } + // Reconnect a new supervisor/registry to persisted running resources. + reattached, err := runtimebackend.NewRuntime(backendName, state, options...) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reattached.Close() }) + second, err := appservices.NewRuntimeSupervisor(reattached.Store, coreservices.NewRuntimeScrollManager(reattached.Store), reattached) + if err != nil { + t.Fatal(err) + } + if err := second.SetWorkloadAPIURL(fmt.Sprintf("http://host.docker.internal:%d", port)); err != nil { + t.Fatal(err) + } + if err := second.ReconcileCommandIdentities(ctx); err != nil { + t.Fatal(err) + } + if _, err := second.AuthenticateCommand(ctx, token, id); err != nil { + t.Fatal("daemon reattachment rotated or lost identity") + } + definition, err := supervisor.ScrollFile(id) + if err != nil { + t.Fatal(err) + } + agent := definition.Commands["agent"] + if err := supervisor.RemoveCommand(id, "agent"); err != nil { + t.Fatal(err) + } + eventually("revocation", func() bool { _, err := registry.Lookup(ctx, token); return err != nil }) + if _, err := call("list_commands", appservices.ToolInput{}); err == nil { + t.Fatal("removed execution remained authorized") + } + oldToken, oldExecution := token, execution + if err := supervisor.AddCommand(id, "agent", agent); err != nil { + t.Fatal(err) + } + if _, err := supervisor.Run(id, "agent"); err != nil { + t.Fatal(err) + } + eventually("fresh execution rotates credential", func() bool { + records, err := registry.Records(ctx) + if err != nil { + return false + } + for _, record := range records { + if record.DeploymentID != id || record.Command != "agent" || record.ExecutionID == oldExecution { + continue + } + execution = record.ExecutionID + if kubernetes != nil { + secret, err := kubernetes.CoreV1().Secrets(id).Get(ctx, "druid-workload-"+execution, metav1.GetOptions{}) + if err != nil { + return false + } + token = string(secret.Data["token"]) + } else { + data, err := os.ReadFile(filepath.Join(state, "identities", execution)) + if err != nil { + return false + } + token = string(data) + } + return token != oldToken && strings.HasPrefix(token, workloadidentity.TokenPrefix) + } + return false + }) + if _, err := registry.Lookup(ctx, oldToken); err == nil { + t.Fatal("old credential resurrected after replacement") + } + t.Log("verified real mount/HTTP, all nine tools, command creation/execution, stdin/logs, completed logs, disconnection, daemon reattachment, and removal revocation") +} From d3cba3d8eebcf34252e0e3efc8a434afa669ed8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 01:54:45 +0100 Subject: [PATCH 02/14] refactor(auth)!: use Pod-bound agent tokens Replace the Kubernetes credential registry with ServiceAccount tokens, TokenReview and current execution checks. Keep Docker file credentials. Kubernetes agents using opaque tokens require explicit recreation; no legacy credential fallback is retained. --- apps/druid/adapters/http/handlers/auth.go | 22 +- .../adapters/http/handlers/command_tools.go | 11 +- .../http/handlers/command_tools_test.go | 12 +- apps/druid/adapters/http/handlers/routes.go | 8 +- .../adapters/http/handlers/routes_test.go | 32 +++ .../druid/core/services/command_tools_test.go | 7 + apps/druid/core/services/runtime_identity.go | 46 ++- .../core/services/runtime_identity_test.go | 70 +++++ .../services/runtime_session_execution.go | 12 +- config/helm-charts/druid-cli/chart_test.go | 16 ++ .../helm-charts/druid-cli/templates/rbac.yaml | 30 +- internal/core/domain/command_identity.go | 7 +- internal/core/ports/command_runtime.go | 6 + internal/runtime/docker/command_identity.go | 16 ++ internal/runtime/kubernetes/backend.go | 20 -- .../kubernetes/command_authentication_test.go | 122 ++++++++ .../runtime/kubernetes/command_identity.go | 52 ++-- .../kubernetes/command_identity_test.go | 24 +- .../kubernetes/namespace_identities.go | 174 ------------ .../kubernetes/namespace_identities_test.go | 57 ---- internal/runtime/kubernetes/resources.go | 12 +- .../runtime/kubernetes/workload_identity.go | 87 +++--- internal/workloadidentity/identity.go | 11 +- internal/workloadidentity/identity_test.go | 212 -------------- .../workloadidentity/kubernetes_registry.go | 266 ------------------ test/integration/agent_ui_test.go | 2 +- test/integration/workload_identity_test.go | 92 +++--- 27 files changed, 535 insertions(+), 891 deletions(-) create mode 100644 apps/druid/core/services/runtime_identity_test.go create mode 100644 internal/runtime/kubernetes/command_authentication_test.go delete mode 100644 internal/runtime/kubernetes/namespace_identities.go delete mode 100644 internal/runtime/kubernetes/namespace_identities_test.go delete mode 100644 internal/workloadidentity/kubernetes_registry.go diff --git a/apps/druid/adapters/http/handlers/auth.go b/apps/druid/adapters/http/handlers/auth.go index e574121a..71cf9acd 100644 --- a/apps/druid/adapters/http/handlers/auth.go +++ b/apps/druid/adapters/http/handlers/auth.go @@ -4,28 +4,23 @@ import ( "github.com/gofiber/contrib/websocket" "github.com/gofiber/fiber/v2" "github.com/highcard-dev/daemon/internal/core/ports" - "github.com/highcard-dev/daemon/internal/workloadidentity" - "strings" ) const ownerLocal = "druid-owner-id" func (h *ScrollHandler) PublicAuth(c *fiber.Ctx) error { - if strings.HasPrefix(bearer(c.Get("Authorization")), workloadidentity.TokenPrefix) { - return fiber.NewError(fiber.StatusForbidden, "workload credentials cannot access this endpoint") - } if h.authorizer == nil { - if h.allowUnauthenticatedPublic { + if h.allowUnauthenticatedPublic && c.Get("Authorization") == "" { return c.Next() } return fiber.NewError(fiber.StatusUnauthorized, "public authentication is not configured") } auth, err := h.authorizer.CheckHeader(c) if err != nil { - return fiber.NewError(fiber.StatusUnauthorized, err.Error()) + return fiber.NewError(fiber.StatusUnauthorized, "invalid owner credential") } if auth == nil { - if h.allowUnauthenticatedPublic { + if h.allowUnauthenticatedPublic && c.Get("Authorization") == "" { return c.Next() } return fiber.NewError(fiber.StatusUnauthorized, "missing authorization token") @@ -56,17 +51,18 @@ func (h *ScrollHandler) authorizeRuntimeOwner(id string, subject string) error { } func (h *WebsocketHandler) PublicQueryAuth(c *websocket.Conn) bool { - if strings.HasPrefix(c.Query("token"), workloadidentity.TokenPrefix) || strings.HasPrefix(bearer(c.Headers("Authorization")), workloadidentity.TokenPrefix) { - return false + token := c.Query("token") + if token == "" { + token = bearer(c.Headers("Authorization")) } if h.authorizer == nil { - return h.allowUnauthenticatedPublic + return h.allowUnauthenticatedPublic && token == "" && c.Headers("Authorization") == "" } - auth, err := h.authorizer.CheckQuery(c.Params("id"), c.Query("token")) + auth, err := h.authorizer.CheckQuery(c.Params("id"), token) if err != nil { return false } - return auth != nil || h.allowUnauthenticatedPublic + return auth != nil || (h.allowUnauthenticatedPublic && token == "" && c.Headers("Authorization") == "") } type jwksProvider interface { diff --git a/apps/druid/adapters/http/handlers/command_tools.go b/apps/druid/adapters/http/handlers/command_tools.go index 4113dcc2..9b274ad2 100644 --- a/apps/druid/adapters/http/handlers/command_tools.go +++ b/apps/druid/adapters/http/handlers/command_tools.go @@ -51,8 +51,15 @@ func (h *ScrollHandler) commandToolHTTP(name string) fiber.Handler { return func(c *fiber.Ctx) error { token := bearer(c.Get("Authorization")) // Preserve the existing owner-authenticated endpoints. - if (name == "get_queue" || name == "publish_ui") && !strings.HasPrefix(token, workloadidentity.TokenPrefix) { - return c.Next() + if name == "get_queue" || name == "publish_ui" { + if c.Get("Authorization") == "" { + return c.Next() + } + if h.authorizer != nil { + if owner, err := h.authorizer.CheckHeader(c); err == nil && owner != nil { + return c.Next() + } + } } input := services.ToolInput{} if len(c.Body()) > 0 { diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go index 8185e3b3..dc4c36b5 100644 --- a/apps/druid/adapters/http/handlers/command_tools_test.go +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -107,6 +107,12 @@ type toolRouteBackend struct { } func (b *toolRouteBackend) WorkloadIdentities() workloadidentity.Registry { return b.registry } +func (b *toolRouteBackend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { + return b.registry.Ensure(ctx, record) +} +func (b *toolRouteBackend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Record, error) { + return b.registry.Lookup(ctx, token) +} func (b *toolRouteBackend) Name() string { return "fixture" } func (b *toolRouteBackend) RunCommand(ports.RuntimeCommand) (*int, error) { return nil, nil } func (b *toolRouteBackend) CreateUIPackageUpload(_ context.Context, action ports.RuntimeUIPackageUploadAction) (ports.RuntimeUIPackageUpload, error) { @@ -145,7 +151,9 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { if err != nil { t.Fatal(err) } - runtime := &domain.RuntimeScroll{ID: "tools", Root: t.TempDir(), ScrollName: "tools", ScrollYAML: "name: tools\ndesc: Tools fixture\nversion: 0.1.0\napp_version: 1.0\ncommands:\n agent:\n run: persistent\n procedures:\n - image: busybox\n env: {SECRET: must-not-leak}\n"} + runtime := &domain.RuntimeScroll{ID: "tools", Root: t.TempDir(), ScrollName: "tools", ScrollYAML: "name: tools\ndesc: Tools fixture\nversion: 0.1.0\napp_version: 1.0\ncommands:\n agent:\n run: persistent\n identity:\n permissions: [commands:list, commands:create, queue:read, ui:publish]\n procedures:\n - image: busybox\n env: {SECRET: must-not-leak}\n"} + runtime.ScrollYAML += " limited-agent:\n run: persistent\n identity:\n permissions: [commands:list]\n procedures:\n - image: busybox\n" + runtime.Procedures = domain.ProcedureStatusMap{"agent": {"agent.0": {ExecutionID: "execution", Status: domain.ScrollLockStatusRunning}}, "limited-agent": {"limited-agent.0": {ExecutionID: "limited", Status: domain.ScrollLockStatusRunning}}} if err := store.CreateScroll(runtime); err != nil { t.Fatal(err) } @@ -315,7 +323,7 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { t.Fatalf("signal procedure schema regressed: %v, %v", err, signal) } // A credential with only commands:list must neither discover nor call creation. - limited, err := registry.Ensure(ctx, workloadidentity.Record{ExecutionID: "limited", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list"}}) + limited, err := registry.Ensure(ctx, workloadidentity.Record{ExecutionID: "limited", DeploymentID: "tools", Command: "limited-agent", Permissions: []string{"commands:list"}}) if err != nil { t.Fatal(err) } diff --git a/apps/druid/adapters/http/handlers/routes.go b/apps/druid/adapters/http/handlers/routes.go index 503aa052..e0bcdbfd 100644 --- a/apps/druid/adapters/http/handlers/routes.go +++ b/apps/druid/adapters/http/handlers/routes.go @@ -6,8 +6,6 @@ import ( "github.com/gofiber/fiber/v2/middleware/cors" "github.com/highcard-dev/daemon/internal/api" "github.com/highcard-dev/daemon/internal/core/ports" - "github.com/highcard-dev/daemon/internal/workloadidentity" - "strings" ) type RouteHandlers struct { @@ -32,15 +30,15 @@ func RegisterRoutes(app *fiber.App, handlers RouteHandlers) { func RegisterManagementRoutes(app *fiber.App, handlers RouteHandlers) { app.Use(func(ctx *fiber.Ctx) error { - if strings.HasPrefix(bearer(ctx.Get("Authorization")), workloadidentity.TokenPrefix) { + if ctx.Get("Authorization") != "" && ctx.Locals("druid-workload-identity") == nil && handlers.Authorizer == nil { return fiber.NewError(fiber.StatusForbidden, "command credentials cannot access management endpoints") } return ctx.Next() }) if handlers.Authorizer != nil { app.Use(func(ctx *fiber.Ctx) error { - if _, err := handlers.Authorizer.CheckHeader(ctx); err != nil { - return fiber.NewError(fiber.StatusUnauthorized, err.Error()) + if auth, err := handlers.Authorizer.CheckHeader(ctx); err != nil || (ctx.Get("Authorization") != "" && auth == nil) { + return fiber.NewError(fiber.StatusUnauthorized, "invalid management credential") } return ctx.Next() }) diff --git a/apps/druid/adapters/http/handlers/routes_test.go b/apps/druid/adapters/http/handlers/routes_test.go index 11fca1de..bc9ae37d 100644 --- a/apps/druid/adapters/http/handlers/routes_test.go +++ b/apps/druid/adapters/http/handlers/routes_test.go @@ -92,6 +92,38 @@ func (ownerRouteAuth) CheckHeader(c *fiber.Ctx) (*ports.AuthContext, error) { type ownerRouteBackend struct{ ports.RuntimeBackendInterface } +func TestUnsafePublicAuthenticationNeverAcceptsSuppliedUnverifiedCredentials(t *testing.T) { + for _, authorizer := range []ports.AuthorizerServiceInterface{nil, missingOwnerAuth{}} { + handler := NewScrollHandler(nil, authorizer) + handler.SetAllowUnauthenticatedPublic(true) + app := fiber.New() + app.Use(handler.PublicAuth) + app.Get("/test", func(c *fiber.Ctx) error { return c.SendStatus(200) }) + for _, header := range []string{"", "Bearer druid_wl_invalid", "Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln", "Bearer invalid", "Basic invalid"} { + request := httptest.NewRequest(http.MethodGet, "/test", nil) + request.Header.Set("Authorization", header) + response, err := app.Test(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + want := 401 + if header == "" { + want = 200 + } + if response.StatusCode != want { + t.Fatalf("credential supplied=%v: got %d want %d", header != "", response.StatusCode, want) + } + } + } +} + +type missingOwnerAuth struct { + ports.AuthorizerServiceInterface +} + +func (missingOwnerAuth) CheckHeader(*fiber.Ctx) (*ports.AuthContext, error) { return nil, nil } + func TestPublicReleaseRoutesEnforceOwnerBeforeReadingOrUpdating(t *testing.T) { supervisor, err := appservices.NewRuntimeSupervisor(ownerRouteStore{}, nil, ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return ownerRouteBackend{}, nil diff --git a/apps/druid/core/services/command_tools_test.go b/apps/druid/core/services/command_tools_test.go index f2d527da..723e27b4 100644 --- a/apps/druid/core/services/command_tools_test.go +++ b/apps/druid/core/services/command_tools_test.go @@ -188,6 +188,12 @@ type toolTestBackend struct { } func (b *toolTestBackend) WorkloadIdentities() workloadidentity.Registry { return b.registry } +func (b *toolTestBackend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { + return b.registry.Ensure(ctx, record) +} +func (b *toolTestBackend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Record, error) { + return b.registry.Lookup(ctx, token) +} func (b *toolTestBackend) ActiveExecutions(context.Context) (map[string]bool, error) { return map[string]bool{}, nil } @@ -230,6 +236,7 @@ commands: - image: busybox command: [true] `, Procedures: domain.ProcedureStatusMap{"agent": {"agent-console": {ExecutionID: "agent-execution", Status: domain.ScrollLockStatusRunning}}}} + runtime.ScrollYAML = strings.ReplaceAll(runtime.ScrollYAML, "permissions: [commands:list]", "permissions: ["+strings.Join(permissions, ", ")+"]") if err := store.CreateScroll(runtime); err != nil { t.Fatal(err) } diff --git a/apps/druid/core/services/runtime_identity.go b/apps/druid/core/services/runtime_identity.go index 0d1f3e96..68c1d208 100644 --- a/apps/druid/core/services/runtime_identity.go +++ b/apps/druid/core/services/runtime_identity.go @@ -13,7 +13,7 @@ import ( ) func commandRegistry(backend ports.RuntimeBackendInterface) workloadidentity.Registry { - if provider, ok := backend.(ports.CommandIdentityBackend); ok { + if provider, ok := backend.(ports.StoredCommandIdentities); ok { return provider.WorkloadIdentities() } return nil @@ -88,24 +88,56 @@ func (s *RuntimeSession) revokeCommandIdentities(command string) error { } func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token string, deployment string) (workloadidentity.Record, error) { - registry := commandRegistry(s.runtimeBackend) - if registry == nil { + backend, ok := s.runtimeBackend.(ports.CommandIdentityBackend) + if !ok { return workloadidentity.Record{}, workloadidentity.ErrUnauthorized } - record, err := registry.Lookup(ctx, token) + record, err := backend.AuthenticateCommandIdentity(ctx, token) if err != nil { return workloadidentity.Record{}, err } if record.DeploymentID != deployment { return workloadidentity.Record{}, workloadidentity.ErrUnauthorized } + session, err := s.sessionFor(deployment) + if err != nil { + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + } + session.mu.Lock() + defer session.mu.Unlock() + runtime := session.runtimeScroll + definition := session.scrollService.GetFile().Commands[record.Command] + if definition == nil || definition.Identity == nil || (record.RuntimeRoot != "" && record.RuntimeRoot != runtime.Root) || runtime.Status == domain.RuntimeScrollStatusDeleted || runtime.Status == domain.RuntimeScrollStatusStopped { + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + } + statuses := runtime.Procedures[record.Command] + status, exists := deriveCommandStatus(statuses, record.Command, definition) + if !exists || (status != domain.ScrollLockStatusRunning && status != domain.ScrollLockStatusWaiting) { + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + } + for _, current := range statuses { + if current.ExecutionID != record.ExecutionID || record.ExecutionID == "" { + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + } + } + permissions := append([]string{}, definition.Identity.Permissions...) + if record.Permissions != nil { + // File-backed identities retain the grants captured at provisioning. + permissions = nil + for _, permission := range definition.Identity.Permissions { + if record.Allows(permission) { + permissions = append(permissions, permission) + } + } + } + record.Permissions = permissions return record, nil } -// ReconcileCommandIdentities is safe to retry: registry removal precedes secret -// cleanup, and only the runtime resources determine surviving executions. +// ReconcileCommandIdentities cleans up file credentials using surviving resources. +// Kubernetes has no daemon-owned credential registry to reconcile. func (s *RuntimeSupervisor) ReconcileCommandIdentities(ctx context.Context) error { - backend, ok := s.runtimeBackend.(ports.CommandIdentityBackend) + backend, ok := s.runtimeBackend.(ports.StoredCommandIdentities) if !ok || backend.WorkloadIdentities() == nil { return nil } diff --git a/apps/druid/core/services/runtime_identity_test.go b/apps/druid/core/services/runtime_identity_test.go new file mode 100644 index 00000000..86d01393 --- /dev/null +++ b/apps/druid/core/services/runtime_identity_test.go @@ -0,0 +1,70 @@ +package services + +import ( + "context" + "errors" + "testing" + + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/workloadidentity" +) + +// A Pod-authenticated identity supplies binding, not permission grants. +type podIdentityBackend struct { + *toolTestBackend + record workloadidentity.Record +} + +func (b *podIdentityBackend) AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Record, error) { + return b.record, nil +} + +func TestPodIdentityUsesCurrentDefinitionAndExecution(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*RuntimeSession, *workloadidentity.Record) + }{ + {name: "active"}, + {name: "replaced", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { + s.runtimeScroll.Procedures["agent"]["agent-console"] = domain.LockStatus{ExecutionID: "replacement", Status: domain.ScrollLockStatusRunning} + }}, + {name: "completed", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { + s.runtimeScroll.Procedures["agent"]["agent-console"] = domain.LockStatus{ExecutionID: "agent-execution", Status: domain.ScrollLockStatusDone} + }}, + {name: "failed", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { + s.runtimeScroll.Procedures["agent"]["agent-console"] = domain.LockStatus{ExecutionID: "agent-execution", Status: domain.ScrollLockStatusError} + }}, + {name: "removed", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { + delete(s.scrollService.GetFile().Commands, "agent") + }}, + {name: "opted out", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { + s.scrollService.GetFile().Commands["agent"].Identity = nil + }}, + {name: "stopped", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { + s.runtimeScroll.Status = domain.RuntimeScrollStatusStopped + }}, + {name: "deleted", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { + s.runtimeScroll.Status = domain.RuntimeScrollStatusDeleted + }}, + {name: "other namespace or volume", mutate: func(_ *RuntimeSession, r *workloadidentity.Record) { r.RuntimeRoot = "k8s://other/data" }}, + {name: "other deployment", mutate: func(_ *RuntimeSession, r *workloadidentity.Record) { r.DeploymentID = "other" }}, + } { + t.Run(test.name, func(t *testing.T) { + supervisor, backend, _ := commandToolsFixture(t, "commands:list", "queue:read") + session := supervisor.sessions["tools"] + record := workloadidentity.Record{RuntimeRoot: session.runtimeScroll.Root, DeploymentID: "tools", Command: "agent", ExecutionID: "agent-execution"} + if test.mutate != nil { + test.mutate(session, &record) + } + supervisor.runtimeBackend = &podIdentityBackend{toolTestBackend: backend, record: record} + authenticated, err := supervisor.AuthenticateCommand(context.Background(), "pod-bound-token", "tools") + if test.name == "active" { + if err != nil || !authenticated.Allows("commands:list") || !authenticated.Allows("queue:read") || authenticated.Allows("commands:run") { + t.Fatalf("identity=%+v err=%v", authenticated, err) + } + } else if !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatalf("accepted %s: %v", test.name, err) + } + }) + } +} diff --git a/apps/druid/core/services/runtime_session_execution.go b/apps/druid/core/services/runtime_session_execution.go index ef31c5ae..7b9d5f87 100644 --- a/apps/druid/core/services/runtime_session_execution.go +++ b/apps/druid/core/services/runtime_session_execution.go @@ -92,19 +92,19 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin return err } } - identity := &domain.RuntimeCommandIdentity{ExecutionID: executionID} + identity := &domain.RuntimeCommandIdentity{ExecutionID: executionID, DeploymentID: scrollID} s.provisioning.Store(executionID, true) defer s.provisioning.Delete(executionID) if command.Identity != nil { - registry := commandRegistry(s.runtimeBackend) - if registry == nil || s.workloadAPIURL == "" { - return fmt.Errorf("command identity requires a registry and workload API URL") + backend, ok := s.runtimeBackend.(ports.CommandIdentityBackend) + if !ok || s.workloadAPIURL == "" { + return fmt.Errorf("command identity requires runtime support and workload API URL") } - mount, err := registry.Ensure(context.Background(), workloadidentity.Record{RuntimeRoot: root, Recover: recovering, ExecutionID: executionID, DeploymentID: scrollID, Command: cmd, Permissions: command.Identity.Permissions}) + mount, err := backend.PrepareCommandIdentity(context.Background(), workloadidentity.Record{RuntimeRoot: root, Recover: recovering, ExecutionID: executionID, DeploymentID: scrollID, Command: cmd, Permissions: command.Identity.Permissions}) if err != nil { return err } - identity.SecretName, identity.TokenPath = mount.Secret, mount.Path + identity.Enabled, identity.TokenPath = true, mount.Path } // Never mutate the persisted Scroll or leak transient mount references. copyCommand := *command diff --git a/config/helm-charts/druid-cli/chart_test.go b/config/helm-charts/druid-cli/chart_test.go index a9c916d2..0d0a3f9a 100644 --- a/config/helm-charts/druid-cli/chart_test.go +++ b/config/helm-charts/druid-cli/chart_test.go @@ -17,6 +17,10 @@ func TestChartRendersDefaultAndCustomValues(t *testing.T) { "kind: Service", "kind: Role", "kind: RoleBinding", + "kind: ClusterRole", + "kind: ClusterRoleBinding", + `resources: ["tokenreviews"]`, + `resources: ["serviceaccounts"]`, "--runtime=kubernetes", "--listen=:8081", "--public-listen=:8082", @@ -38,6 +42,18 @@ func TestChartRendersDefaultAndCustomValues(t *testing.T) { if strings.Contains(defaultManifest, `resources: ["*"]`) || strings.Contains(defaultManifest, `verbs: ["*"]`) { t.Fatal("chart rendered wildcard RBAC") } + // TokenReview is cluster-scoped even when all runtime resources are scoped + // to one namespace. Agent accounts themselves never receive a binding. + for _, document := range strings.Split(defaultManifest, "---") { + if strings.Contains(document, `resources: ["tokenreviews"]`) && !strings.Contains(document, "kind: ClusterRole\n") { + t.Fatal("TokenReview was placed in a namespaced Role") + } + if strings.Contains(document, "kind: RoleBinding") || strings.Contains(document, "kind: ClusterRoleBinding") { + if strings.Contains(document, "name: druid-workload-agent") { + t.Fatal("agent account received Kubernetes RBAC") + } + } + } customManifest := helmTemplate(t, "--set", "auth.jwksUrl=https://auth.example.test/.well-known/jwks.json", diff --git a/config/helm-charts/druid-cli/templates/rbac.yaml b/config/helm-charts/druid-cli/templates/rbac.yaml index 23411f1b..379ab1f9 100644 --- a/config/helm-charts/druid-cli/templates/rbac.yaml +++ b/config/helm-charts/druid-cli/templates/rbac.yaml @@ -6,15 +6,15 @@ metadata: labels: {{- include "druid-cli.labels" . | nindent 4 }} rules: - - apiGroups: ["authentication.k8s.io"] - resources: ["tokenreviews"] - verbs: ["create"] - apiGroups: [""] resources: ["configmaps"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + verbs: ["get", "list", "create", "update", "patch", "delete"] - apiGroups: [""] resources: ["secrets"] verbs: ["get", "list", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["serviceaccounts"] + verbs: ["create"] - apiGroups: [""] resources: ["persistentvolumeclaims", "services", "pods"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] @@ -51,3 +51,25 @@ subjects: - kind: ServiceAccount name: {{ include "druid-cli.serviceAccountName" . }} namespace: {{ .Release.Namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "druid-cli.fullname" . }}-{{ .Release.Namespace }}-token-review +rules: + - apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "druid-cli.fullname" . }}-{{ .Release.Namespace }}-token-review +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "druid-cli.fullname" . }}-{{ .Release.Namespace }}-token-review +subjects: + - kind: ServiceAccount + name: {{ include "druid-cli.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} diff --git a/internal/core/domain/command_identity.go b/internal/core/domain/command_identity.go index 0ca1e744..c0d2ec2c 100644 --- a/internal/core/domain/command_identity.go +++ b/internal/core/domain/command_identity.go @@ -7,9 +7,10 @@ type CommandIdentity struct { // RuntimeCommandIdentity is transient mount metadata, never serialized in a Scroll. type RuntimeCommandIdentity struct { - ExecutionID string - SecretName string - TokenPath string + ExecutionID string + DeploymentID string + Enabled bool + TokenPath string } func ValidCommandPermission(permission string) bool { diff --git a/internal/core/ports/command_runtime.go b/internal/core/ports/command_runtime.go index 8cc947fe..bf5bb14e 100644 --- a/internal/core/ports/command_runtime.go +++ b/internal/core/ports/command_runtime.go @@ -8,6 +8,12 @@ import ( // CommandIdentityBackend is implemented by both concrete command runtimes. // It does not replace infrastructure ServiceAccount authentication. type CommandIdentityBackend interface { + PrepareCommandIdentity(context.Context, workloadidentity.Record) (workloadidentity.CredentialMount, error) + AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Record, error) +} + +// StoredCommandIdentities is only needed by runtimes that own credential files. +type StoredCommandIdentities interface { WorkloadIdentities() workloadidentity.Registry ActiveExecutions(context.Context) (map[string]bool, error) } diff --git a/internal/runtime/docker/command_identity.go b/internal/runtime/docker/command_identity.go index b73d0fb8..049cd9fa 100644 --- a/internal/runtime/docker/command_identity.go +++ b/internal/runtime/docker/command_identity.go @@ -7,6 +7,22 @@ import ( "github.com/highcard-dev/daemon/internal/workloadidentity" ) +func (b *Backend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { + if b.identities == nil { + return workloadidentity.CredentialMount{}, workloadidentity.ErrUnavailable + } + return b.identities.Ensure(ctx, record) +} + +func (b *Backend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Record, error) { + if b.identities == nil { + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + } + record, err := b.identities.Lookup(ctx, token) + record.Permissions = append([]string{}, record.Permissions...) + return record, err +} + func (b *Backend) ActiveExecutions(ctx context.Context) (map[string]bool, error) { items, err := b.client.ContainerList(ctx, container.ListOptions{All: true, Filters: filters.NewArgs(filters.Arg("label", workloadidentity.ExecutionLabel))}) if err != nil { diff --git a/internal/runtime/kubernetes/backend.go b/internal/runtime/kubernetes/backend.go index b49eca2d..4ff74b63 100644 --- a/internal/runtime/kubernetes/backend.go +++ b/internal/runtime/kubernetes/backend.go @@ -14,13 +14,10 @@ import ( "github.com/highcard-dev/daemon/internal/core/ports" "github.com/highcard-dev/daemon/internal/utils/logger" - "github.com/highcard-dev/daemon/internal/workloadidentity" "go.uber.org/zap" ) type Backend struct { - closeIdentities context.CancelFunc - identities workloadidentity.Registry client k8sclient.Interface restConfig *rest.Config httpClient *http.Client @@ -78,29 +75,12 @@ func New(config Config, observer ports.ProcedureStatusObserver) (*Backend, error jobExits: make(map[string]recentJobExit), } backend.statsReader = backend.readNodeStatsSummary - identityContext, cancelIdentities := context.WithCancel(context.Background()) - backend.identities, err = newNamespaceIdentities(identityContext, client, namespace) - if err != nil { - cancelIdentities() - return nil, err - } - backend.closeIdentities = cancelIdentities if config.PullImage == "" { logger.Log().Warn("Kubernetes cluster materialization requires --k8s-pull-image or DRUID_K8S_PULL_IMAGE") } return backend, nil } -// Close releases daemon-side watches without stopping surviving workloads. -func (b *Backend) Close() error { - if b.closeIdentities != nil { - b.closeIdentities() - } - return nil -} - -func (b *Backend) WorkloadIdentities() workloadidentity.Registry { return b.identities } - func runtimeRESTConfig(config Config) (*rest.Config, string, string, bool, error) { restConfig, inClusterErr := rest.InClusterConfig() if inClusterErr == nil { diff --git a/internal/runtime/kubernetes/command_authentication_test.go b/internal/runtime/kubernetes/command_authentication_test.go new file mode 100644 index 00000000..5726064e --- /dev/null +++ b/internal/runtime/kubernetes/command_authentication_test.go @@ -0,0 +1,122 @@ +package kubernetes + +import ( + "context" + "errors" + "testing" + + "github.com/highcard-dev/daemon/internal/workloadidentity" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +func agentPod() *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-0", Namespace: "games", UID: types.UID("pod-uid"), + Labels: map[string]string{labelManagedBy: "druid", labelComponent: "runtime", labelScrollID: "data", labelRuntimeID: "data", labelCommand: "agent-command", workloadidentity.ExecutionLabel: "execution"}, + Annotations: map[string]string{commandDeploymentAnnotation: "full-deployment-id", labelCommand: "agent_command"}}, + Spec: corev1.PodSpec{ServiceAccountName: commandServiceAccount, Volumes: []corev1.Volume{pvcVolume("data", "data")}}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } +} + +func TestCommandServiceAccountAuthentication(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*corev1.Pod, *authv1.TokenReviewStatus) + deleted, unavailable bool + }{ + {name: "valid"}, + {name: "wrong audience", mutate: func(_ *corev1.Pod, r *authv1.TokenReviewStatus) { r.Audiences = []string{"druid-cli"} }}, + {name: "unauthenticated", mutate: func(_ *corev1.Pod, r *authv1.TokenReviewStatus) { r.Authenticated = false }}, + {name: "unbound token", mutate: func(_ *corev1.Pod, r *authv1.TokenReviewStatus) { r.User.Extra = nil }}, + {name: "replaced pod", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.UID = "replacement" }}, + {name: "different account", mutate: func(p *corev1.Pod, r *authv1.TokenReviewStatus) { + p.Spec.ServiceAccountName = runtimeWorkerServiceAccount + r.User.Username = "system:serviceaccount:games:" + runtimeWorkerServiceAccount + }}, + {name: "wrong namespace", mutate: func(_ *corev1.Pod, r *authv1.TokenReviewStatus) { + r.User.Username = "system:serviceaccount:other:" + commandServiceAccount + }}, + {name: "unmanaged pod", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Labels[labelManagedBy] = "other" }}, + {name: "worker pod", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Labels[labelComponent] = "worker-pull" }}, + {name: "missing deployment", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { delete(p.Annotations, commandDeploymentAnnotation) }}, + {name: "mismatched command", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Annotations[labelCommand] = "other" }}, + {name: "missing execution", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { delete(p.Labels, workloadidentity.ExecutionLabel) }}, + {name: "wrong runtime root", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { + p.Spec.Volumes[0].PersistentVolumeClaim.ClaimName = "other-data" + }}, + {name: "completed", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Status.Phase = corev1.PodSucceeded }}, + {name: "failed", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Status.Phase = corev1.PodFailed }}, + {name: "terminating", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { now := metav1.Now(); p.DeletionTimestamp = &now }}, + {name: "deleted", deleted: true}, + {name: "API unavailable", unavailable: true}, + } { + t.Run(test.name, func(t *testing.T) { + pod := agentPod() + status := authv1.TokenReviewStatus{Authenticated: true, Audiences: []string{commandTokenAudience}, User: authv1.UserInfo{Username: "system:serviceaccount:games:" + commandServiceAccount, Extra: map[string]authv1.ExtraValue{podNameExtra: {"agent-0"}, podUIDExtra: {"pod-uid"}}}} + if test.mutate != nil { + test.mutate(pod, &status) + } + client := fake.NewSimpleClientset() + if !test.deleted { + if _, err := client.CoreV1().Pods(pod.Namespace).Create(context.Background(), pod, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } + client.PrependReactor("create", "tokenreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + review := action.(ktesting.CreateAction).GetObject().(*authv1.TokenReview) + if len(review.Spec.Audiences) != 1 || review.Spec.Audiences[0] != commandTokenAudience { + t.Fatal("wrong requested audience") + } + if test.unavailable { + return true, nil, errors.New("private API failure") + } + return true, &authv1.TokenReview{Status: status}, nil + }) + backend := NewWithClient(Config{Namespace: "default"}, client) + record, err := backend.AuthenticateCommandIdentity(context.Background(), "credential") + if test.name == "valid" { + if err != nil || record.DeploymentID != "full-deployment-id" || record.Command != "agent_command" || record.RuntimeRoot != ref("games", "data") || record.ExecutionID != "execution" || len(record.Permissions) != 0 { + t.Fatalf("record=%+v err=%v", record, err) + } + // No cache or registry is required after daemon reconstruction. + if _, err := NewWithClient(Config{}, client).AuthenticateCommandIdentity(context.Background(), "rotated-credential"); err != nil { + t.Fatal(err) + } + } else { + expected := workloadidentity.ErrUnauthorized + if test.unavailable { + expected = workloadidentity.ErrUnavailable + } + if !errors.Is(err, expected) { + t.Fatalf("got %v, want %v", err, expected) + } + } + }) + } +} + +func TestCommandIdentityProvisioningOnlyCreatesAnUnprivilegedServiceAccount(t *testing.T) { + client := fake.NewSimpleClientset() + backend := NewWithClient(Config{Namespace: "default"}, client) + for index := 0; index < 2; index++ { + if _, err := backend.PrepareCommandIdentity(context.Background(), workloadidentity.Record{RuntimeRoot: ref("games", "data")}); err != nil { + t.Fatal(err) + } + } + for _, action := range client.Actions() { + if action.GetResource().Resource != "serviceaccounts" || action.GetNamespace() != "games" { + t.Fatalf("unexpected provisioning: %v", action) + } + } + account, err := client.CoreV1().ServiceAccounts("games").Get(context.Background(), commandServiceAccount, metav1.GetOptions{}) + if err != nil || account.AutomountServiceAccountToken == nil || *account.AutomountServiceAccountToken { + t.Fatalf("account=%v err=%v", account, err) + } +} diff --git a/internal/runtime/kubernetes/command_identity.go b/internal/runtime/kubernetes/command_identity.go index 0cced980..8fbc93bc 100644 --- a/internal/runtime/kubernetes/command_identity.go +++ b/internal/runtime/kubernetes/command_identity.go @@ -2,30 +2,48 @@ package kubernetes import ( "context" + "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/workloadidentity" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1 "k8s.io/api/core/v1" ) -func (b *Backend) ActiveExecutions(ctx context.Context) (map[string]bool, error) { - selector := metav1.ListOptions{LabelSelector: workloadidentity.ExecutionLabel} - jobs, err := b.client.BatchV1().Jobs("").List(ctx, selector) - if err != nil { - return nil, err +const commandServiceAccount = "druid-workload-agent" +const commandTokenAudience = "druid-command-tools" +const commandDeploymentAnnotation = "druid.gg/deployment-id" + +func commandIdentityAnnotations(identity *domain.RuntimeCommandIdentity, command string) map[string]string { + if identity == nil || !identity.Enabled { + return nil } - active := map[string]bool{} - for _, job := range jobs.Items { - if job.Status.Succeeded == 0 && !kubernetesJobFailed(&job) && job.DeletionTimestamp == nil { - active[job.Labels[workloadidentity.ExecutionLabel]] = true - } + return map[string]string{commandDeploymentAnnotation: identity.DeploymentID, labelCommand: command} +} + +func (b *Backend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { + namespace, _, err := parseRef(record.RuntimeRoot) + if err == nil { + err = b.ensureRuntimeServiceAccount(ctx, namespace, commandServiceAccount) } - sets, err := b.client.AppsV1().StatefulSets("").List(ctx, selector) + return workloadidentity.CredentialMount{}, err +} + +func (b *Backend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Record, error) { + pod, err := b.authenticateBoundPod(ctx, token, commandTokenAudience) if err != nil { - return nil, err + return workloadidentity.Record{}, err + } + deployment, command := pod.Annotations[commandDeploymentAnnotation], pod.Annotations[labelCommand] + execution := pod.Labels[workloadidentity.ExecutionLabel] + if pod.Spec.ServiceAccountName != commandServiceAccount || pod.DeletionTimestamp != nil || (pod.Status.Phase != corev1.PodRunning && pod.Status.Phase != corev1.PodPending) || pod.Labels[labelManagedBy] != "druid" || pod.Labels[labelComponent] != "runtime" || deployment == "" || command == "" || execution == "" || pod.Labels[labelCommand] != dnsLabel(command) { + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized } - for _, set := range sets.Items { - if set.DeletionTimestamp == nil && (set.Spec.Replicas == nil || *set.Spec.Replicas > 0) { - active[set.Labels[workloadidentity.ExecutionLabel]] = true + for _, volume := range pod.Spec.Volumes { + if volume.Name == "data" && volume.PersistentVolumeClaim != nil && pod.Labels[labelScrollID] == dnsLabel(volume.PersistentVolumeClaim.ClaimName) { + root := ref(pod.Namespace, volume.PersistentVolumeClaim.ClaimName) + if pod.Labels[labelRuntimeID] != dnsLabel(runtimeID(root)) { + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + } + return workloadidentity.Record{RuntimeRoot: root, DeploymentID: deployment, Command: command, ExecutionID: execution}, nil } } - return active, nil + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized } diff --git a/internal/runtime/kubernetes/command_identity_test.go b/internal/runtime/kubernetes/command_identity_test.go index 4e2310c0..2d6fb643 100644 --- a/internal/runtime/kubernetes/command_identity_test.go +++ b/internal/runtime/kubernetes/command_identity_test.go @@ -7,7 +7,7 @@ import ( ) func TestCommandIdentityMountedInJobsAndPersistentProcedures(t *testing.T) { - identity := &domain.RuntimeCommandIdentity{ExecutionID: "execution", SecretName: "druid-workload-execution"} + identity := &domain.RuntimeCommandIdentity{ExecutionID: "execution", Enabled: true} procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: identity} job, err := procedureJobSpec("runtime", ref("runtime", "data"), "automation", "automation.0", "job", 1, procedure, nil, "", "") if err != nil { @@ -17,8 +17,12 @@ func TestCommandIdentityMountedInJobsAndPersistentProcedures(t *testing.T) { t.Fatal("execution label missing") } pod := job.Spec.Template.Spec - if len(pod.Volumes) != 2 || pod.Volumes[1].Secret == nil || pod.Volumes[1].Secret.SecretName != identity.SecretName { - t.Fatal("execution Secret not mounted") + if len(pod.Volumes) != 2 || pod.Volumes[1].Projected == nil || pod.ServiceAccountName != commandServiceAccount || pod.AutomountServiceAccountToken == nil || *pod.AutomountServiceAccountToken { + t.Fatal("bound ServiceAccount token not mounted") + } + projection := pod.Volumes[1].Projected.Sources[0].ServiceAccountToken + if projection.Audience != commandTokenAudience || *projection.ExpirationSeconds != 3600 || projection.Path != "token" { + t.Fatal("incorrect command token projection") } if mounts := pod.Containers[0].VolumeMounts; len(mounts) != 1 || mounts[0].MountPath != "/run/secrets/druid" || !mounts[0].ReadOnly { t.Fatal("credential mount is not protected") @@ -34,3 +38,17 @@ func TestCommandIdentityMountedInJobsAndPersistentProcedures(t *testing.T) { t.Fatal("execution identity changed immutable StatefulSet selector") } } + +func TestCommandsWithoutIdentityReceiveNoCredential(t *testing.T) { + for _, identity := range []*domain.RuntimeCommandIdentity{nil, {ExecutionID: "execution"}} { + procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: identity} + job, err := procedureJobSpec("runtime", ref("runtime", "data"), "command", "command.0", "job", 1, procedure, nil, "", "") + if err != nil { + t.Fatal(err) + } + pod := job.Spec.Template.Spec + if len(pod.Volumes) != 1 || pod.ServiceAccountName != "" || pod.AutomountServiceAccountToken == nil || *pod.AutomountServiceAccountToken || len(job.Spec.Template.Annotations) != 0 { + t.Fatal("non-identity command received credentials") + } + } +} diff --git a/internal/runtime/kubernetes/namespace_identities.go b/internal/runtime/kubernetes/namespace_identities.go deleted file mode 100644 index 166eb8ef..00000000 --- a/internal/runtime/kubernetes/namespace_identities.go +++ /dev/null @@ -1,174 +0,0 @@ -package kubernetes - -import ( - "context" - "errors" - "sync" - - "github.com/highcard-dev/daemon/internal/workloadidentity" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - kubeclient "k8s.io/client-go/kubernetes" -) - -// Runtime roots may select different namespaces. Keep each namespace's tokens -// and shared authorization ConfigMap beside its workloads, never in the daemon's -// default namespace. Authorization itself still uses synchronized local caches. -type namespaceIdentities struct { - ctx context.Context - client kubeclient.Interface - defaultNamespace string - mu sync.Mutex - registries map[string]workloadidentity.Registry - cancel map[string]context.CancelFunc - revoked map[string]bool -} - -func newNamespaceIdentities(ctx context.Context, client kubeclient.Interface, namespace string) (*namespaceIdentities, error) { - r := &namespaceIdentities{ctx: ctx, client: client, defaultNamespace: namespace, registries: map[string]workloadidentity.Registry{}, cancel: map[string]context.CancelFunc{}, revoked: map[string]bool{}} - if _, err := r.namespace(namespace); err != nil { - return nil, err - } - if err := r.discover(ctx); err != nil { - return nil, err - } - return r, nil -} - -func (r *namespaceIdentities) namespace(namespace string) (workloadidentity.Registry, error) { - r.mu.Lock() - defer r.mu.Unlock() - if registry := r.registries[namespace]; registry != nil { - return registry, nil - } - ctx, cancel := context.WithCancel(r.ctx) - registry, err := workloadidentity.NewKubernetesRegistry(ctx, r.client, namespace) - if err != nil { - cancel() - return nil, err - } - r.registries[namespace] = registry - r.cancel[namespace] = cancel - return registry, nil -} - -func (r *namespaceIdentities) discover(ctx context.Context) error { - maps, err := r.client.CoreV1().ConfigMaps("").List(ctx, metav1.ListOptions{FieldSelector: "metadata.name=" + workloadidentity.RegistryConfigMap}) - if err != nil { - return err - } - present := map[string]bool{} - for _, cm := range maps.Items { - if cm.Name != workloadidentity.RegistryConfigMap { - continue - } - if _, err := r.namespace(cm.Namespace); err != nil { - return err - } - present[cm.Namespace] = true - } - // Deleted runtime namespaces must not leave failed watches blocking cleanup - // in unrelated namespaces. A healthy newly-created watch is kept even if it - // appeared after the list snapshot. - r.mu.Lock() - defer r.mu.Unlock() - for namespace, registry := range r.registries { - if namespace == r.defaultNamespace || present[namespace] { - continue - } - if _, err := registry.Records(ctx); errors.Is(err, workloadidentity.ErrUnavailable) { - r.cancel[namespace]() - delete(r.cancel, namespace) - delete(r.registries, namespace) - } - } - return nil -} - -func (r *namespaceIdentities) snapshot() []workloadidentity.Registry { - r.mu.Lock() - defer r.mu.Unlock() - registries := make([]workloadidentity.Registry, 0, len(r.registries)) - for _, registry := range r.registries { - registries = append(registries, registry) - } - return registries -} - -func (r *namespaceIdentities) Ensure(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { - namespace := r.defaultNamespace - if record.RuntimeRoot != "" { - var err error - namespace, _, err = parseRef(record.RuntimeRoot) - if err != nil { - return workloadidentity.CredentialMount{}, err - } - } - registry, err := r.namespace(namespace) - if err != nil { - return workloadidentity.CredentialMount{}, err - } - r.mu.Lock() - revoked := r.revoked[record.ExecutionID] - r.mu.Unlock() - if revoked { - return workloadidentity.CredentialMount{}, workloadidentity.ErrUnauthorized - } - return registry.Ensure(ctx, record) -} - -func (r *namespaceIdentities) Lookup(ctx context.Context, token string) (workloadidentity.Record, error) { - var unavailable bool - for _, registry := range r.snapshot() { - record, err := registry.Lookup(ctx, token) - if err == nil { - r.mu.Lock() - revoked := r.revoked[record.ExecutionID] - r.mu.Unlock() - if revoked { - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized - } - return record, nil - } - unavailable = unavailable || errors.Is(err, workloadidentity.ErrUnavailable) - } - if unavailable { - return workloadidentity.Record{}, workloadidentity.ErrUnavailable - } - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized -} - -func (r *namespaceIdentities) Records(ctx context.Context) ([]workloadidentity.Record, error) { - if err := r.discover(ctx); err != nil { - return nil, err - } - var records []workloadidentity.Record - for _, registry := range r.snapshot() { - values, err := registry.Records(ctx) - if err != nil { - return nil, err - } - records = append(records, values...) - } - return records, nil -} - -func (r *namespaceIdentities) Revoke(ctx context.Context, execution string) error { - r.mu.Lock() - r.revoked[execution] = true - r.mu.Unlock() - for _, registry := range r.snapshot() { - if err := registry.Revoke(ctx, execution); err != nil { - return err - } - } - return nil -} - -func (r *namespaceIdentities) Prune(ctx context.Context, active map[string]bool) error { - for _, registry := range r.snapshot() { - if err := registry.Prune(ctx, active); err != nil { - return err - } - } - return nil -} diff --git a/internal/runtime/kubernetes/namespace_identities_test.go b/internal/runtime/kubernetes/namespace_identities_test.go deleted file mode 100644 index 4b8acff5..00000000 --- a/internal/runtime/kubernetes/namespace_identities_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package kubernetes - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/highcard-dev/daemon/internal/workloadidentity" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes/fake" -) - -func TestCommandIdentityFollowsRuntimeNamespaceAndSurvivesReattachment(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - client := fake.NewSimpleClientset() - registry, err := newNamespaceIdentities(ctx, client, "daemon") - if err != nil { - t.Fatal(err) - } - mount, err := registry.Ensure(ctx, workloadidentity.Record{RuntimeRoot: "k8s://deployment/data", ExecutionID: "execution", DeploymentID: "deployment", Command: "agent", Permissions: []string{"logs:read"}}) - if err != nil { - t.Fatal(err) - } - secret, err := client.CoreV1().Secrets("deployment").Get(ctx, mount.Secret, metav1.GetOptions{}) - if err != nil { - t.Fatal("credential not mounted in workload namespace", err) - } - if secrets, _ := client.CoreV1().Secrets("daemon").List(ctx, metav1.ListOptions{}); len(secrets.Items) != 0 { - t.Fatal("credential placed in daemon namespace") - } - reopened, err := newNamespaceIdentities(ctx, client, "daemon") - if err != nil { - t.Fatal(err) - } - token := string(secret.Data["token"]) - if record, err := reopened.Lookup(ctx, token); err != nil || record.DeploymentID != "deployment" { - t.Fatal("namespace registry not recovered", err) - } - if err := reopened.Revoke(ctx, "execution"); err != nil { - t.Fatal(err) - } - if _, err := reopened.Ensure(ctx, workloadidentity.Record{RuntimeRoot: "k8s://new-namespace/data", ExecutionID: "execution", DeploymentID: "deployment", Command: "agent"}); !errors.Is(err, workloadidentity.ErrUnauthorized) { - t.Fatal("late provisioning in a new namespace resurrected a revoked execution") - } - deadline := time.Now().Add(time.Second) - for { - if _, err := registry.Lookup(ctx, token); err != nil { - break - } - if time.Now().After(deadline) { - t.Fatal("cross-daemon revocation did not propagate") - } - time.Sleep(time.Millisecond) - } -} diff --git a/internal/runtime/kubernetes/resources.go b/internal/runtime/kubernetes/resources.go index 784e771c..a8c619e8 100644 --- a/internal/runtime/kubernetes/resources.go +++ b/internal/runtime/kubernetes/resources.go @@ -242,7 +242,7 @@ func procedureJobSpec(namespace string, root string, commandName string, procedu BackoffLimit: &backoff, TTLSecondsAfterFinished: finishedJobTTL(), Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: labels}, + ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: commandIdentityAnnotations(procedure.RuntimeIdentity, commandName)}, Spec: podSpec, }, }, @@ -352,7 +352,7 @@ func persistentStatefulSetSpec(namespace string, root string, commandName string ServiceName: resourceName, Selector: &metav1.LabelSelector{MatchLabels: selectorLabels}, Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: labels}, + ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: commandIdentityAnnotations(mainProcedure.RuntimeIdentity, commandName)}, Spec: podSpec, }, }, @@ -360,14 +360,18 @@ func persistentStatefulSetSpec(namespace string, root string, commandName string } func applyCommandIdentity(pod *corev1.PodSpec, labels map[string]string, identity *domain.RuntimeCommandIdentity) { + pod.AutomountServiceAccountToken = ptrBool(false) if identity == nil { return } labels[workloadidentity.ExecutionLabel] = identity.ExecutionID - if identity.SecretName == "" { + if !identity.Enabled { return } - pod.Volumes = append(pod.Volumes, corev1.Volume{Name: "command-identity", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: identity.SecretName}}}) + pod.ServiceAccountName = commandServiceAccount + pod.AutomountServiceAccountToken = ptrBool(false) + expiration := int64(3600) + pod.Volumes = append(pod.Volumes, corev1.Volume{Name: "command-identity", VolumeSource: corev1.VolumeSource{Projected: &corev1.ProjectedVolumeSource{Sources: []corev1.VolumeProjection{{ServiceAccountToken: &corev1.ServiceAccountTokenProjection{Audience: commandTokenAudience, ExpirationSeconds: &expiration, Path: "token"}}}}}}) mount := corev1.VolumeMount{Name: "command-identity", MountPath: "/run/secrets/druid", ReadOnly: true} for index := range pod.Containers { pod.Containers[index].VolumeMounts = append(pod.Containers[index].VolumeMounts, mount) diff --git a/internal/runtime/kubernetes/workload_identity.go b/internal/runtime/kubernetes/workload_identity.go index e55c7b14..c44909c9 100644 --- a/internal/runtime/kubernetes/workload_identity.go +++ b/internal/runtime/kubernetes/workload_identity.go @@ -5,12 +5,12 @@ import ( "fmt" "strings" + "github.com/highcard-dev/daemon/internal/core/ports" + "github.com/highcard-dev/daemon/internal/workloadidentity" authv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/highcard-dev/daemon/internal/core/ports" ) const ( @@ -18,54 +18,61 @@ const ( podUIDExtra = "authentication.kubernetes.io/pod-uid" ) -// AuthenticateWorkload verifies the bound ServiceAccount token with the API -// server and then re-checks the pod that owns it. The pod lookup makes a token -// invalid as soon as its bound workload disappears. -func (b *Backend) AuthenticateWorkload(ctx context.Context, token string) (ports.RuntimeWorkloadIdentity, error) { - token = strings.TrimSpace(token) - if token == "" { - return ports.RuntimeWorkloadIdentity{}, fmt.Errorf("missing workload bearer token") +// Infrastructure and command identities share verification, never authorization. +func (b *Backend) authenticateBoundPod(ctx context.Context, token, audience string) (*corev1.Pod, error) { + if strings.TrimSpace(token) == "" { + return nil, workloadidentity.ErrUnauthorized } review, err := b.client.AuthenticationV1().TokenReviews().Create(ctx, &authv1.TokenReview{ - Spec: authv1.TokenReviewSpec{Token: token, Audiences: []string{b.config.ServiceAccountAudience}}, + Spec: authv1.TokenReviewSpec{Token: token, Audiences: []string{audience}}, }, metav1.CreateOptions{}) if err != nil { - return ports.RuntimeWorkloadIdentity{}, fmt.Errorf("review workload token: %w", err) + return nil, workloadidentity.ErrUnavailable } - if !review.Status.Authenticated || len(review.Status.Audiences) == 0 { - return ports.RuntimeWorkloadIdentity{}, fmt.Errorf("workload token is not authenticated for audience %q", b.config.ServiceAccountAudience) + matchedAudience := false + for _, accepted := range review.Status.Audiences { + matchedAudience = matchedAudience || accepted == audience } - namespace, serviceAccount, ok := parseServiceAccountSubject(review.Status.User.Username) - if !ok { - return ports.RuntimeWorkloadIdentity{}, fmt.Errorf("workload token is not a service account") + if !review.Status.Authenticated || !matchedAudience { + return nil, workloadidentity.ErrUnauthorized } - podName := userExtra(review.Status.User.Extra, podNameExtra) - podUID := userExtra(review.Status.User.Extra, podUIDExtra) - if podName == "" || podUID == "" { - return ports.RuntimeWorkloadIdentity{}, fmt.Errorf("workload token is not bound to a pod") + namespace, serviceAccount, ok := parseServiceAccountSubject(review.Status.User.Username) + podName, podUID := userExtra(review.Status.User.Extra, podNameExtra), userExtra(review.Status.User.Extra, podUIDExtra) + if !ok || podName == "" || podUID == "" { + return nil, workloadidentity.ErrUnauthorized } pod, err := b.client.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + return nil, workloadidentity.ErrUnauthorized + } if err != nil { - if apierrors.IsNotFound(err) { - return ports.RuntimeWorkloadIdentity{}, fmt.Errorf("bound pod no longer exists") - } - return ports.RuntimeWorkloadIdentity{}, fmt.Errorf("get bound pod: %w", err) + return nil, workloadidentity.ErrUnavailable } if string(pod.UID) != podUID || pod.Spec.ServiceAccountName != serviceAccount { - return ports.RuntimeWorkloadIdentity{}, fmt.Errorf("workload token does not match live pod") + return nil, workloadidentity.ErrUnauthorized + } + return pod, nil +} + +func (b *Backend) AuthenticateWorkload(ctx context.Context, token string) (ports.RuntimeWorkloadIdentity, error) { + pod, err := b.authenticateBoundPod(ctx, token, b.config.ServiceAccountAudience) + if err != nil { + return ports.RuntimeWorkloadIdentity{}, err } identity := ports.RuntimeWorkloadIdentity{ - Namespace: namespace, ServiceAccount: serviceAccount, PodName: podName, PodUID: podUID, + Namespace: pod.Namespace, ServiceAccount: pod.Spec.ServiceAccountName, PodName: pod.Name, PodUID: string(pod.UID), RuntimeID: pod.Labels[labelRuntimeID], } - switch serviceAccount { + switch identity.ServiceAccount { + case commandServiceAccount: + return ports.RuntimeWorkloadIdentity{}, workloadidentity.ErrUnauthorized case runtimeWorkerServiceAccount: if pod.Labels[labelManagedBy] != "druid" || pod.Labels[labelComponent] != "worker-pull" || identity.RuntimeID == "" { return ports.RuntimeWorkloadIdentity{}, fmt.Errorf("invalid Druid worker workload") } identity.Kind = "worker" default: - if !b.isOperatorServiceAccount(namespace, serviceAccount) { + if !b.isOperatorServiceAccount(pod.Namespace, identity.ServiceAccount) { return ports.RuntimeWorkloadIdentity{}, fmt.Errorf("service account is not permitted") } identity.Kind = "operator" @@ -74,37 +81,23 @@ func (b *Backend) AuthenticateWorkload(ctx context.Context, token string) (ports } func (b *Backend) ensureRuntimeServiceAccount(ctx context.Context, namespace string, name string) error { - serviceAccount := &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: map[string]string{ - labelManagedBy: "druid", labelComponent: "workload-identity", - }}, + _, err := b.client.CoreV1().ServiceAccounts(namespace).Create(ctx, &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: map[string]string{labelManagedBy: "druid", labelComponent: "workload-identity"}}, AutomountServiceAccountToken: ptrBool(false), - } - _, err := b.client.CoreV1().ServiceAccounts(namespace).Create(ctx, serviceAccount, metav1.CreateOptions{}) + }, metav1.CreateOptions{}) if apierrors.IsAlreadyExists(err) { return nil } return err } -// ensureRuntimeServiceAccounts provisions the fixed identities used by Druid -// runtime workloads. Create plus AlreadyExists is an atomic existence check, -// so concurrent deployment startup cannot race between a separate get/create. func (b *Backend) ensureRuntimeServiceAccounts(ctx context.Context, namespace string) error { - for _, name := range []string{runtimeWorkerServiceAccount} { - if err := b.ensureRuntimeServiceAccount(ctx, namespace, name); err != nil { - return fmt.Errorf("ensure runtime service account %q: %w", name, err) - } - } - return nil + return b.ensureRuntimeServiceAccount(ctx, namespace, runtimeWorkerServiceAccount) } func (b *Backend) isOperatorServiceAccount(namespace string, serviceAccount string) bool { expected := strings.TrimSpace(b.config.OperatorServiceAccount) - if expected == "" { - return false - } - return expected == namespace+"/"+serviceAccount + return expected != "" && expected == namespace+"/"+serviceAccount } func parseServiceAccountSubject(subject string) (string, string, bool) { diff --git a/internal/workloadidentity/identity.go b/internal/workloadidentity/identity.go index 1c14852b..cb40efb3 100644 --- a/internal/workloadidentity/identity.go +++ b/internal/workloadidentity/identity.go @@ -17,17 +17,17 @@ const TokenFile = "/run/secrets/druid/token" const ExecutionLabel = "druid.execution-id" var ErrUnauthorized = errors.New("invalid workload credential") -var ErrUnavailable = errors.New("workload identity registry is not synchronized") -var tokenPattern = regexp.MustCompile(`druid_wl_[A-Za-z0-9_-]{43}`) +var ErrUnavailable = errors.New("workload identity verification is unavailable") +var tokenPattern = regexp.MustCompile(`druid_wl_[A-Za-z0-9_-]{43}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`) var executionFileName = regexp.MustCompile(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`) type Record struct { - RuntimeRoot string `json:"-"` // Provisioning hint; never an authorization field. + RuntimeRoot string `json:"-"` // Provisioning root or verified Pod volume binding. Recover bool `json:"-"` ExecutionID string `json:"execution_id"` DeploymentID string `json:"deployment_id"` Command string `json:"command"` - Permissions []string `json:"permissions"` + Permissions []string `json:"permissions"` // nil derives grants from the command; non-nil caps them. } func (r Record) Allows(permission string) bool { @@ -41,8 +41,7 @@ func (r Record) Allows(permission string) bool { // CredentialMount contains only a backend-owned reference, never raw token data. type CredentialMount struct { - Secret string - Path string + Path string } type Registry interface { diff --git a/internal/workloadidentity/identity_test.go b/internal/workloadidentity/identity_test.go index b6173038..ee6458f6 100644 --- a/internal/workloadidentity/identity_test.go +++ b/internal/workloadidentity/identity_test.go @@ -6,136 +6,9 @@ import ( "os" "path/filepath" "strings" - "sync" - "sync/atomic" "testing" - "time" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/watch" - "k8s.io/client-go/kubernetes/fake" - ktesting "k8s.io/client-go/testing" ) -func TestKubernetesRegistryWatchesFreshCollectionRevision(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: RegistryConfigMap, Namespace: "runtime", ResourceVersion: "1"}} - client := fake.NewSimpleClientset(cm) - client.PrependReactor("list", "configmaps", func(ktesting.Action) (bool, runtime.Object, error) { - return true, &corev1.ConfigMapList{ListMeta: metav1.ListMeta{ResourceVersion: "1000"}, Items: []corev1.ConfigMap{*cm}}, nil - }) - client.PrependWatchReactor("configmaps", func(action ktesting.Action) (bool, watch.Interface, error) { - if action.(ktesting.WatchAction).GetWatchRestrictions().ResourceVersion != "1000" { - return true, nil, apierrors.NewResourceExpired("object revision was compacted") - } - return true, watch.NewRaceFreeFake(), nil - }) - registry, err := NewKubernetesRegistry(ctx, client, "runtime") - if err != nil { - t.Fatalf("idle ConfigMap failed to synchronize: %v", err) - } - if _, err := registry.Records(ctx); err != nil { - t.Fatal(err) - } -} - -func TestKubernetesRegistryRequiresInitialSyncAndRecoversAfterWatchLoss(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - client := fake.NewSimpleClientset() - var unavailable atomic.Bool - streams := make(chan *watch.RaceFreeFakeWatcher, 8) - client.PrependWatchReactor("configmaps", func(ktesting.Action) (bool, watch.Interface, error) { - if unavailable.Load() { - return true, nil, errors.New("watch disconnected") - } - stream := watch.NewRaceFreeFake() - streams <- stream - return true, stream, nil - }) - registry, err := NewKubernetesRegistry(ctx, client, "runtime") - if err != nil { - t.Fatal(err) - } - stream := <-streams - mount, err := registry.Ensure(ctx, Record{ExecutionID: "execution", DeploymentID: "deployment"}) - if err != nil { - t.Fatal(err) - } - secret, err := client.CoreV1().Secrets("runtime").Get(ctx, mount.Secret, metav1.GetOptions{}) - if err != nil { - t.Fatal(err) - } - token := string(secret.Data["token"]) - cm, err := client.CoreV1().ConfigMaps("runtime").Get(ctx, RegistryConfigMap, metav1.GetOptions{}) - if err != nil { - t.Fatal(err) - } - stream.Modify(cm) - wait := func(healthy bool) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for { - _, err := registry.Lookup(ctx, token) - if (healthy && err == nil) || (!healthy && errors.Is(err, ErrUnavailable)) { - return - } - if time.Now().After(deadline) { - t.Fatalf("cache healthy=%v not reached: %v", healthy, err) - } - time.Sleep(time.Millisecond) - } - } - wait(true) - unavailable.Store(true) - stream.Stop() - wait(false) - unavailable.Store(false) - wait(true) - - blocked := fake.NewSimpleClientset() - blocked.PrependWatchReactor("configmaps", func(ktesting.Action) (bool, watch.Interface, error) { return true, nil, errors.New("no watch") }) - short, stop := context.WithTimeout(ctx, 50*time.Millisecond) - defer stop() - if _, err := NewKubernetesRegistry(short, blocked, "runtime"); err == nil { - t.Fatal("registry accepted requests before initial watch synchronization") - } -} - -func TestKubernetesRegistryPrunesOnlyStaleOrphanCredentials(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - client := fake.NewSimpleClientset() - registry, err := NewKubernetesRegistry(ctx, client, "runtime") - if err != nil { - t.Fatal(err) - } - for _, id := range []string{"old", "fresh"} { - created := metav1.NewTime(time.Now().Add(-2 * time.Minute)) - if id == "fresh" { - created = metav1.Now() - } - _, err := client.CoreV1().Secrets("runtime").Create(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "druid-workload-" + id, CreationTimestamp: created, Labels: map[string]string{ExecutionLabel: id}}}, metav1.CreateOptions{}) - if err != nil { - t.Fatal(err) - } - } - if err := registry.Prune(ctx, map[string]bool{}); err != nil { - t.Fatal(err) - } - if _, err := client.CoreV1().Secrets("runtime").Get(ctx, "druid-workload-old", metav1.GetOptions{}); !apierrors.IsNotFound(err) { - t.Fatal("stale orphan retained") - } - if _, err := client.CoreV1().Secrets("runtime").Get(ctx, "druid-workload-fresh", metav1.GetOptions{}); err != nil { - t.Fatal("fresh provisioner raced by pruning") - } -} - func TestFileRegistryPreservesRevokesAndSeparatesCredentials(t *testing.T) { ctx := context.Background() directory := t.TempDir() @@ -190,88 +63,3 @@ func TestFileRegistryPreservesRevokesAndSeparatesCredentials(t *testing.T) { t.Fatal("unsafe credential path accepted") } } - -func TestKubernetesRegistryConcurrentWritesCacheAndRevocation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - client := fake.NewSimpleClientset() - registry, err := NewKubernetesRegistry(ctx, client, "runtime") - if err != nil { - t.Fatal(err) - } - var once sync.Once - client.PrependReactor("update", "configmaps", func(action ktesting.Action) (bool, runtime.Object, error) { - conflict := false - once.Do(func() { conflict = true }) - if conflict { - return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "configmaps"}, RegistryConfigMap, errors.New("fixture conflict")) - } - return false, nil, nil - }) - var group sync.WaitGroup - for _, execution := range []string{"one", "two", "three", "four"} { - group.Add(1) - go func(id string) { - defer group.Done() - if _, err := registry.Ensure(ctx, Record{ExecutionID: id, DeploymentID: "deployment", Command: "agent", Permissions: []string{"logs:read"}}); err != nil { - t.Errorf("ensure: %v", err) - } - }(execution) - } - group.Wait() - deadline := time.Now().Add(3 * time.Second) - for { - records, err := registry.Records(ctx) - if err == nil && len(records) == 4 { - break - } - if time.Now().After(deadline) { - t.Fatal("watch did not synchronize concurrent updates") - } - time.Sleep(time.Millisecond) - } - cm, err := client.CoreV1().ConfigMaps("runtime").Get(ctx, RegistryConfigMap, metav1.GetOptions{}) - if err != nil || len(cm.Data) != 4 { - t.Fatal("registry updates were lost") - } - secret, err := client.CoreV1().Secrets("runtime").Get(ctx, "druid-workload-one", metav1.GetOptions{}) - if err != nil { - t.Fatal(err) - } - token := string(secret.Data["token"]) - for _, value := range cm.Data { - if strings.Contains(value, token) { - t.Fatal("raw credential exposed in ConfigMap") - } - } - client.ClearActions() - for index := 0; index < 10; index++ { - if _, err := registry.Lookup(ctx, token); err != nil { - t.Fatal(err) - } - } - if len(client.Actions()) != 0 { - t.Fatal("authorization performed per-request API lookups") - } - if err := registry.Revoke(ctx, "one"); err != nil { - t.Fatal(err) - } - for { - _, err := registry.Lookup(ctx, token) - if errors.Is(err, ErrUnauthorized) { - break - } - if time.Now().After(deadline) { - t.Fatal("revocation did not propagate") - } - time.Sleep(time.Millisecond) - } - registry.setCache(nil, false) - if _, err := registry.Lookup(ctx, token); !errors.Is(err, ErrUnavailable) { - t.Fatal("unhealthy cache did not fail closed") - } - registry.setCache(map[string]string{}, true) - if _, err := registry.Lookup(ctx, token); !errors.Is(err, ErrUnauthorized) { - t.Fatal("recovered cache accepted revoked credential") - } -} diff --git a/internal/workloadidentity/kubernetes_registry.go b/internal/workloadidentity/kubernetes_registry.go deleted file mode 100644 index 874d920a..00000000 --- a/internal/workloadidentity/kubernetes_registry.go +++ /dev/null @@ -1,266 +0,0 @@ -package workloadidentity - -import ( - "context" - "encoding/json" - "fmt" - "sync" - "time" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/watch" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/util/retry" -) - -const RegistryConfigMap = "druid-workload-identities" - -type KubernetesRegistry struct { - revoked map[string]bool - writeMu sync.Mutex - client kubernetes.Interface - namespace string - mu sync.RWMutex - records map[string]Record - healthy bool -} - -func NewKubernetesRegistry(ctx context.Context, client kubernetes.Interface, namespace string) (*KubernetesRegistry, error) { - r := &KubernetesRegistry{client: client, namespace: namespace, revoked: map[string]bool{}} - _, err := client.CoreV1().ConfigMaps(namespace).Create(ctx, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: RegistryConfigMap}, Data: map[string]string{}}, metav1.CreateOptions{}) - if err != nil && !apierrors.IsAlreadyExists(err) { - return nil, err - } - ready := make(chan struct{}) - go r.watch(ctx, ready) - select { - case <-ready: - return r, nil - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(15 * time.Second): - return nil, ErrUnavailable - } -} - -func decodeRecords(data map[string]string) (map[string]Record, error) { - records := make(map[string]Record, len(data)) - for key, value := range data { - var record Record - if err := json.Unmarshal([]byte(value), &record); err != nil { - return nil, err - } - records[key] = record - } - return records, nil -} - -func (r *KubernetesRegistry) setCache(data map[string]string, healthy bool) { - records, err := decodeRecords(data) - r.mu.Lock() - defer r.mu.Unlock() - r.records = records - r.healthy = healthy && err == nil -} - -// Use the collection revision: an idle object's revision may already have been -// compacted. Every disconnect fails closed until the next list/watch sync. -func (r *KubernetesRegistry) watch(ctx context.Context, ready chan struct{}) { - var initial sync.Once - for ctx.Err() == nil { - maps, err := r.client.CoreV1().ConfigMaps(r.namespace).List(ctx, metav1.ListOptions{FieldSelector: "metadata.name=" + RegistryConfigMap}) - if err == nil && len(maps.Items) == 1 { - var stream watch.Interface - stream, err = r.client.CoreV1().ConfigMaps(r.namespace).Watch(ctx, metav1.ListOptions{FieldSelector: "metadata.name=" + RegistryConfigMap, ResourceVersion: maps.ResourceVersion, TimeoutSeconds: ptrInt64(300)}) - if err == nil { - r.setCache(maps.Items[0].Data, true) - initial.Do(func() { close(ready) }) - watching: - for { - select { - case <-ctx.Done(): - break watching - case event, ok := <-stream.ResultChan(): - if !ok || event.Type == watch.Error || event.Type == watch.Deleted { - break watching - } - if updated, ok := event.Object.(*corev1.ConfigMap); ok { - r.setCache(updated.Data, true) - } - } - } - stream.Stop() - } - } - r.setCache(nil, false) - select { - case <-ctx.Done(): - return - case <-time.After(200 * time.Millisecond): - } - } - r.setCache(nil, false) -} - -func ptrInt64(value int64) *int64 { return &value } - -func (r *KubernetesRegistry) mutate(ctx context.Context, change func(map[string]string) error) error { - r.writeMu.Lock() - defer r.writeMu.Unlock() - return retry.RetryOnConflict(retry.DefaultRetry, func() error { - cm, err := r.client.CoreV1().ConfigMaps(r.namespace).Get(ctx, RegistryConfigMap, metav1.GetOptions{}) - if err != nil { - return err - } - if cm.Data == nil { - cm.Data = map[string]string{} - } - if err := change(cm.Data); err != nil { - return err - } - _, err = r.client.CoreV1().ConfigMaps(r.namespace).Update(ctx, cm, metav1.UpdateOptions{}) - return err - }) -} - -func (r *KubernetesRegistry) Ensure(ctx context.Context, record Record) (CredentialMount, error) { - name := "druid-workload-" + record.ExecutionID - secret, err := r.client.CoreV1().Secrets(r.namespace).Get(ctx, name, metav1.GetOptions{}) - created := false - if apierrors.IsNotFound(err) { - if record.Recover { - return CredentialMount{}, ErrUnauthorized - } - token, tokenErr := NewToken() - if tokenErr != nil { - return CredentialMount{}, tokenErr - } - secret, err = r.client.CoreV1().Secrets(r.namespace).Create(ctx, &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{ExecutionLabel: record.ExecutionID}}, - Data: map[string][]byte{"token": []byte(token)}, - }, metav1.CreateOptions{}) - created = err == nil - if apierrors.IsAlreadyExists(err) { - secret, err = r.client.CoreV1().Secrets(r.namespace).Get(ctx, name, metav1.GetOptions{}) - } - } - if err != nil { - return CredentialMount{}, err - } - if len(secret.Data["token"]) == 0 { - return CredentialMount{}, fmt.Errorf("execution credential is missing") - } - hash := Hash(string(secret.Data["token"])) - err = r.mutate(ctx, func(data map[string]string) error { - if r.revoked[record.ExecutionID] { - return ErrUnauthorized - } - if existing, ok := data[hash]; ok { - var old Record - if err := json.Unmarshal([]byte(existing), &old); err != nil { - return err - } - if old.ExecutionID != record.ExecutionID || old.DeploymentID != record.DeploymentID || old.Command != record.Command { - return ErrUnauthorized - } - return nil // Persisted grants, not a new request, remain authoritative. - } - if !created { - return ErrUnauthorized - } // Never resurrect a revoked credential. - value, err := json.Marshal(record) - if err != nil { - return err - } - data[hash] = string(value) - return nil - }) - if err != nil { - if created { - _ = r.client.CoreV1().Secrets(r.namespace).Delete(ctx, name, metav1.DeleteOptions{}) - } - return CredentialMount{}, err - } - return CredentialMount{Secret: name}, nil -} - -func (r *KubernetesRegistry) Lookup(_ context.Context, token string) (Record, error) { - r.mu.RLock() - defer r.mu.RUnlock() - if !r.healthy { - return Record{}, ErrUnavailable - } - record, ok := r.records[Hash(token)] - if !ok { - return Record{}, ErrUnauthorized - } - return record, nil -} - -func (r *KubernetesRegistry) Records(_ context.Context) ([]Record, error) { - r.mu.RLock() - defer r.mu.RUnlock() - if !r.healthy { - return nil, ErrUnavailable - } - records := make([]Record, 0, len(r.records)) - for _, record := range r.records { - records = append(records, record) - } - return records, nil -} - -func (r *KubernetesRegistry) Revoke(ctx context.Context, execution string) error { - if err := r.mutate(ctx, func(data map[string]string) error { - r.revoked[execution] = true - for key, value := range data { - var record Record - if err := json.Unmarshal([]byte(value), &record); err != nil { - return err - } - if record.ExecutionID == execution { - delete(data, key) - } - } - return nil - }); err != nil { - return err - } - err := r.client.CoreV1().Secrets(r.namespace).Delete(ctx, "druid-workload-"+execution, metav1.DeleteOptions{}) - if apierrors.IsNotFound(err) { - return nil - } - return err -} - -func (r *KubernetesRegistry) Prune(ctx context.Context, active map[string]bool) error { - records, err := r.Records(ctx) - if err != nil { - return err - } - known := map[string]bool{} - for _, record := range records { - known[record.ExecutionID] = true - } - secrets, err := r.client.CoreV1().Secrets(r.namespace).List(ctx, metav1.ListOptions{LabelSelector: ExecutionLabel}) - if err != nil { - return err - } - for _, secret := range secrets.Items { - execution := secret.Labels[ExecutionLabel] - // Secret creation precedes registry publication. Never race a fresh - // provisioner (including another daemon) or its watch propagation. - if time.Since(secret.CreationTimestamp.Time) < time.Minute { - continue - } - if known[execution] || active[execution] || secret.Name != "druid-workload-"+execution { - continue - } - if err := r.client.CoreV1().Secrets(r.namespace).Delete(ctx, secret.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { - return err - } - } - return nil -} diff --git a/test/integration/agent_ui_test.go b/test/integration/agent_ui_test.go index 691d92d5..05e2915a 100644 --- a/test/integration/agent_ui_test.go +++ b/test/integration/agent_ui_test.go @@ -165,7 +165,7 @@ commands: if _, err := supervisor.Run(id, "agent"); err != nil { t.Fatal(err) } - registry := runtime.Backend.(ports.CommandIdentityBackend).WorkloadIdentities() + registry := runtime.Backend.(ports.StoredCommandIdentities).WorkloadIdentities() var token string for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { records, _ := registry.Records(ctx) diff --git a/test/integration/workload_identity_test.go b/test/integration/workload_identity_test.go index c272ca82..da214a63 100644 --- a/test/integration/workload_identity_test.go +++ b/test/integration/workload_identity_test.go @@ -6,12 +6,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net" "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -29,6 +31,7 @@ import ( runtimekubernetes "github.com/highcard-dev/daemon/internal/runtime/kubernetes" "github.com/highcard-dev/daemon/internal/workloadidentity" "github.com/modelcontextprotocol/go-sdk/mcp" + authzv1 "k8s.io/api/authorization/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -157,7 +160,6 @@ commands: if _, err := supervisor.Run(id, "agent"); err != nil { t.Fatal(err) } - registry := runtime.Backend.(ports.CommandIdentityBackend).WorkloadIdentities() var token, execution string eventually := func(label string, check func() bool) { t.Helper() @@ -170,36 +172,61 @@ commands: } t.Fatalf("timed out: %s", label) } - eventually("credential provisioning", func() bool { - records, err := registry.Records(ctx) + + readCredential := func() bool { + queue, err := supervisor.Queue(id) if err != nil { return false } execution = "" - for _, record := range records { - if record.DeploymentID == id && record.Command == "agent" { - execution = record.ExecutionID - break - } + for _, status := range queue["agent"] { + execution = status.ExecutionID + break } if execution == "" { return false } if kubernetes != nil { - secret, err := kubernetes.CoreV1().Secrets(id).Get(ctx, "druid-workload-"+execution, metav1.GetOptions{}) + pods, err := kubernetes.CoreV1().Pods(id).List(ctx, metav1.ListOptions{LabelSelector: workloadidentity.ExecutionLabel + "=" + execution}) if err != nil { return false } - token = string(secret.Data["token"]) - } else { - data, err := os.ReadFile(filepath.Join(state, "identities", execution)) - if err != nil { - return false + for _, pod := range pods.Items { + if pod.Status.Phase != corev1.PodRunning { + continue + } + data, err := exec.CommandContext(ctx, "kubectl", "--context", "k3d-druid-gs", "exec", "-n", id, pod.Name, "--", "cat", workloadidentity.TokenFile).Output() + if err == nil { + token = strings.TrimSpace(string(data)) + return token != "" + } } - token = string(data) + return false } + data, err := os.ReadFile(filepath.Join(state, "identities", execution)) + if err != nil { + return false + } + token = string(data) return token != "" - }) + } + eventually("credential provisioning", readCredential) + if kubernetes != nil { + if _, err := runtime.Backend.(ports.RuntimeWorkloadAuthenticator).AuthenticateWorkload(ctx, token); err == nil { + t.Fatal("agent token authenticated as infrastructure") + } + if _, err := supervisor.AuthenticateCommand(ctx, "druid_wl_"+strings.Repeat("A", 43), id); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("Kubernetes accepted an opaque-token fallback") + } + access, err := kubernetes.AuthorizationV1().SubjectAccessReviews().Create(ctx, &authzv1.SubjectAccessReview{Spec: authzv1.SubjectAccessReviewSpec{ + User: "system:serviceaccount:" + id + ":druid-workload-agent", Groups: []string{"system:serviceaccounts", "system:serviceaccounts:" + id, "system:authenticated"}, + ResourceAttributes: &authzv1.ResourceAttributes{Namespace: id, Verb: "get", Resource: "pods"}, + }}, metav1.CreateOptions{}) + if err != nil || access.Status.Allowed { + t.Fatalf("agent Kubernetes access: %v, err=%v", access, err) + } + } + call := func(name string, input appservices.ToolInput) (any, error) { return supervisor.CallCommandTool(ctx, token, id, name, input) } @@ -277,7 +304,7 @@ commands: t.Fatal("completed container logs unavailable") } eventually("healthy identity reconciliation", func() bool { return supervisor.ReconcileCommandIdentities(ctx) == nil }) - if _, err := registry.Lookup(ctx, token); err != nil { + if _, err := supervisor.AuthenticateCommand(ctx, token, id); err != nil { t.Fatal("surviving execution lost its credential") } if launcher := os.Getenv("DRUID_WORKLOAD_TEST_LAUNCHER"); launcher != "" { @@ -352,7 +379,7 @@ commands: if err := supervisor.RemoveCommand(id, "agent"); err != nil { t.Fatal(err) } - eventually("revocation", func() bool { _, err := registry.Lookup(ctx, token); return err != nil }) + eventually("revocation", func() bool { _, err := supervisor.AuthenticateCommand(ctx, token, id); return err != nil }) if _, err := call("list_commands", appservices.ToolInput{}); err == nil { t.Fatal("removed execution remained authorized") } @@ -363,34 +390,15 @@ commands: if _, err := supervisor.Run(id, "agent"); err != nil { t.Fatal(err) } + eventually("fresh execution rotates credential", func() bool { - records, err := registry.Records(ctx) - if err != nil { + if !readCredential() { return false } - for _, record := range records { - if record.DeploymentID != id || record.Command != "agent" || record.ExecutionID == oldExecution { - continue - } - execution = record.ExecutionID - if kubernetes != nil { - secret, err := kubernetes.CoreV1().Secrets(id).Get(ctx, "druid-workload-"+execution, metav1.GetOptions{}) - if err != nil { - return false - } - token = string(secret.Data["token"]) - } else { - data, err := os.ReadFile(filepath.Join(state, "identities", execution)) - if err != nil { - return false - } - token = string(data) - } - return token != oldToken && strings.HasPrefix(token, workloadidentity.TokenPrefix) - } - return false + return execution != oldExecution && token != oldToken && (kubernetes != nil || strings.HasPrefix(token, workloadidentity.TokenPrefix)) }) - if _, err := registry.Lookup(ctx, oldToken); err == nil { + + if _, err := supervisor.AuthenticateCommand(ctx, oldToken, id); err == nil { t.Fatal("old credential resurrected after replacement") } t.Log("verified real mount/HTTP, all nine tools, command creation/execution, stdin/logs, completed logs, disconnection, daemon reattachment, and removal revocation") From 1e566694880b796fe77c76196ddabc48fdc47b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 02:04:35 +0100 Subject: [PATCH 03/14] refactor(auth): use Pod-scoped tool grants Snapshot Kubernetes grants on runtime-created Pods and authenticate without loading daemon sessions or execution records. Native Pod lifecycle controls Kubernetes revocation; Docker retains its file-based credential registry and captured grants. --- .../http/handlers/command_tools_test.go | 4 +- .../druid/core/services/command_tools_test.go | 1 - apps/druid/core/services/runtime_identity.go | 32 -------- .../core/services/runtime_identity_test.go | 74 ++++++------------- .../services/runtime_session_execution.go | 1 + internal/core/domain/command_identity.go | 1 + internal/runtime/docker/command_identity.go | 4 +- .../kubernetes/command_authentication_test.go | 8 +- .../runtime/kubernetes/command_identity.go | 7 +- .../kubernetes/command_identity_test.go | 8 +- internal/workloadidentity/identity.go | 4 +- 11 files changed, 46 insertions(+), 98 deletions(-) diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go index dc4c36b5..b9e39398 100644 --- a/apps/druid/adapters/http/handlers/command_tools_test.go +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -152,8 +152,6 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { t.Fatal(err) } runtime := &domain.RuntimeScroll{ID: "tools", Root: t.TempDir(), ScrollName: "tools", ScrollYAML: "name: tools\ndesc: Tools fixture\nversion: 0.1.0\napp_version: 1.0\ncommands:\n agent:\n run: persistent\n identity:\n permissions: [commands:list, commands:create, queue:read, ui:publish]\n procedures:\n - image: busybox\n env: {SECRET: must-not-leak}\n"} - runtime.ScrollYAML += " limited-agent:\n run: persistent\n identity:\n permissions: [commands:list]\n procedures:\n - image: busybox\n" - runtime.Procedures = domain.ProcedureStatusMap{"agent": {"agent.0": {ExecutionID: "execution", Status: domain.ScrollLockStatusRunning}}, "limited-agent": {"limited-agent.0": {ExecutionID: "limited", Status: domain.ScrollLockStatusRunning}}} if err := store.CreateScroll(runtime); err != nil { t.Fatal(err) } @@ -323,7 +321,7 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { t.Fatalf("signal procedure schema regressed: %v, %v", err, signal) } // A credential with only commands:list must neither discover nor call creation. - limited, err := registry.Ensure(ctx, workloadidentity.Record{ExecutionID: "limited", DeploymentID: "tools", Command: "limited-agent", Permissions: []string{"commands:list"}}) + limited, err := registry.Ensure(ctx, workloadidentity.Record{ExecutionID: "limited", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list"}}) if err != nil { t.Fatal(err) } diff --git a/apps/druid/core/services/command_tools_test.go b/apps/druid/core/services/command_tools_test.go index 723e27b4..de052406 100644 --- a/apps/druid/core/services/command_tools_test.go +++ b/apps/druid/core/services/command_tools_test.go @@ -236,7 +236,6 @@ commands: - image: busybox command: [true] `, Procedures: domain.ProcedureStatusMap{"agent": {"agent-console": {ExecutionID: "agent-execution", Status: domain.ScrollLockStatusRunning}}}} - runtime.ScrollYAML = strings.ReplaceAll(runtime.ScrollYAML, "permissions: [commands:list]", "permissions: ["+strings.Join(permissions, ", ")+"]") if err := store.CreateScroll(runtime); err != nil { t.Fatal(err) } diff --git a/apps/druid/core/services/runtime_identity.go b/apps/druid/core/services/runtime_identity.go index 68c1d208..ce2cdfbc 100644 --- a/apps/druid/core/services/runtime_identity.go +++ b/apps/druid/core/services/runtime_identity.go @@ -99,38 +99,6 @@ func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token strin if record.DeploymentID != deployment { return workloadidentity.Record{}, workloadidentity.ErrUnauthorized } - session, err := s.sessionFor(deployment) - if err != nil { - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized - } - session.mu.Lock() - defer session.mu.Unlock() - runtime := session.runtimeScroll - definition := session.scrollService.GetFile().Commands[record.Command] - if definition == nil || definition.Identity == nil || (record.RuntimeRoot != "" && record.RuntimeRoot != runtime.Root) || runtime.Status == domain.RuntimeScrollStatusDeleted || runtime.Status == domain.RuntimeScrollStatusStopped { - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized - } - statuses := runtime.Procedures[record.Command] - status, exists := deriveCommandStatus(statuses, record.Command, definition) - if !exists || (status != domain.ScrollLockStatusRunning && status != domain.ScrollLockStatusWaiting) { - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized - } - for _, current := range statuses { - if current.ExecutionID != record.ExecutionID || record.ExecutionID == "" { - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized - } - } - permissions := append([]string{}, definition.Identity.Permissions...) - if record.Permissions != nil { - // File-backed identities retain the grants captured at provisioning. - permissions = nil - for _, permission := range definition.Identity.Permissions { - if record.Allows(permission) { - permissions = append(permissions, permission) - } - } - } - record.Permissions = permissions return record, nil } diff --git a/apps/druid/core/services/runtime_identity_test.go b/apps/druid/core/services/runtime_identity_test.go index 86d01393..245591a0 100644 --- a/apps/druid/core/services/runtime_identity_test.go +++ b/apps/druid/core/services/runtime_identity_test.go @@ -5,66 +5,38 @@ import ( "errors" "testing" - "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" "github.com/highcard-dev/daemon/internal/workloadidentity" ) -// A Pod-authenticated identity supplies binding, not permission grants. type podIdentityBackend struct { - *toolTestBackend + ports.RuntimeBackendInterface record workloadidentity.Record } -func (b *podIdentityBackend) AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Record, error) { +func (b podIdentityBackend) AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Record, error) { return b.record, nil } -func TestPodIdentityUsesCurrentDefinitionAndExecution(t *testing.T) { - for _, test := range []struct { - name string - mutate func(*RuntimeSession, *workloadidentity.Record) - }{ - {name: "active"}, - {name: "replaced", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { - s.runtimeScroll.Procedures["agent"]["agent-console"] = domain.LockStatus{ExecutionID: "replacement", Status: domain.ScrollLockStatusRunning} - }}, - {name: "completed", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { - s.runtimeScroll.Procedures["agent"]["agent-console"] = domain.LockStatus{ExecutionID: "agent-execution", Status: domain.ScrollLockStatusDone} - }}, - {name: "failed", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { - s.runtimeScroll.Procedures["agent"]["agent-console"] = domain.LockStatus{ExecutionID: "agent-execution", Status: domain.ScrollLockStatusError} - }}, - {name: "removed", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { - delete(s.scrollService.GetFile().Commands, "agent") - }}, - {name: "opted out", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { - s.scrollService.GetFile().Commands["agent"].Identity = nil - }}, - {name: "stopped", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { - s.runtimeScroll.Status = domain.RuntimeScrollStatusStopped - }}, - {name: "deleted", mutate: func(s *RuntimeSession, _ *workloadidentity.Record) { - s.runtimeScroll.Status = domain.RuntimeScrollStatusDeleted - }}, - {name: "other namespace or volume", mutate: func(_ *RuntimeSession, r *workloadidentity.Record) { r.RuntimeRoot = "k8s://other/data" }}, - {name: "other deployment", mutate: func(_ *RuntimeSession, r *workloadidentity.Record) { r.DeploymentID = "other" }}, - } { - t.Run(test.name, func(t *testing.T) { - supervisor, backend, _ := commandToolsFixture(t, "commands:list", "queue:read") - session := supervisor.sessions["tools"] - record := workloadidentity.Record{RuntimeRoot: session.runtimeScroll.Root, DeploymentID: "tools", Command: "agent", ExecutionID: "agent-execution"} - if test.mutate != nil { - test.mutate(session, &record) - } - supervisor.runtimeBackend = &podIdentityBackend{toolTestBackend: backend, record: record} - authenticated, err := supervisor.AuthenticateCommand(context.Background(), "pod-bound-token", "tools") - if test.name == "active" { - if err != nil || !authenticated.Allows("commands:list") || !authenticated.Allows("queue:read") || authenticated.Allows("commands:run") { - t.Fatalf("identity=%+v err=%v", authenticated, err) - } - } else if !errors.Is(err, workloadidentity.ErrUnauthorized) { - t.Fatalf("accepted %s: %v", test.name, err) - } - }) +func (b podIdentityBackend) PrepareCommandIdentity(context.Context, workloadidentity.Record) (workloadidentity.CredentialMount, error) { + return workloadidentity.CredentialMount{}, nil +} + +func TestCommandAuthenticationNeedsNoDaemonState(t *testing.T) { + backend := podIdentityBackend{record: workloadidentity.Record{DeploymentID: "deployment", Permissions: []string{"commands:list"}}} + supervisor, err := NewRuntimeSupervisor(nil, nil, ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) + if err != nil { + t.Fatal(err) + } + record, err := supervisor.AuthenticateCommand(context.Background(), "verified-pod-token", "deployment") + if err != nil || !record.Allows("commands:list") || record.Allows("commands:run") { + t.Fatalf("record=%+v err=%v", record, err) + } + if _, err := supervisor.AuthenticateCommand(context.Background(), "verified-pod-token", "other"); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("cross-deployment credential accepted") + } + // Permission denial must also happen without touching runtime storage. + if _, err := supervisor.CallCommandTool(context.Background(), "verified-pod-token", "deployment", "run_command", ToolInput{Command: "example"}); err == nil { + t.Fatal("ungranted permission accepted") } } diff --git a/apps/druid/core/services/runtime_session_execution.go b/apps/druid/core/services/runtime_session_execution.go index 7b9d5f87..0f6681ce 100644 --- a/apps/druid/core/services/runtime_session_execution.go +++ b/apps/druid/core/services/runtime_session_execution.go @@ -105,6 +105,7 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin return err } identity.Enabled, identity.TokenPath = true, mount.Path + identity.Permissions = append([]string(nil), command.Identity.Permissions...) } // Never mutate the persisted Scroll or leak transient mount references. copyCommand := *command diff --git a/internal/core/domain/command_identity.go b/internal/core/domain/command_identity.go index c0d2ec2c..66e9a777 100644 --- a/internal/core/domain/command_identity.go +++ b/internal/core/domain/command_identity.go @@ -10,6 +10,7 @@ type RuntimeCommandIdentity struct { ExecutionID string DeploymentID string Enabled bool + Permissions []string TokenPath string } diff --git a/internal/runtime/docker/command_identity.go b/internal/runtime/docker/command_identity.go index 049cd9fa..a24ccab2 100644 --- a/internal/runtime/docker/command_identity.go +++ b/internal/runtime/docker/command_identity.go @@ -18,9 +18,7 @@ func (b *Backend) AuthenticateCommandIdentity(ctx context.Context, token string) if b.identities == nil { return workloadidentity.Record{}, workloadidentity.ErrUnauthorized } - record, err := b.identities.Lookup(ctx, token) - record.Permissions = append([]string{}, record.Permissions...) - return record, err + return b.identities.Lookup(ctx, token) } func (b *Backend) ActiveExecutions(ctx context.Context) (map[string]bool, error) { diff --git a/internal/runtime/kubernetes/command_authentication_test.go b/internal/runtime/kubernetes/command_authentication_test.go index 5726064e..16467df7 100644 --- a/internal/runtime/kubernetes/command_authentication_test.go +++ b/internal/runtime/kubernetes/command_authentication_test.go @@ -19,7 +19,7 @@ func agentPod() *corev1.Pod { return &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "agent-0", Namespace: "games", UID: types.UID("pod-uid"), Labels: map[string]string{labelManagedBy: "druid", labelComponent: "runtime", labelScrollID: "data", labelRuntimeID: "data", labelCommand: "agent-command", workloadidentity.ExecutionLabel: "execution"}, - Annotations: map[string]string{commandDeploymentAnnotation: "full-deployment-id", labelCommand: "agent_command"}}, + Annotations: map[string]string{commandDeploymentAnnotation: "full-deployment-id", labelCommand: "agent_command", commandPermissionsAnnotation: "commands:list logs:read"}}, Spec: corev1.PodSpec{ServiceAccountName: commandServiceAccount, Volumes: []corev1.Volume{pvcVolume("data", "data")}}, Status: corev1.PodStatus{Phase: corev1.PodRunning}, } @@ -32,6 +32,7 @@ func TestCommandServiceAccountAuthentication(t *testing.T) { deleted, unavailable bool }{ {name: "valid"}, + {name: "no grants", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { delete(p.Annotations, commandPermissionsAnnotation) }}, {name: "wrong audience", mutate: func(_ *corev1.Pod, r *authv1.TokenReviewStatus) { r.Audiences = []string{"druid-cli"} }}, {name: "unauthenticated", mutate: func(_ *corev1.Pod, r *authv1.TokenReviewStatus) { r.Authenticated = false }}, {name: "unbound token", mutate: func(_ *corev1.Pod, r *authv1.TokenReviewStatus) { r.User.Extra = nil }}, @@ -81,8 +82,9 @@ func TestCommandServiceAccountAuthentication(t *testing.T) { }) backend := NewWithClient(Config{Namespace: "default"}, client) record, err := backend.AuthenticateCommandIdentity(context.Background(), "credential") - if test.name == "valid" { - if err != nil || record.DeploymentID != "full-deployment-id" || record.Command != "agent_command" || record.RuntimeRoot != ref("games", "data") || record.ExecutionID != "execution" || len(record.Permissions) != 0 { + if test.name == "valid" || test.name == "no grants" { + granted := test.name == "valid" + if err != nil || record.DeploymentID != "full-deployment-id" || record.Command != "agent_command" || record.RuntimeRoot != ref("games", "data") || record.ExecutionID != "execution" || record.Allows("commands:list") != granted || record.Allows("logs:read") != granted || record.Allows("commands:run") { t.Fatalf("record=%+v err=%v", record, err) } // No cache or registry is required after daemon reconstruction. diff --git a/internal/runtime/kubernetes/command_identity.go b/internal/runtime/kubernetes/command_identity.go index 8fbc93bc..85a370bf 100644 --- a/internal/runtime/kubernetes/command_identity.go +++ b/internal/runtime/kubernetes/command_identity.go @@ -2,6 +2,8 @@ package kubernetes import ( "context" + "strings" + "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/workloadidentity" corev1 "k8s.io/api/core/v1" @@ -10,12 +12,13 @@ import ( const commandServiceAccount = "druid-workload-agent" const commandTokenAudience = "druid-command-tools" const commandDeploymentAnnotation = "druid.gg/deployment-id" +const commandPermissionsAnnotation = "druid.gg/permissions" func commandIdentityAnnotations(identity *domain.RuntimeCommandIdentity, command string) map[string]string { if identity == nil || !identity.Enabled { return nil } - return map[string]string{commandDeploymentAnnotation: identity.DeploymentID, labelCommand: command} + return map[string]string{commandDeploymentAnnotation: identity.DeploymentID, labelCommand: command, commandPermissionsAnnotation: strings.Join(identity.Permissions, " ")} } func (b *Backend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { @@ -42,7 +45,7 @@ func (b *Backend) AuthenticateCommandIdentity(ctx context.Context, token string) if pod.Labels[labelRuntimeID] != dnsLabel(runtimeID(root)) { return workloadidentity.Record{}, workloadidentity.ErrUnauthorized } - return workloadidentity.Record{RuntimeRoot: root, DeploymentID: deployment, Command: command, ExecutionID: execution}, nil + return workloadidentity.Record{RuntimeRoot: root, DeploymentID: deployment, Command: command, ExecutionID: execution, Permissions: strings.Fields(pod.Annotations[commandPermissionsAnnotation])}, nil } } return workloadidentity.Record{}, workloadidentity.ErrUnauthorized diff --git a/internal/runtime/kubernetes/command_identity_test.go b/internal/runtime/kubernetes/command_identity_test.go index 2d6fb643..ac32c85e 100644 --- a/internal/runtime/kubernetes/command_identity_test.go +++ b/internal/runtime/kubernetes/command_identity_test.go @@ -7,7 +7,7 @@ import ( ) func TestCommandIdentityMountedInJobsAndPersistentProcedures(t *testing.T) { - identity := &domain.RuntimeCommandIdentity{ExecutionID: "execution", Enabled: true} + identity := &domain.RuntimeCommandIdentity{ExecutionID: "execution", DeploymentID: "deployment", Enabled: true, Permissions: []string{"commands:list", "logs:read"}} procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: identity} job, err := procedureJobSpec("runtime", ref("runtime", "data"), "automation", "automation.0", "job", 1, procedure, nil, "", "") if err != nil { @@ -17,6 +17,9 @@ func TestCommandIdentityMountedInJobsAndPersistentProcedures(t *testing.T) { t.Fatal("execution label missing") } pod := job.Spec.Template.Spec + if job.Spec.Template.Annotations[commandDeploymentAnnotation] != "deployment" || job.Spec.Template.Annotations[commandPermissionsAnnotation] != "commands:list logs:read" { + t.Fatal("Pod authorization grants missing") + } if len(pod.Volumes) != 2 || pod.Volumes[1].Projected == nil || pod.ServiceAccountName != commandServiceAccount || pod.AutomountServiceAccountToken == nil || *pod.AutomountServiceAccountToken { t.Fatal("bound ServiceAccount token not mounted") } @@ -37,6 +40,9 @@ func TestCommandIdentityMountedInJobsAndPersistentProcedures(t *testing.T) { if _, ok := set.Spec.Selector.MatchLabels[workloadidentity.ExecutionLabel]; ok { t.Fatal("execution identity changed immutable StatefulSet selector") } + if set.Spec.Template.Annotations[commandPermissionsAnnotation] != "commands:list logs:read" { + t.Fatal("persistent Pod authorization grants missing") + } } func TestCommandsWithoutIdentityReceiveNoCredential(t *testing.T) { diff --git a/internal/workloadidentity/identity.go b/internal/workloadidentity/identity.go index cb40efb3..f77c5b33 100644 --- a/internal/workloadidentity/identity.go +++ b/internal/workloadidentity/identity.go @@ -22,12 +22,12 @@ var tokenPattern = regexp.MustCompile(`druid_wl_[A-Za-z0-9_-]{43}|eyJ[A-Za-z0-9_ var executionFileName = regexp.MustCompile(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`) type Record struct { - RuntimeRoot string `json:"-"` // Provisioning root or verified Pod volume binding. + RuntimeRoot string `json:"-"` // Backend runtime binding; not a daemon-session dependency. Recover bool `json:"-"` ExecutionID string `json:"execution_id"` DeploymentID string `json:"deployment_id"` Command string `json:"command"` - Permissions []string `json:"permissions"` // nil derives grants from the command; non-nil caps them. + Permissions []string `json:"permissions"` } func (r Record) Allows(permission string) bool { From eb86043340899aa39393da333b24d26c08786b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 02:15:11 +0100 Subject: [PATCH 04/14] refactor(auth)!: drop Docker tool credentials Docker tools deliberately allow anonymous access to every deployment and ignore permission grants. Restrict this runtime to trusted local use. Remove the file registry, token mounts and credential lifecycle machinery. Kubernetes Pod-bound authentication and unrelated route auth stay unchanged. --- README.md | 10 + apps/druid/adapters/cli/daemon.go | 20 -- .../adapters/http/handlers/command_tools.go | 2 +- .../http/handlers/command_tools_test.go | 49 ++--- .../handlers/docker_command_tools_test.go | 87 ++++++++ .../druid/core/services/command_tools_test.go | 135 ++---------- apps/druid/core/services/runtime_commands.go | 3 - apps/druid/core/services/runtime_identity.go | 74 +------ .../core/services/runtime_identity_test.go | 32 ++- apps/druid/core/services/runtime_lifecycle.go | 17 -- apps/druid/core/services/runtime_session.go | 1 - .../services/runtime_session_execution.go | 26 +-- .../core/services/runtime_session_runtime.go | 3 - .../druid/core/services/runtime_supervisor.go | 7 - internal/core/domain/command_identity.go | 1 - internal/core/ports/command_runtime.go | 10 +- internal/runtime/backend.go | 2 - internal/runtime/docker/backend.go | 13 -- internal/runtime/docker/command_identity.go | 36 ---- .../runtime/docker/command_identity_test.go | 18 +- internal/runtime/docker/spec.go | 3 - .../kubernetes/command_authentication_test.go | 2 +- .../runtime/kubernetes/command_identity.go | 4 +- .../utils/logger/credential_encoder_test.go | 6 +- internal/workloadidentity/file_registry.go | 194 ------------------ internal/workloadidentity/identity.go | 34 --- internal/workloadidentity/identity_test.go | 61 +----- test/integration/agent_ui_test.go | 20 +- test/integration/workload_identity_test.go | 50 +++-- 29 files changed, 225 insertions(+), 695 deletions(-) create mode 100644 apps/druid/adapters/http/handlers/docker_command_tools_test.go delete mode 100644 internal/runtime/docker/command_identity.go delete mode 100644 internal/workloadidentity/file_registry.go diff --git a/README.md b/README.md index eea87aad..d893716c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,16 @@ There is also websocket support for stdout. TTY is also supported. Runtime selection is daemon-only: start the daemon with `druid daemon --runtime docker`, then use `druid` to create, run, and inspect scrolls without passing a runtime. Docker runtime state stays in SQLite under the runtime state directory. Scroll specs and runtime data live together in one runtime root. +**Docker agent-tool caveat:** HTTP/MCP tool endpoints on the Docker runtime are +unauthenticated and ignore command permissions. Anyone who can reach them can +invoke every tool for any deployment, including command creation/execution, +stdin, log access and UI publication. Docker creates no workload credentials or +identity registry. Use it only for trusted, single-user local development; keep +the daemon proxy bound to loopback and do not expose it to untrusted networks or +multi-tenant workloads. Container workloads may also reach these endpoints. +Existing authentication on unrelated dashboard, management and worker routes is +unchanged. Kubernetes retains Pod-bound authentication and per-tool permissions. + Kubernetes runtime support is available with `druid daemon --runtime kubernetes` for in-cluster daemons or out-of-cluster daemons using kubeconfig. It stores daemon scroll state in ConfigMaps, materializes OCI artifacts through `druid worker pull` Jobs, and uses kubelet pod stats for procedure-level traffic checks. See `docs/kubernetes_runtime.md` for kubeconfig, RBAC, and PVC setup. ## Documentation diff --git a/apps/druid/adapters/cli/daemon.go b/apps/druid/adapters/cli/daemon.go index c92405ea..f74d5561 100644 --- a/apps/druid/adapters/cli/daemon.go +++ b/apps/druid/adapters/cli/daemon.go @@ -1,7 +1,6 @@ package cli import ( - "context" "fmt" "net" "os" @@ -155,25 +154,6 @@ func runRuntimeDaemon() error { if err := supervisor.SetWorkloadAPIURL(runtimeWorkloadAPIURL); err != nil { return err } - identityContext, cancelIdentityReconciliation := context.WithCancel(context.Background()) - defer cancelIdentityReconciliation() - if err := supervisor.ReconcileCommandIdentities(identityContext); err != nil { - return err - } - go func() { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - for { - select { - case <-identityContext.Done(): - return - case <-ticker.C: - if err := supervisor.ReconcileCommandIdentities(identityContext); err != nil { - logger.Log().Warn("Command identity reconciliation unavailable") - } - } - } - }() supervisor.SetWorkerTimeout(runtimeWorkerTimeout) callbackConfig := ports.RuntimeWorkerCallbackConfig{ Listen: runtimeWorkerCallbackListen, diff --git a/apps/druid/adapters/http/handlers/command_tools.go b/apps/druid/adapters/http/handlers/command_tools.go index 9b274ad2..53d2e0af 100644 --- a/apps/druid/adapters/http/handlers/command_tools.go +++ b/apps/druid/adapters/http/handlers/command_tools.go @@ -51,7 +51,7 @@ func (h *ScrollHandler) commandToolHTTP(name string) fiber.Handler { return func(c *fiber.Ctx) error { token := bearer(c.Get("Authorization")) // Preserve the existing owner-authenticated endpoints. - if name == "get_queue" || name == "publish_ui" { + if h.supervisor.CommandToolsRequireIdentity() && (name == "get_queue" || name == "publish_ui") { if c.Get("Authorization") == "" { return c.Next() } diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go index b9e39398..7b494cc9 100644 --- a/apps/druid/adapters/http/handlers/command_tools_test.go +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -6,8 +6,8 @@ import ( "io" "net/http" "net/http/httptest" - "os" "strings" + "sync" "testing" "github.com/gofiber/fiber/v2" @@ -103,15 +103,17 @@ func TestCreateCommandSchemaPreservesNestedTypesForOpenHands(t *testing.T) { type toolRouteBackend struct { ports.RuntimeBackendInterface - registry workloadidentity.Registry + credentials sync.Map } -func (b *toolRouteBackend) WorkloadIdentities() workloadidentity.Registry { return b.registry } -func (b *toolRouteBackend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { - return b.registry.Ensure(ctx, record) +func (b *toolRouteBackend) PrepareCommandIdentity(context.Context, workloadidentity.Record) error { + return nil } -func (b *toolRouteBackend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Record, error) { - return b.registry.Lookup(ctx, token) +func (b *toolRouteBackend) AuthenticateCommandIdentity(_ context.Context, token string) (workloadidentity.Record, error) { + if record, ok := b.credentials.Load(token); ok { + return record.(workloadidentity.Record), nil + } + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized } func (b *toolRouteBackend) Name() string { return "fixture" } func (b *toolRouteBackend) RunCommand(ports.RuntimeCommand) (*int, error) { return nil, nil } @@ -119,9 +121,6 @@ func (b *toolRouteBackend) CreateUIPackageUpload(_ context.Context, action ports return ports.RuntimeUIPackageUpload{UploadURL: "http://upload-private/wasm", DocumentUploadURL: "http://upload-private/document", URL: "http://packages/" + string(action.Scope) + "/app.wasm"}, nil } func (b *toolRouteBackend) StopCommand(string, string) error { return nil } -func (b *toolRouteBackend) ActiveExecutions(context.Context) (map[string]bool, error) { - return map[string]bool{"execution": true}, nil -} type authenticatedTransport struct{ token string } @@ -135,18 +134,7 @@ func (t authenticatedTransport) RoundTrip(request *http.Request) (*http.Response func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - registry, err := workloadidentity.NewFileRegistry(t.TempDir()) - if err != nil { - t.Fatal(err) - } - mount, err := registry.Ensure(ctx, workloadidentity.Record{ExecutionID: "execution", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list", "commands:create", "queue:read", "ui:publish"}}) - if err != nil { - t.Fatal(err) - } - token, err := os.ReadFile(mount.Path) - if err != nil { - t.Fatal(err) - } + token := "fixture-token" store, err := docker.NewStateStore(t.TempDir()) if err != nil { t.Fatal(err) @@ -155,7 +143,8 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { if err := store.CreateScroll(runtime); err != nil { t.Fatal(err) } - backend := &toolRouteBackend{registry: registry} + backend := &toolRouteBackend{} + backend.credentials.Store(token, workloadidentity.Record{ExecutionID: "execution", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list", "commands:create", "queue:read", "ui:publish"}}) supervisor, err := appservices.NewRuntimeSupervisor(store, coreservices.NewRuntimeScrollManager(store), ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) if err != nil { t.Fatal(err) @@ -321,14 +310,8 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { t.Fatalf("signal procedure schema regressed: %v, %v", err, signal) } // A credential with only commands:list must neither discover nor call creation. - limited, err := registry.Ensure(ctx, workloadidentity.Record{ExecutionID: "limited", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list"}}) - if err != nil { - t.Fatal(err) - } - limitedToken, err := os.ReadFile(limited.Path) - if err != nil { - t.Fatal(err) - } + limitedToken := "limited-token" + backend.credentials.Store(limitedToken, workloadidentity.Record{ExecutionID: "limited", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list"}}) limitedSession, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: server.URL + "/tools/mcp", HTTPClient: &http.Client{Transport: authenticatedTransport{string(limitedToken)}}}, nil) if err != nil { t.Fatal(err) @@ -369,9 +352,7 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { t.Fatalf("unauthorized creation returned %d", response.StatusCode) } } - if err := registry.Revoke(ctx, "execution"); err != nil { - t.Fatal(err) - } + backend.credentials.Delete(token) if _, err := session.ListTools(ctx, nil); err == nil { t.Fatal("MCP discovery accepted a revoked credential") } diff --git a/apps/druid/adapters/http/handlers/docker_command_tools_test.go b/apps/druid/adapters/http/handlers/docker_command_tools_test.go new file mode 100644 index 00000000..88f1af6e --- /dev/null +++ b/apps/druid/adapters/http/handlers/docker_command_tools_test.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" + appservices "github.com/highcard-dev/daemon/apps/druid/core/services" + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" + coreservices "github.com/highcard-dev/daemon/internal/core/services" + "github.com/highcard-dev/daemon/internal/runtime/docker" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type dockerToolRouteBackend struct{ *toolRouteBackend } + +func (*dockerToolRouteBackend) Name() string { return "docker" } + +func TestDockerToolsAreAnonymousWhileOwnerRoutesRemainProtected(t *testing.T) { + store, err := docker.NewStateStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"one", "other"} { + if err := store.CreateScroll(&domain.RuntimeScroll{ID: id, Root: t.TempDir(), ScrollName: id, ScrollYAML: "name: fixture\ndesc: fixture\nversion: 0.1.0\napp_version: 1.0\ncommands:\n echo:\n procedures:\n - image: busybox\n"}); err != nil { + t.Fatal(err) + } + } + backend := &dockerToolRouteBackend{&toolRouteBackend{}} + supervisor, err := appservices.NewRuntimeSupervisor(store, coreservices.NewRuntimeScrollManager(store), ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) + if err != nil { + t.Fatal(err) + } + app := fiber.New(fiber.Config{DisableStartupMessage: true}) + RegisterPublicRoutes(app, RouteHandlers{Server: NewRuntimeServer(NewHealthHandler(), NewScrollHandler(supervisor)), Websocket: NewWebsocketHandler()}) + for _, id := range []string{"one", "other"} { + for _, path := range []string{"commands", "queue"} { + response, err := app.Test(httptest.NewRequest("GET", "/"+id+"/api/v1/"+path, nil)) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 200 { + t.Fatalf("anonymous Docker %s: %d", path, response.StatusCode) + } + } + } + // The shared publish route must reach validation, not owner authentication. + response, err := app.Test(httptest.NewRequest("POST", "/one/api/v1/ui/packages/invalid/publish", strings.NewReader("{}"))) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 400 { + t.Fatalf("Docker publish unexpectedly requires credentials: %d", response.StatusCode) + } + response, err = app.Test(httptest.NewRequest("GET", "/one/api/v1/scroll", nil)) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 401 { + t.Fatal("Docker tool exemption reached an unrelated owner route") + } + server := httptest.NewServer(adaptor.FiberApp(app)) + defer server.Close() + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{Name: "docker-anonymous-test", Version: "1"}, nil) + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: server.URL + "/other/mcp", HTTPClient: http.DefaultClient}, nil) + if err != nil { + t.Fatal(err) + } + defer session.Close() + tools, err := session.ListTools(ctx, nil) + if err != nil || len(tools.Tools) != len(commandToolDefinitions()) { + t.Fatalf("anonymous Docker discovery: %v, %v", tools, err) + } + result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "create_command", Arguments: map[string]any{"command": "anonymous-created", "instructions": map[string]any{"procedures": []any{map[string]any{"image": "busybox", "command": []string{"true"}}}}}}) + if err != nil || result.IsError { + t.Fatalf("anonymous Docker creation: %v, %v", result, err) + } +} diff --git a/apps/druid/core/services/command_tools_test.go b/apps/druid/core/services/command_tools_test.go index de052406..fdb388b4 100644 --- a/apps/druid/core/services/command_tools_test.go +++ b/apps/druid/core/services/command_tools_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "os" "strings" "sync" "testing" @@ -71,7 +70,7 @@ func TestCreateCommandRejectionsAndConcurrentDuplicates(t *testing.T) { t.Fatalf("%s: expected %d, got %v", test.input.Command, test.status, err) } } - if _, err := backend.registry.Lookup(ctx, token); err != nil { + if _, err := backend.AuthenticateCommandIdentity(ctx, token); err != nil { t.Fatal("duplicate creation disturbed existing execution identity") } if _, err := s.CallCommandTool(ctx, token, "other", "create_command", ToolInput{Command: "new", Instructions: definition}); !errors.Is(err, workloadidentity.ErrUnauthorized) { @@ -168,9 +167,7 @@ func TestPublishUIToolPermissionsProjectionAndPaths(t *testing.T) { if current[domain.RuntimeUIPackageScopePrivate] != previous[domain.RuntimeUIPackageScopePrivate] { t.Fatal("failed publish replaced previous package") } - if err := backend.registry.Revoke(ctx, "agent-execution"); err != nil { - t.Fatal(err) - } + backend.credentials.Delete(token) if _, err := s.CallCommandTool(ctx, token, "tools", "publish_ui", ToolInput{Scope: "private"}); !errors.Is(err, workloadidentity.ErrUnauthorized) { t.Fatal("revoked publish accepted") } @@ -182,20 +179,19 @@ func TestPublishUIToolPermissionsProjectionAndPaths(t *testing.T) { type toolTestBackend struct { *fakeWorkerBackend - registry workloadidentity.Registry - logs string - input string + credentials sync.Map + logs string + input string } -func (b *toolTestBackend) WorkloadIdentities() workloadidentity.Registry { return b.registry } -func (b *toolTestBackend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { - return b.registry.Ensure(ctx, record) -} -func (b *toolTestBackend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Record, error) { - return b.registry.Lookup(ctx, token) +func (b *toolTestBackend) PrepareCommandIdentity(context.Context, workloadidentity.Record) error { + return nil } -func (b *toolTestBackend) ActiveExecutions(context.Context) (map[string]bool, error) { - return map[string]bool{}, nil +func (b *toolTestBackend) AuthenticateCommandIdentity(_ context.Context, token string) (workloadidentity.Record, error) { + if record, ok := b.credentials.Load(token); ok { + return record.(workloadidentity.Record), nil + } + return workloadidentity.Record{}, workloadidentity.ErrUnauthorized } func (b *toolTestBackend) ReadConsoleLogs(_ context.Context, _, _ string, _ int, _ int64) (string, bool, error) { return b.logs, false, nil @@ -211,11 +207,7 @@ func (b *toolTestBackend) Create(observer ports.ProcedureStatusObserver) (ports. func commandToolsFixture(t *testing.T, permissions ...string) (*RuntimeSupervisor, *toolTestBackend, string) { t.Helper() - registry, err := workloadidentity.NewFileRegistry(t.TempDir()) - if err != nil { - t.Fatal(err) - } - backend := &toolTestBackend{fakeWorkerBackend: &fakeWorkerBackend{}, registry: registry, logs: "hello\nERROR fixture\n"} + backend := &toolTestBackend{fakeWorkerBackend: &fakeWorkerBackend{}, logs: "hello\nERROR fixture\n"} store := newTestStateStore(t) runtime := &domain.RuntimeScroll{ID: "tools", Root: t.TempDir(), ScrollName: "tools", ScrollYAML: `name: tools desc: Tools fixture @@ -246,15 +238,9 @@ commands: } supervisor.sessions[runtime.ID] = session t.Cleanup(session.stopDeploymentQueue) - mount, err := registry.Ensure(context.Background(), workloadidentity.Record{ExecutionID: "agent-execution", DeploymentID: "tools", Command: "agent", Permissions: permissions}) - if err != nil { - t.Fatal(err) - } - token, err := os.ReadFile(mount.Path) - if err != nil { - t.Fatal(err) - } - return supervisor, backend, string(token) + token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln" + backend.credentials.Store(token, workloadidentity.Record{ExecutionID: "agent-execution", DeploymentID: "tools", Command: "agent", Permissions: permissions}) + return supervisor, backend, token } func TestCommandToolsPermissionsProjectionAndConsoles(t *testing.T) { @@ -295,9 +281,7 @@ func TestCommandToolsPermissionsProjectionAndConsoles(t *testing.T) { if _, err := supervisor.CallCommandTool(ctx, token, "tools", "send_stdin", ToolInput{Console: "agent-console", Text: strings.Repeat("a", 8193)}); err == nil { t.Fatal("oversized stdin accepted") } - if err := backend.registry.Revoke(ctx, "agent-execution"); err != nil { - t.Fatal(err) - } + backend.credentials.Delete(token) if _, err := supervisor.CallCommandTool(ctx, token, "tools", "list_commands", ToolInput{}); !errors.Is(err, workloadidentity.ErrUnauthorized) { t.Fatal("revoked token accepted") } @@ -336,88 +320,3 @@ func TestLateProcedureEventCannotOverwriteReplacementExecution(t *testing.T) { t.Fatal("stale event overwrote replacement") } } - -func TestReplacingCommandRevokesOldIdentityWithoutUpgradingItsGrants(t *testing.T) { - supervisor, backend, token := commandToolsFixture(t, "commands:list") - definition, err := supervisor.ScrollFile("tools") - if err != nil { - t.Fatal(err) - } - replacement := *definition.Commands["agent"] - replacement.Identity = &domain.CommandIdentity{Permissions: []string{"commands:list", "console:write"}} - if err := supervisor.AddCommand("tools", "agent", &replacement); err != nil { - t.Fatal(err) - } - if _, err := backend.registry.Lookup(context.Background(), token); !errors.Is(err, workloadidentity.ErrUnauthorized) { - t.Fatal("replacement retained old credential") - } - if len(supervisor.sessions["tools"].Snapshot()["agent"]) != 0 { - t.Fatal("replacement kept old execution status") - } -} - -func TestCommandIdentityRevokedOnStopDeletionAndStaleResourceReconciliation(t *testing.T) { - for _, operation := range []string{"stop", "delete", "reconcile"} { - t.Run(operation, func(t *testing.T) { - supervisor, backend, token := commandToolsFixture(t, "commands:list") - var err error - switch operation { - case "stop": - _, err = supervisor.Stop("tools") - case "delete": - err = supervisor.DeleteWithPolicy("tools", false) - case "reconcile": - err = supervisor.ReconcileCommandIdentities(context.Background()) - } - if err != nil { - t.Fatal(err) - } - if _, err := backend.registry.Lookup(context.Background(), token); !errors.Is(err, workloadidentity.ErrUnauthorized) { - t.Fatal("credential survived " + operation) - } - }) - } -} - -func TestCommandIdentityFailedProvisioningRevokesAndRemovesCredential(t *testing.T) { - supervisor, backend, _ := commandToolsFixture(t) - session := supervisor.sessions["tools"] - session.workloadAPIURL = "http://workload.test" - command := &domain.CommandInstructionSet{Identity: &domain.CommandIdentity{Permissions: []string{"logs:read"}}, Procedures: []*domain.Procedure{{Image: "busybox"}}} - if err := supervisor.AddCommand("tools", "fail", command); err != nil { - t.Fatal(err) - } - var tokenPath, token string - backend.runCommand = func(command ports.RuntimeCommand) (*int, error) { - identity := command.Command.Procedures[0].RuntimeIdentity - if identity == nil { - t.Fatal("identity not provisioned") - } - tokenPath = identity.TokenPath - data, err := os.ReadFile(tokenPath) - if err != nil { - t.Error(err) - } - token = string(data) - for _, env := range command.ProcedureEnv { - for key, value := range env { - if value == token { - t.Errorf("raw token injected in environment %s", key) - } - } - } - return nil, errors.New("fixture provisioning failure") - } - if _, err := supervisor.RunAndWait("tools", "fail"); err == nil { - t.Fatal("provisioning failure swallowed") - } - if token == "" { - t.Fatal("provisioning path not exercised") - } - if _, err := backend.registry.Lookup(context.Background(), token); !errors.Is(err, workloadidentity.ErrUnauthorized) { - t.Fatal("failed provisioning left credential active") - } - if _, err := os.Stat(tokenPath); !os.IsNotExist(err) { - t.Fatal("failed provisioning left raw credential file") - } -} diff --git a/apps/druid/core/services/runtime_commands.go b/apps/druid/core/services/runtime_commands.go index 3bf49e27..61ee0f6c 100644 --- a/apps/druid/core/services/runtime_commands.go +++ b/apps/druid/core/services/runtime_commands.go @@ -81,9 +81,6 @@ func (s *RuntimeSession) RemoveCommand(command string) error { if command == "" { return fmt.Errorf("command is required") } - if err := s.revokeCommandIdentities(command); err != nil { - return err - } s.runMu.Lock() defer s.runMu.Unlock() diff --git a/apps/druid/core/services/runtime_identity.go b/apps/druid/core/services/runtime_identity.go index ce2cdfbc..52eb0d4a 100644 --- a/apps/druid/core/services/runtime_identity.go +++ b/apps/druid/core/services/runtime_identity.go @@ -12,13 +12,6 @@ import ( "github.com/highcard-dev/daemon/internal/workloadidentity" ) -func commandRegistry(backend ports.RuntimeBackendInterface) workloadidentity.Registry { - if provider, ok := backend.(ports.StoredCommandIdentities); ok { - return provider.WorkloadIdentities() - } - return nil -} - func (s *RuntimeSupervisor) SetWorkloadAPIURL(base string) error { if base != "" { parsed, err := url.Parse(base) @@ -58,36 +51,19 @@ func (s *RuntimeSession) assignExecutionIdentity(command, execution string) erro return s.store.UpdateScroll(s.runtimeScroll) } -func (s *RuntimeSession) revokeExecution(execution string) error { - if registry := commandRegistry(s.runtimeBackend); registry != nil && execution != "" { - return registry.Revoke(context.Background(), execution) - } - return nil +// Docker tools are deliberately unauthenticated: trusted local development only. +func (s *RuntimeSupervisor) CommandToolsRequireIdentity() bool { + return s.runtimeBackend.Name() != "docker" } -func (s *RuntimeSession) revokeCommandIdentities(command string) error { - seen := map[string]bool{} - for name, procedures := range s.Snapshot() { - if command != "" && name != command { - continue - } - definition := s.scrollService.GetFile().Commands[name] - if definition == nil || definition.Identity == nil { - continue - } - for _, status := range procedures { - if status.ExecutionID != "" && !seen[status.ExecutionID] { - seen[status.ExecutionID] = true - if err := s.revokeExecution(status.ExecutionID); err != nil { - return err - } - } +func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token string, deployment string) (workloadidentity.Record, error) { + if !s.CommandToolsRequireIdentity() { + record := workloadidentity.Record{DeploymentID: deployment} + for _, permission := range ToolPermissions { + record.Permissions = append(record.Permissions, permission) } + return record, nil } - return nil -} - -func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token string, deployment string) (workloadidentity.Record, error) { backend, ok := s.runtimeBackend.(ports.CommandIdentityBackend) if !ok { return workloadidentity.Record{}, workloadidentity.ErrUnauthorized @@ -101,35 +77,3 @@ func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token strin } return record, nil } - -// ReconcileCommandIdentities cleans up file credentials using surviving resources. -// Kubernetes has no daemon-owned credential registry to reconcile. -func (s *RuntimeSupervisor) ReconcileCommandIdentities(ctx context.Context) error { - backend, ok := s.runtimeBackend.(ports.StoredCommandIdentities) - if !ok || backend.WorkloadIdentities() == nil { - return nil - } - // Snapshot records first: credentials provisioned after this snapshot must - // not be revoked using an older resource/provisioning snapshot. - records, err := backend.WorkloadIdentities().Records(ctx) - if err != nil { - return err - } - active, err := backend.ActiveExecutions(ctx) - if err != nil { - return err - } - s.mu.Lock() - for _, session := range s.sessions { - session.provisioning.Range(func(key, value any) bool { active[key.(string)] = true; return true }) - } - s.mu.Unlock() - for _, record := range records { - if !active[record.ExecutionID] { - if err := backend.WorkloadIdentities().Revoke(ctx, record.ExecutionID); err != nil { - return err - } - } - } - return backend.WorkloadIdentities().Prune(ctx, active) -} diff --git a/apps/druid/core/services/runtime_identity_test.go b/apps/druid/core/services/runtime_identity_test.go index 245591a0..0f0c4663 100644 --- a/apps/druid/core/services/runtime_identity_test.go +++ b/apps/druid/core/services/runtime_identity_test.go @@ -12,14 +12,21 @@ import ( type podIdentityBackend struct { ports.RuntimeBackendInterface record workloadidentity.Record + name string } func (b podIdentityBackend) AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Record, error) { return b.record, nil } -func (b podIdentityBackend) PrepareCommandIdentity(context.Context, workloadidentity.Record) (workloadidentity.CredentialMount, error) { - return workloadidentity.CredentialMount{}, nil +func (b podIdentityBackend) PrepareCommandIdentity(context.Context, workloadidentity.Record) error { + return nil +} +func (b podIdentityBackend) Name() string { + if b.name != "" { + return b.name + } + return "fixture" } func TestCommandAuthenticationNeedsNoDaemonState(t *testing.T) { @@ -40,3 +47,24 @@ func TestCommandAuthenticationNeedsNoDaemonState(t *testing.T) { t.Fatal("ungranted permission accepted") } } + +func TestDockerToolsIgnoreCredentialsPermissionsAndDeploymentBinding(t *testing.T) { + backend := podIdentityBackend{name: "docker"} + supervisor, err := NewRuntimeSupervisor(nil, nil, ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) + if err != nil { + t.Fatal(err) + } + for _, deployment := range []string{"one", "other"} { + for _, token := range []string{"", "invalid-token"} { + record, err := supervisor.AuthenticateCommand(context.Background(), token, deployment) + if err != nil || record.DeploymentID != deployment { + t.Fatal("Docker unexpectedly required credentials") + } + for _, permission := range ToolPermissions { + if !record.Allows(permission) { + t.Fatalf("Docker denied %s", permission) + } + } + } + } +} diff --git a/apps/druid/core/services/runtime_lifecycle.go b/apps/druid/core/services/runtime_lifecycle.go index 151bd75b..b4451be2 100644 --- a/apps/druid/core/services/runtime_lifecycle.go +++ b/apps/druid/core/services/runtime_lifecycle.go @@ -1,7 +1,6 @@ package services import ( - "context" "github.com/highcard-dev/daemon/internal/core/domain" ) @@ -17,9 +16,6 @@ func (s *RuntimeSupervisor) DeleteWithPolicy(id string, purgeData bool) error { delete(s.sessions, id) s.mu.Unlock() if session != nil { - if err := session.revokeCommandIdentities(""); err != nil { - return err - } session.stopDeploymentQueue() } @@ -28,19 +24,6 @@ func (s *RuntimeSupervisor) DeleteWithPolicy(id string, purgeData bool) error { return err } if runtimeScroll.Root != "" { - if registry := commandRegistry(s.runtimeBackend); registry != nil { - records, err := registry.Records(context.Background()) - if err != nil { - return err - } - for _, record := range records { - if record.DeploymentID == id { - if err := registry.Revoke(context.Background(), record.ExecutionID); err != nil { - return err - } - } - } - } if err := s.runtimeBackend.DeleteRuntime(runtimeScroll.Root, purgeData); err != nil { return err } diff --git a/apps/druid/core/services/runtime_session.go b/apps/druid/core/services/runtime_session.go index af18042d..4fd698fb 100644 --- a/apps/druid/core/services/runtime_session.go +++ b/apps/druid/core/services/runtime_session.go @@ -15,7 +15,6 @@ import ( // the runtime backend. type RuntimeSession struct { workloadAPIURL string - provisioning sync.Map store ports.RuntimeScrollStore runtimeScroll *domain.RuntimeScroll scrollService *coreservices.ScrollService diff --git a/apps/druid/core/services/runtime_session_execution.go b/apps/druid/core/services/runtime_session_execution.go index 0f6681ce..fe0fbc57 100644 --- a/apps/druid/core/services/runtime_session_execution.go +++ b/apps/druid/core/services/runtime_session_execution.go @@ -73,6 +73,7 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin procedureEnv[procedure][key] = value } } + requiresIdentity := s.runtimeBackend.Name() != "docker" executionID := "" s.queueMu.Lock() recovering := s.queue[cmd] != nil && s.queue[cmd].recovered @@ -84,7 +85,7 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin } } if executionID == "" { - if recovering && command.Identity != nil { + if recovering && command.Identity != nil && requiresIdentity { return fmt.Errorf("identity-enabled command requires a fresh execution") } executionID = uuid.NewString() @@ -93,18 +94,15 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin } } identity := &domain.RuntimeCommandIdentity{ExecutionID: executionID, DeploymentID: scrollID} - s.provisioning.Store(executionID, true) - defer s.provisioning.Delete(executionID) - if command.Identity != nil { + if command.Identity != nil && requiresIdentity { backend, ok := s.runtimeBackend.(ports.CommandIdentityBackend) if !ok || s.workloadAPIURL == "" { return fmt.Errorf("command identity requires runtime support and workload API URL") } - mount, err := backend.PrepareCommandIdentity(context.Background(), workloadidentity.Record{RuntimeRoot: root, Recover: recovering, ExecutionID: executionID, DeploymentID: scrollID, Command: cmd, Permissions: command.Identity.Permissions}) - if err != nil { + if err := backend.PrepareCommandIdentity(context.Background(), workloadidentity.Record{RuntimeRoot: root}); err != nil { return err } - identity.Enabled, identity.TokenPath = true, mount.Path + identity.Enabled = true identity.Permissions = append([]string(nil), command.Identity.Permissions...) } // Never mutate the persisted Scroll or leak transient mount references. @@ -119,15 +117,14 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin procedureEnv[name] = map[string]string{} } procedureEnv[name]["DRUID_EXECUTION_ID"] = executionID - if command.Identity != nil { - procedureEnv[name]["DRUID_TOKEN_FILE"] = workloadidentity.TokenFile + if s.workloadAPIURL != "" && (command.Identity != nil || !requiresIdentity) { + procedureEnv[name]["DRUID_TOKEN_FILE"] = "" + if requiresIdentity { + procedureEnv[name]["DRUID_TOKEN_FILE"] = workloadidentity.TokenFile + } procedureEnv[name]["DRUID_API_URL"] = s.workloadAPIURL + "/" + url.PathEscape(scrollID) } } - if command.Identity != nil && command.Run != domain.RunModePersistent { - defer s.revokeExecution(executionID) - } - exitCode, err := s.runtimeBackend.RunCommand(ports.RuntimeCommand{ ExecutionID: executionID, Name: cmd, @@ -139,9 +136,6 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin ProcedureEnv: procedureEnv, }) if err != nil { - if command.Identity != nil { - _ = s.revokeExecution(executionID) - } s.setCommandProcedureStatus(cmd, command, domain.ScrollLockStatusError, exitCode) return err } diff --git a/apps/druid/core/services/runtime_session_runtime.go b/apps/druid/core/services/runtime_session_runtime.go index 21fa6201..a834ddc9 100644 --- a/apps/druid/core/services/runtime_session_runtime.go +++ b/apps/druid/core/services/runtime_session_runtime.go @@ -88,9 +88,6 @@ func (s *RuntimeSession) StopRuntimeForMaintenance() error { } func (s *RuntimeSession) stopRuntime(resumeQueue bool) error { - if err := s.revokeCommandIdentities(""); err != nil { - return err - } s.mu.Lock() root := s.runtimeScroll.Root started := s.started diff --git a/apps/druid/core/services/runtime_supervisor.go b/apps/druid/core/services/runtime_supervisor.go index 72e16fe6..218fc0bd 100644 --- a/apps/druid/core/services/runtime_supervisor.go +++ b/apps/druid/core/services/runtime_supervisor.go @@ -91,13 +91,6 @@ func (s *RuntimeSupervisor) ObserveProcedureStatus(update ports.ProcedureStatusU return } session.persistProcedureExecutionStatus(update.Command, update.Procedure, update.Status, update.ExitCode, update.ExecutionID) - command := session.scrollService.GetFile().Commands[update.Command] - if command != nil && command.Identity != nil && update.ExecutionID != "" && (update.Status == domain.ScrollLockStatusDone || update.Status == domain.ScrollLockStatusError) { - status, ok := deriveCommandStatus(session.Snapshot()[update.Command], update.Command, session.scrollService.GetFile().Commands[update.Command]) - if ok && (status == domain.ScrollLockStatusDone || status == domain.ScrollLockStatusError) { - _ = session.revokeExecution(update.ExecutionID) - } - } } func (s *RuntimeSupervisor) SetWorkerCallbacks(callbacks *WorkerCallbackManager, callbackURL string) { diff --git a/internal/core/domain/command_identity.go b/internal/core/domain/command_identity.go index 66e9a777..d4188f31 100644 --- a/internal/core/domain/command_identity.go +++ b/internal/core/domain/command_identity.go @@ -11,7 +11,6 @@ type RuntimeCommandIdentity struct { DeploymentID string Enabled bool Permissions []string - TokenPath string } func ValidCommandPermission(permission string) bool { diff --git a/internal/core/ports/command_runtime.go b/internal/core/ports/command_runtime.go index bf5bb14e..3682dde5 100644 --- a/internal/core/ports/command_runtime.go +++ b/internal/core/ports/command_runtime.go @@ -5,19 +5,13 @@ import ( "github.com/highcard-dev/daemon/internal/workloadidentity" ) -// CommandIdentityBackend is implemented by both concrete command runtimes. +// CommandIdentityBackend is implemented by runtimes that authenticate command workloads. // It does not replace infrastructure ServiceAccount authentication. type CommandIdentityBackend interface { - PrepareCommandIdentity(context.Context, workloadidentity.Record) (workloadidentity.CredentialMount, error) + PrepareCommandIdentity(context.Context, workloadidentity.Record) error AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Record, error) } -// StoredCommandIdentities is only needed by runtimes that own credential files. -type StoredCommandIdentities interface { - WorkloadIdentities() workloadidentity.Registry - ActiveExecutions(context.Context) (map[string]bool, error) -} - type CommandConsoleBackend interface { ReadConsoleLogs(context.Context, string, string, int, int64) (string, bool, error) WriteConsoleInput(context.Context, string, string, []byte) (int, error) diff --git a/internal/runtime/backend.go b/internal/runtime/backend.go index ba145d17..477fb665 100644 --- a/internal/runtime/backend.go +++ b/internal/runtime/backend.go @@ -3,7 +3,6 @@ package runtime import ( "fmt" "io" - "path/filepath" "github.com/highcard-dev/daemon/internal/core/ports" "github.com/highcard-dev/daemon/internal/runtime/docker" @@ -65,7 +64,6 @@ func NewRuntime(name string, stateDir string, opts ...Option) (*Runtime, error) if err != nil { return nil, err } - options.Docker.IdentityStateDir = filepath.Join(store.StateDir(), "identities") return &Runtime{ Store: dockerRuntimeStore{RuntimeScrollStore: store, config: options.Docker.WithDefaults()}, create: func(observer ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { diff --git a/internal/runtime/docker/backend.go b/internal/runtime/docker/backend.go index 7a352657..a8731a56 100644 --- a/internal/runtime/docker/backend.go +++ b/internal/runtime/docker/backend.go @@ -8,11 +8,9 @@ import ( "github.com/docker/docker/client" "github.com/highcard-dev/daemon/internal/core/ports" - "github.com/highcard-dev/daemon/internal/workloadidentity" ) type Backend struct { - identities workloadidentity.Registry client *client.Client procedureStatusObserver ports.ProcedureStatusObserver config Config @@ -21,7 +19,6 @@ type Backend struct { } type Config struct { - IdentityStateDir string WorkerImage string Network string Storage string @@ -117,15 +114,7 @@ func NewWithConfig(config Config, observer ports.ProcedureStatusObserver) (*Back if err != nil { return nil, err } - var identities workloadidentity.Registry - if config.IdentityStateDir != "" { - identities, err = workloadidentity.NewFileRegistry(config.IdentityStateDir) - if err != nil { - return nil, err - } - } return &Backend{ - identities: identities, client: cli, procedureStatusObserver: observer, config: config, @@ -133,8 +122,6 @@ func NewWithConfig(config Config, observer ports.ProcedureStatusObserver) (*Back }, nil } -func (b *Backend) WorkloadIdentities() workloadidentity.Registry { return b.identities } - func (b *Backend) Name() string { return "docker" } diff --git a/internal/runtime/docker/command_identity.go b/internal/runtime/docker/command_identity.go deleted file mode 100644 index a24ccab2..00000000 --- a/internal/runtime/docker/command_identity.go +++ /dev/null @@ -1,36 +0,0 @@ -package docker - -import ( - "context" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/filters" - "github.com/highcard-dev/daemon/internal/workloadidentity" -) - -func (b *Backend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { - if b.identities == nil { - return workloadidentity.CredentialMount{}, workloadidentity.ErrUnavailable - } - return b.identities.Ensure(ctx, record) -} - -func (b *Backend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Record, error) { - if b.identities == nil { - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized - } - return b.identities.Lookup(ctx, token) -} - -func (b *Backend) ActiveExecutions(ctx context.Context) (map[string]bool, error) { - items, err := b.client.ContainerList(ctx, container.ListOptions{All: true, Filters: filters.NewArgs(filters.Arg("label", workloadidentity.ExecutionLabel))}) - if err != nil { - return nil, err - } - active := map[string]bool{} - for _, item := range items { - if item.State == "running" || item.State == "restarting" || item.State == "created" || item.State == "paused" { - active[item.Labels[workloadidentity.ExecutionLabel]] = true - } - } - return active, nil -} diff --git a/internal/runtime/docker/command_identity_test.go b/internal/runtime/docker/command_identity_test.go index 93532dce..16e44ce3 100644 --- a/internal/runtime/docker/command_identity_test.go +++ b/internal/runtime/docker/command_identity_test.go @@ -6,21 +6,13 @@ import ( "testing" ) -func TestCommandCredentialMountIsReadOnlyAndOutsideData(t *testing.T) { - procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: &domain.RuntimeCommandIdentity{ExecutionID: "execution", TokenPath: "/protected/identities/execution"}} - config, host, err := containerSpec("automation", procedure, "docker-volume://data", nil, nil, map[string]string{"DRUID_TOKEN_FILE": workloadidentity.TokenFile}) +func TestDockerCommandHasExecutionLabelButNoCredentialMount(t *testing.T) { + procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: &domain.RuntimeCommandIdentity{ExecutionID: "execution"}} + config, host, err := containerSpec("automation", procedure, "docker-volume://data", nil, nil, nil) if err != nil { t.Fatal(err) } - if config.Labels[workloadidentity.ExecutionLabel] != "execution" { - t.Fatal("execution label missing") - } - if len(host.Mounts) != 1 || host.Mounts[0].Target != workloadidentity.TokenFile || !host.Mounts[0].ReadOnly || host.Mounts[0].Source != "/protected/identities/execution" { - t.Fatal("invalid credential mount") - } - procedure.RuntimeIdentity = nil - _, host, err = containerSpec("ordinary", procedure, "docker-volume://data", nil, nil, nil) - if err != nil || len(host.Mounts) != 0 { - t.Fatal("non-opted-in command received a credential mount") + if config.Labels[workloadidentity.ExecutionLabel] != "execution" || len(host.Mounts) != 0 { + t.Fatal("Docker command identity created credential state") } } diff --git a/internal/runtime/docker/spec.go b/internal/runtime/docker/spec.go index c9347dd0..06c6a869 100644 --- a/internal/runtime/docker/spec.go +++ b/internal/runtime/docker/spec.go @@ -71,9 +71,6 @@ func containerSpec(commandName string, procedure *domain.Procedure, root string, } if identity := procedure.RuntimeIdentity; identity != nil { config.Labels[workloadidentity.ExecutionLabel] = identity.ExecutionID - if identity.TokenPath != "" { - hostConfig.Mounts = append(hostConfig.Mounts, mount.Mount{Type: mount.TypeBind, Source: identity.TokenPath, Target: workloadidentity.TokenFile, ReadOnly: true}) - } } return config, hostConfig, nil } diff --git a/internal/runtime/kubernetes/command_authentication_test.go b/internal/runtime/kubernetes/command_authentication_test.go index 16467df7..ee3c2c69 100644 --- a/internal/runtime/kubernetes/command_authentication_test.go +++ b/internal/runtime/kubernetes/command_authentication_test.go @@ -108,7 +108,7 @@ func TestCommandIdentityProvisioningOnlyCreatesAnUnprivilegedServiceAccount(t *t client := fake.NewSimpleClientset() backend := NewWithClient(Config{Namespace: "default"}, client) for index := 0; index < 2; index++ { - if _, err := backend.PrepareCommandIdentity(context.Background(), workloadidentity.Record{RuntimeRoot: ref("games", "data")}); err != nil { + if err := backend.PrepareCommandIdentity(context.Background(), workloadidentity.Record{RuntimeRoot: ref("games", "data")}); err != nil { t.Fatal(err) } } diff --git a/internal/runtime/kubernetes/command_identity.go b/internal/runtime/kubernetes/command_identity.go index 85a370bf..c4590ee6 100644 --- a/internal/runtime/kubernetes/command_identity.go +++ b/internal/runtime/kubernetes/command_identity.go @@ -21,12 +21,12 @@ func commandIdentityAnnotations(identity *domain.RuntimeCommandIdentity, command return map[string]string{commandDeploymentAnnotation: identity.DeploymentID, labelCommand: command, commandPermissionsAnnotation: strings.Join(identity.Permissions, " ")} } -func (b *Backend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) (workloadidentity.CredentialMount, error) { +func (b *Backend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) error { namespace, _, err := parseRef(record.RuntimeRoot) if err == nil { err = b.ensureRuntimeServiceAccount(ctx, namespace, commandServiceAccount) } - return workloadidentity.CredentialMount{}, err + return err } func (b *Backend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Record, error) { diff --git a/internal/utils/logger/credential_encoder_test.go b/internal/utils/logger/credential_encoder_test.go index 0a6801c2..1d0eb557 100644 --- a/internal/utils/logger/credential_encoder_test.go +++ b/internal/utils/logger/credential_encoder_test.go @@ -6,16 +6,12 @@ import ( "strings" "testing" - "github.com/highcard-dev/daemon/internal/workloadidentity" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) func TestCredentialRedactionIncludesInheritedAndNestedLogFields(t *testing.T) { - token, err := workloadidentity.NewToken() - if err != nil { - t.Fatal(err) - } + token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln" var output bytes.Buffer log := zap.New(NewProductionEncoderForWriter(zapcore.InfoLevel, zapcore.AddSync(&output))).With(zap.String("inherited", token)) log.Error("failure "+token, zap.Error(errors.New(token)), zap.Any("nested", map[string]string{"token": token})) diff --git a/internal/workloadidentity/file_registry.go b/internal/workloadidentity/file_registry.go deleted file mode 100644 index 8ee50a41..00000000 --- a/internal/workloadidentity/file_registry.go +++ /dev/null @@ -1,194 +0,0 @@ -package workloadidentity - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" -) - -// FileRegistry is daemon-owned state, separate from runtime data and backups. -type FileRegistry struct { - revoked map[string]bool - dir string - mu sync.RWMutex - records map[string]Record -} - -func NewFileRegistry(dir string) (*FileRegistry, error) { - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, err - } - if err := os.Chmod(dir, 0700); err != nil { - return nil, err - } - r := &FileRegistry{dir: dir, records: map[string]Record{}, revoked: map[string]bool{}} - data, err := os.ReadFile(filepath.Join(dir, "registry.json")) - if err == nil { - err = json.Unmarshal(data, &r.records) - } - if err != nil && !os.IsNotExist(err) { - return nil, err - } - return r, nil -} - -func (r *FileRegistry) save(records map[string]Record) error { - data, err := json.Marshal(records) - if err != nil { - return err - } - file, err := os.CreateTemp(r.dir, ".registry-") - if err != nil { - return err - } - defer os.Remove(file.Name()) - if _, err := file.Write(data); err != nil { - file.Close() - return err - } - if err := file.Sync(); err != nil { - file.Close() - return err - } - if err := file.Close(); err != nil { - return err - } - if err := os.Rename(file.Name(), filepath.Join(r.dir, "registry.json")); err != nil { - return err - } - r.records = records - return nil -} - -func (r *FileRegistry) Ensure(_ context.Context, record Record) (CredentialMount, error) { - r.mu.Lock() - defer r.mu.Unlock() - // IDs are generated by the daemon, but validate before using them as paths. - if record.ExecutionID == "" || filepath.Base(record.ExecutionID) != record.ExecutionID || record.ExecutionID == "." || record.ExecutionID == ".." { - return CredentialMount{}, fmt.Errorf("invalid execution ID") - } - path := filepath.Join(r.dir, record.ExecutionID) - if r.revoked[record.ExecutionID] { - return CredentialMount{}, ErrUnauthorized - } - for hash, existing := range r.records { - if existing.ExecutionID == record.ExecutionID { - if existing.DeploymentID != record.DeploymentID || existing.Command != record.Command { - return CredentialMount{}, ErrUnauthorized - } - token, err := os.ReadFile(path) - if err != nil || Hash(string(token)) != hash { - return CredentialMount{}, ErrUnauthorized - } - return CredentialMount{Path: path}, nil - } - } - if record.Recover { - return CredentialMount{}, ErrUnauthorized - } - token, err := NewToken() - if err != nil { - return CredentialMount{}, err - } - // Parent directory is 0700. The bind-mounted file must also be readable by - // non-root image users; it is mounted read-only into the execution. - if err := r.writeToken(path, token); err != nil { - return CredentialMount{}, err - } - updated := make(map[string]Record, len(r.records)+1) - for key, value := range r.records { - updated[key] = value - } - updated[Hash(token)] = record - if err := r.save(updated); err != nil { - os.Remove(path) - return CredentialMount{}, err - } - return CredentialMount{Path: path}, nil -} - -func (r *FileRegistry) writeToken(path, token string) error { - file, err := os.CreateTemp(r.dir, ".token-") - if err != nil { - return err - } - defer os.Remove(file.Name()) - defer file.Close() - if _, err := file.WriteString(token); err != nil { - return err - } - if err := file.Chmod(0444); err != nil { - return err - } - if err := file.Sync(); err != nil { - return err - } - if err := file.Close(); err != nil { - return err - } - return os.Rename(file.Name(), path) -} - -func (r *FileRegistry) Lookup(_ context.Context, token string) (Record, error) { - r.mu.RLock() - defer r.mu.RUnlock() - record, ok := r.records[Hash(token)] - if !ok || r.revoked[record.ExecutionID] { - return Record{}, ErrUnauthorized - } - return record, nil -} - -func (r *FileRegistry) Records(_ context.Context) ([]Record, error) { - r.mu.RLock() - defer r.mu.RUnlock() - records := make([]Record, 0, len(r.records)) - for _, record := range r.records { - records = append(records, record) - } - return records, nil -} - -func (r *FileRegistry) Revoke(_ context.Context, execution string) error { - r.mu.Lock() - defer r.mu.Unlock() - r.revoked[execution] = true - updated := make(map[string]Record, len(r.records)) - for key, record := range r.records { - if record.ExecutionID != execution { - updated[key] = record - } - } - if err := r.save(updated); err != nil { - return err - } - if err := os.Remove(filepath.Join(r.dir, execution)); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -func (r *FileRegistry) Prune(_ context.Context, active map[string]bool) error { - r.mu.Lock() - defer r.mu.Unlock() - files, err := os.ReadDir(r.dir) - if err != nil { - return err - } - known := map[string]bool{} - for _, record := range r.records { - known[record.ExecutionID] = true - } - for _, file := range files { - if file.IsDir() || !executionFileName.MatchString(file.Name()) || known[file.Name()] || active[file.Name()] { - continue - } - if err := os.Remove(filepath.Join(r.dir, file.Name())); err != nil { - return err - } - } - return nil -} diff --git a/internal/workloadidentity/identity.go b/internal/workloadidentity/identity.go index f77c5b33..e342a9d0 100644 --- a/internal/workloadidentity/identity.go +++ b/internal/workloadidentity/identity.go @@ -3,27 +3,19 @@ package workloadidentity import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" "errors" "regexp" ) -const TokenPrefix = "druid_wl_" const TokenFile = "/run/secrets/druid/token" const ExecutionLabel = "druid.execution-id" var ErrUnauthorized = errors.New("invalid workload credential") var ErrUnavailable = errors.New("workload identity verification is unavailable") var tokenPattern = regexp.MustCompile(`druid_wl_[A-Za-z0-9_-]{43}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`) -var executionFileName = regexp.MustCompile(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`) type Record struct { RuntimeRoot string `json:"-"` // Backend runtime binding; not a daemon-session dependency. - Recover bool `json:"-"` ExecutionID string `json:"execution_id"` DeploymentID string `json:"deployment_id"` Command string `json:"command"` @@ -39,30 +31,4 @@ func (r Record) Allows(permission string) bool { return false } -// CredentialMount contains only a backend-owned reference, never raw token data. -type CredentialMount struct { - Path string -} - -type Registry interface { - Ensure(context.Context, Record) (CredentialMount, error) - Lookup(context.Context, string) (Record, error) - Records(context.Context) ([]Record, error) - Revoke(context.Context, string) error - Prune(context.Context, map[string]bool) error -} - -func NewToken() (string, error) { - var bytes [32]byte - if _, err := rand.Read(bytes[:]); err != nil { - return "", err - } - return TokenPrefix + base64.RawURLEncoding.EncodeToString(bytes[:]), nil -} - -func Hash(token string) string { - hash := sha256.Sum256([]byte(token)) - return hex.EncodeToString(hash[:]) -} - func Redact(value string) string { return tokenPattern.ReplaceAllString(value, "[REDACTED]") } diff --git a/internal/workloadidentity/identity_test.go b/internal/workloadidentity/identity_test.go index ee6458f6..b8482b4f 100644 --- a/internal/workloadidentity/identity_test.go +++ b/internal/workloadidentity/identity_test.go @@ -1,65 +1,14 @@ package workloadidentity import ( - "context" - "errors" - "os" - "path/filepath" "strings" "testing" ) -func TestFileRegistryPreservesRevokesAndSeparatesCredentials(t *testing.T) { - ctx := context.Background() - directory := t.TempDir() - registry, err := NewFileRegistry(directory) - if err != nil { - t.Fatal(err) - } - record := Record{ExecutionID: "first", DeploymentID: "deployment", Command: "automation", Permissions: []string{"queue:read"}} - mount, err := registry.Ensure(ctx, record) - if err != nil { - t.Fatal(err) - } - token, err := os.ReadFile(mount.Path) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(string(token), TokenPrefix) || len(token) != len(TokenPrefix)+43 { - t.Fatal("invalid token format") - } - data, err := os.ReadFile(filepath.Join(directory, "registry.json")) - if err != nil || strings.Contains(string(data), string(token)) { - t.Fatal("raw credential in authorization registry") - } - reopened, err := NewFileRegistry(directory) - if err != nil { - t.Fatal(err) - } - identity, err := reopened.Lookup(ctx, string(token)) - if err != nil || identity.DeploymentID != record.DeploymentID || !identity.Allows("queue:read") || identity.Allows("commands:run") { - t.Fatal("identity was not preserved") - } - remount, err := reopened.Ensure(ctx, record) - if err != nil || remount.Path != mount.Path { - t.Fatal("reattachment replaced the credential") - } - if err := reopened.Revoke(ctx, record.ExecutionID); err != nil { - t.Fatal(err) - } - if _, err := reopened.Lookup(ctx, string(token)); !errors.Is(err, ErrUnauthorized) { - t.Fatal("revoked token accepted") - } - if _, err := reopened.Ensure(ctx, record); !errors.Is(err, ErrUnauthorized) { - t.Fatal("late provisioning resurrected a revoked execution") - } - if _, err := os.Stat(mount.Path); !os.IsNotExist(err) { - t.Fatal("revoked credential file retained") - } - if Redact("echo "+string(token)) != "echo [REDACTED]" { - t.Fatal("credential was not redacted") - } - if _, err := reopened.Ensure(ctx, Record{ExecutionID: "../escape"}); err == nil { - t.Fatal("unsafe credential path accepted") +func TestCredentialRedaction(t *testing.T) { + for _, token := range []string{"druid_wl_" + strings.Repeat("A", 43), "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln"} { + if got := Redact("echo " + token); got != "echo [REDACTED]" { + t.Fatal("credential exposed") + } } } diff --git a/test/integration/agent_ui_test.go b/test/integration/agent_ui_test.go index 05e2915a..6ae45489 100644 --- a/test/integration/agent_ui_test.go +++ b/test/integration/agent_ui_test.go @@ -22,11 +22,9 @@ import ( "github.com/highcard-dev/daemon/apps/druid/adapters/http/handlers" appservices "github.com/highcard-dev/daemon/apps/druid/core/services" "github.com/highcard-dev/daemon/internal/core/domain" - "github.com/highcard-dev/daemon/internal/core/ports" coreservices "github.com/highcard-dev/daemon/internal/core/services" runtimebackend "github.com/highcard-dev/daemon/internal/runtime" runtimedocker "github.com/highcard-dev/daemon/internal/runtime/docker" - "github.com/highcard-dev/daemon/internal/workloadidentity" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -165,23 +163,7 @@ commands: if _, err := supervisor.Run(id, "agent"); err != nil { t.Fatal(err) } - registry := runtime.Backend.(ports.StoredCommandIdentities).WorkloadIdentities() - var token string - for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { - records, _ := registry.Records(ctx) - for _, record := range records { - if record.DeploymentID == id { - data, _ := os.ReadFile(filepath.Join(state, "identities", record.ExecutionID)) - token = string(data) - } - } - if token != "" { - break - } - } - if !strings.HasPrefix(token, workloadidentity.TokenPrefix) { - t.Fatal("identity not provisioned") - } + token := "" // Docker tool endpoints are intentionally unauthenticated. app := fiber.New(fiber.Config{DisableStartupMessage: true}) handlers.RegisterPublicRoutes(app, handlers.RouteHandlers{Server: handlers.NewRuntimeServer(handlers.NewHealthHandler(), handlers.NewScrollHandler(supervisor)), Websocket: handlers.NewWebsocketHandler()}) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/test/integration/workload_identity_test.go b/test/integration/workload_identity_test.go index da214a63..ea18196f 100644 --- a/test/integration/workload_identity_test.go +++ b/test/integration/workload_identity_test.go @@ -44,7 +44,11 @@ type workloadTransport struct{ token string } func (t workloadTransport) RoundTrip(request *http.Request) (*http.Response, error) { copy := request.Clone(request.Context()) copy.Header = request.Header.Clone() - copy.Header.Set("Authorization", "Bearer "+t.token) + if t.token != "" { + copy.Header.Set("Authorization", "Bearer "+t.token) + } else { + copy.Header.Del("Authorization") + } return http.DefaultTransport.RoundTrip(copy) } @@ -121,7 +125,7 @@ commands: procedures: - id: agent-console image: node:22-alpine - command: [node, -e, 'const fs=require("node:fs"); const token=fs.readFileSync(process.env.DRUID_TOKEN_FILE,"utf8"); try {fs.writeFileSync(process.env.DRUID_TOKEN_FILE,"bad"); process.exit(9)} catch {} fetch(process.env.DRUID_API_URL+"/api/v1/commands", {headers:{Authorization:"Bearer "+token}}).then(r=>{if(!r.ok)process.exit(8); console.log("MOUNT_API_OK")}).catch(()=>process.exit(7)); require("node:readline").createInterface({input:process.stdin}).on("line",line=>console.log("INPUT:"+line)); console.log("READY");'] + command: [node, -e, 'const fs=require("node:fs"); const headers={}; if(process.env.DRUID_TOKEN_FILE){headers.Authorization="Bearer "+fs.readFileSync(process.env.DRUID_TOKEN_FILE,"utf8"); try{fs.writeFileSync(process.env.DRUID_TOKEN_FILE,"bad");process.exit(9)}catch{}} fetch(process.env.DRUID_API_URL+"/api/v1/commands", {headers}).then(r=>{if(!r.ok)process.exit(8); console.log("MOUNT_API_OK")}).catch(()=>process.exit(7)); require("node:readline").createInterface({input:process.stdin}).on("line",line=>console.log("INPUT:"+line)); console.log("READY");'] `} if err := runtime.Store.CreateScroll(scroll); err != nil { t.Fatal(err) @@ -203,12 +207,7 @@ commands: } return false } - data, err := os.ReadFile(filepath.Join(state, "identities", execution)) - if err != nil { - return false - } - token = string(data) - return token != "" + return true // Docker tools intentionally need no credential. } eventually("credential provisioning", readCredential) if kubernetes != nil { @@ -249,7 +248,11 @@ commands: } session := connect() tools, err := session.ListTools(ctx, nil) - if err != nil || len(tools.Tools) != 9 { + wantTools := len(appservices.ToolPermissions) + if kubernetes != nil { + wantTools-- + } // This fixture does not grant ui:publish. + if err != nil || len(tools.Tools) != wantTools { t.Fatalf("discovery failed: %v", err) } for _, name := range []string{"list_commands", "get_queue", "get_deployment_status"} { @@ -303,7 +306,6 @@ commands: if !bytes.Contains(data, []byte("COMPLETED_AFTER_DISCONNECT")) { t.Fatal("completed container logs unavailable") } - eventually("healthy identity reconciliation", func() bool { return supervisor.ReconcileCommandIdentities(ctx) == nil }) if _, err := supervisor.AuthenticateCommand(ctx, token, id); err != nil { t.Fatal("surviving execution lost its credential") } @@ -350,9 +352,9 @@ commands: data, _ := json.Marshal(result) return bytes.Contains(data, []byte("list_commands")) }) - t.Log("verified exact SPA launcher with pinned mcp-remote, mounted token, MCP initialize and tool discovery") + t.Log("verified exact SPA launcher with pinned mcp-remote, runtime authentication, MCP initialize and tool discovery") } - // Reconnect a new supervisor/registry to persisted running resources. + // Reconnect a new supervisor to persisted running resources. reattached, err := runtimebackend.NewRuntime(backendName, state, options...) if err != nil { t.Fatal(err) @@ -365,9 +367,6 @@ commands: if err := second.SetWorkloadAPIURL(fmt.Sprintf("http://host.docker.internal:%d", port)); err != nil { t.Fatal(err) } - if err := second.ReconcileCommandIdentities(ctx); err != nil { - t.Fatal(err) - } if _, err := second.AuthenticateCommand(ctx, token, id); err != nil { t.Fatal("daemon reattachment rotated or lost identity") } @@ -379,9 +378,18 @@ commands: if err := supervisor.RemoveCommand(id, "agent"); err != nil { t.Fatal(err) } - eventually("revocation", func() bool { _, err := supervisor.AuthenticateCommand(ctx, token, id); return err != nil }) - if _, err := call("list_commands", appservices.ToolInput{}); err == nil { - t.Fatal("removed execution remained authorized") + if kubernetes != nil { + eventually("revocation", func() bool { _, err := supervisor.AuthenticateCommand(ctx, token, id); return err != nil }) + if _, err := call("list_commands", appservices.ToolInput{}); err == nil { + t.Fatal("removed execution remained authorized") + } + } else { + if _, err := call("list_commands", appservices.ToolInput{}); err != nil { + t.Fatal("Docker unexpectedly required a credential") + } + if _, err := os.Stat(filepath.Join(state, "identities")); !os.IsNotExist(err) { + t.Fatal("Docker created identity registry files") + } } oldToken, oldExecution := token, execution if err := supervisor.AddCommand(id, "agent", agent); err != nil { @@ -395,11 +403,11 @@ commands: if !readCredential() { return false } - return execution != oldExecution && token != oldToken && (kubernetes != nil || strings.HasPrefix(token, workloadidentity.TokenPrefix)) + return execution != oldExecution && (kubernetes == nil || token != oldToken) }) - if _, err := supervisor.AuthenticateCommand(ctx, oldToken, id); err == nil { + if _, err := supervisor.AuthenticateCommand(ctx, oldToken, id); kubernetes != nil && err == nil { t.Fatal("old credential resurrected after replacement") } - t.Log("verified real mount/HTTP, all nine tools, command creation/execution, stdin/logs, completed logs, disconnection, daemon reattachment, and removal revocation") + t.Log("verified runtime HTTP/MCP, tool discovery, command creation/execution, stdin/logs, completed logs, disconnection, daemon reattachment, and backend-specific authentication") } From 5467737c3611b9e016995d59f40802a08bce4862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 02:32:33 +0100 Subject: [PATCH 05/14] refactor(auth): trim credential redaction Restore existing log encoders and remove obsolete opaque-token matching. Keep JWT sanitization at workload log/error output instead of scanning all logs and rescanning every serialized MCP result. Verify HTTP/MCP sanitization parity without changing Kubernetes isolation, Docker tool access or execution tracking. --- .../adapters/http/handlers/command_tools.go | 2 +- .../http/handlers/command_tools_test.go | 67 ++++++++++++++++++- internal/utils/logger/credential_encoder.go | 26 ------- .../utils/logger/credential_encoder_test.go | 21 ------ internal/utils/logger/development_encoder.go | 2 +- internal/utils/logger/production_encoder.go | 2 +- internal/utils/logger/reduced_encoder.go | 2 +- internal/workloadidentity/identity.go | 2 +- internal/workloadidentity/identity_test.go | 12 ++-- 9 files changed, 74 insertions(+), 62 deletions(-) delete mode 100644 internal/utils/logger/credential_encoder.go delete mode 100644 internal/utils/logger/credential_encoder_test.go diff --git a/apps/druid/adapters/http/handlers/command_tools.go b/apps/druid/adapters/http/handlers/command_tools.go index 53d2e0af..5a9f2c6b 100644 --- a/apps/druid/adapters/http/handlers/command_tools.go +++ b/apps/druid/adapters/http/handlers/command_tools.go @@ -133,7 +133,7 @@ func (h *ScrollHandler) registerCommandTools(app *fiber.App) { if err != nil { return nil, err } - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: workloadidentity.Redact(string(data))}}}, nil + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}}, nil }) } return server diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go index 7b494cc9..7c10df11 100644 --- a/apps/druid/adapters/http/handlers/command_tools_test.go +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "sync" "testing" @@ -104,6 +105,8 @@ func TestCreateCommandSchemaPreservesNestedTypesForOpenHands(t *testing.T) { type toolRouteBackend struct { ports.RuntimeBackendInterface credentials sync.Map + logs string + uploadError error } func (b *toolRouteBackend) PrepareCommandIdentity(context.Context, workloadidentity.Record) error { @@ -118,9 +121,18 @@ func (b *toolRouteBackend) AuthenticateCommandIdentity(_ context.Context, token func (b *toolRouteBackend) Name() string { return "fixture" } func (b *toolRouteBackend) RunCommand(ports.RuntimeCommand) (*int, error) { return nil, nil } func (b *toolRouteBackend) CreateUIPackageUpload(_ context.Context, action ports.RuntimeUIPackageUploadAction) (ports.RuntimeUIPackageUpload, error) { + if b.uploadError != nil { + return ports.RuntimeUIPackageUpload{}, b.uploadError + } return ports.RuntimeUIPackageUpload{UploadURL: "http://upload-private/wasm", DocumentUploadURL: "http://upload-private/document", URL: "http://packages/" + string(action.Scope) + "/app.wasm"}, nil } func (b *toolRouteBackend) StopCommand(string, string) error { return nil } +func (b *toolRouteBackend) ReadConsoleLogs(context.Context, string, string, int, int64) (string, bool, error) { + return b.logs, false, nil +} +func (b *toolRouteBackend) WriteConsoleInput(context.Context, string, string, []byte) (int, error) { + return 0, nil +} type authenticatedTransport struct{ token string } @@ -140,11 +152,12 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { t.Fatal(err) } runtime := &domain.RuntimeScroll{ID: "tools", Root: t.TempDir(), ScrollName: "tools", ScrollYAML: "name: tools\ndesc: Tools fixture\nversion: 0.1.0\napp_version: 1.0\ncommands:\n agent:\n run: persistent\n identity:\n permissions: [commands:list, commands:create, queue:read, ui:publish]\n procedures:\n - image: busybox\n env: {SECRET: must-not-leak}\n"} + runtime.Procedures = domain.ProcedureStatusMap{"agent": {"agent.0": {Status: domain.ScrollLockStatusRunning}}} if err := store.CreateScroll(runtime); err != nil { t.Fatal(err) } backend := &toolRouteBackend{} - backend.credentials.Store(token, workloadidentity.Record{ExecutionID: "execution", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list", "commands:create", "queue:read", "ui:publish"}}) + backend.credentials.Store(token, workloadidentity.Record{ExecutionID: "execution", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list", "commands:create", "queue:read", "ui:publish", "logs:read"}}) supervisor, err := appservices.NewRuntimeSupervisor(store, coreservices.NewRuntimeScrollManager(store), ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) if err != nil { t.Fatal(err) @@ -212,13 +225,63 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { if err != nil { t.Fatal(err) } - if len(listed.Tools) != 5 { + if len(listed.Tools) != 7 { t.Fatalf("permission-filtered discovery returned %d tools", len(listed.Tools)) } result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "list_commands", Arguments: map[string]any{}}) if err != nil || result.IsError { t.Fatal("MCP command listing failed") } + t.Run("HTTP and MCP sanitize workload logs and tool errors identically", func(t *testing.T) { + jwt := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln" + backend.logs = "application printed " + jwt + for _, test := range []struct { + tool, method, path, body string + arguments map[string]any + failure bool + }{ + {"read_logs", "GET", "/tools/api/v1/consoles/agent.0/logs", "", map[string]any{"console": "agent.0"}, false}, + {"search_logs", "POST", "/tools/api/v1/consoles/agent.0/logs/search", `{"query":"application"}`, map[string]any{"console": "agent.0", "query": "application"}, false}, + {"publish_ui", "POST", "/tools/api/v1/ui/packages/private/publish", `{}`, map[string]any{"scope": "private"}, true}, + } { + if test.failure { + backend.uploadError = &appservices.ToolError{Status: 400, Message: "upstream error " + jwt} + } + request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Content-Type", "application/json") + response, err := app.Test(request, -1) + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil { + t.Fatal(err) + } + mcpResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: test.tool, Arguments: test.arguments}) + if err != nil || mcpResult.IsError != test.failure || len(mcpResult.Content) != 1 { + t.Fatalf("%s MCP response: %v, %v", test.tool, mcpResult, err) + } + text := mcpResult.Content[0].(*mcp.TextContent).Text + for _, output := range []string{string(data), text} { + if strings.Contains(output, jwt) || !strings.Contains(output, "[REDACTED]") { + t.Fatalf("%s did not sanitize workload content", test.tool) + } + } + if test.failure { + if response.StatusCode != 400 || string(data) != text { + t.Fatal("HTTP/MCP tool errors differ") + } + } else { + var httpValue, mcpValue any + if response.StatusCode != 200 || json.Unmarshal(data, &httpValue) != nil || json.Unmarshal([]byte(text), &mcpValue) != nil || !reflect.DeepEqual(httpValue, mcpValue) { + t.Fatal("HTTP/MCP log results differ") + } + } + backend.uploadError = nil + } + }) publication, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "publish_ui", Arguments: map[string]any{"scope": "private"}}) if err != nil || publication.IsError { t.Fatalf("MCP publish failed: %v, %v", err, publication) diff --git a/internal/utils/logger/credential_encoder.go b/internal/utils/logger/credential_encoder.go deleted file mode 100644 index 90982f54..00000000 --- a/internal/utils/logger/credential_encoder.go +++ /dev/null @@ -1,26 +0,0 @@ -package logger - -import ( - "github.com/highcard-dev/daemon/internal/workloadidentity" - "go.uber.org/zap/buffer" - "go.uber.org/zap/zapcore" -) - -// Redact after encoding so messages, errors, nested fields and inherited fields -// all receive the same protection, including application output in failures. -type credentialEncoder struct{ zapcore.Encoder } - -func (e credentialEncoder) Clone() zapcore.Encoder { - return credentialEncoder{e.Encoder.Clone()} -} - -func (e credentialEncoder) EncodeEntry(entry zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) { - encoded, err := e.Encoder.EncodeEntry(entry, fields) - if err != nil { - return encoded, err - } - redacted := workloadidentity.Redact(encoded.String()) - encoded.Reset() - encoded.AppendString(redacted) - return encoded, nil -} diff --git a/internal/utils/logger/credential_encoder_test.go b/internal/utils/logger/credential_encoder_test.go deleted file mode 100644 index 1d0eb557..00000000 --- a/internal/utils/logger/credential_encoder_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package logger - -import ( - "bytes" - "errors" - "strings" - "testing" - - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -func TestCredentialRedactionIncludesInheritedAndNestedLogFields(t *testing.T) { - token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln" - var output bytes.Buffer - log := zap.New(NewProductionEncoderForWriter(zapcore.InfoLevel, zapcore.AddSync(&output))).With(zap.String("inherited", token)) - log.Error("failure "+token, zap.Error(errors.New(token)), zap.Any("nested", map[string]string{"token": token})) - if strings.Contains(output.String(), token) || strings.Count(output.String(), "[REDACTED]") != 4 { - t.Fatal("issued credentials leaked through a log field") - } -} diff --git a/internal/utils/logger/development_encoder.go b/internal/utils/logger/development_encoder.go index 61114981..de2b5ff7 100644 --- a/internal/utils/logger/development_encoder.go +++ b/internal/utils/logger/development_encoder.go @@ -18,5 +18,5 @@ func NewDevelopmentEncoder(level zapcore.Level) zapcore.Core { errEncoder.MessageKey = "message" errEncoder.EncodeTime = zapcore.RFC3339TimeEncoder - return zapcore.NewCore(credentialEncoder{zapcore.NewConsoleEncoder(errEncoder)}, zapcore.AddSync(os.Stderr), level) + return zapcore.NewCore(zapcore.NewConsoleEncoder(errEncoder), zapcore.AddSync(os.Stderr), level) } diff --git a/internal/utils/logger/production_encoder.go b/internal/utils/logger/production_encoder.go index e2e84cf7..0a110cac 100644 --- a/internal/utils/logger/production_encoder.go +++ b/internal/utils/logger/production_encoder.go @@ -20,5 +20,5 @@ func NewProductionEncoderForWriter(level zapcore.Level, writer zapcore.WriteSync structuredEncoder.LevelKey = "severity" structuredEncoder.MessageKey = "message" structuredEncoder.EncodeTime = zapcore.ISO8601TimeEncoder - return zapcore.NewCore(credentialEncoder{zapcore.NewJSONEncoder(structuredEncoder)}, writer, level) + return zapcore.NewCore(zapcore.NewJSONEncoder(structuredEncoder), writer, level) } diff --git a/internal/utils/logger/reduced_encoder.go b/internal/utils/logger/reduced_encoder.go index 8ef3e90f..15123c64 100644 --- a/internal/utils/logger/reduced_encoder.go +++ b/internal/utils/logger/reduced_encoder.go @@ -29,7 +29,7 @@ func NewReducedEncoder(level zapcore.Level) zapcore.Core { }), } - return zapcore.NewCore(credentialEncoder{reducedEncoder}, zapcore.AddSync(colorable.NewColorableStdout()), level) + return zapcore.NewCore(reducedEncoder, zapcore.AddSync(colorable.NewColorableStdout()), level) } func (e *ReducedEncoder) Clone() zapcore.Encoder { diff --git a/internal/workloadidentity/identity.go b/internal/workloadidentity/identity.go index e342a9d0..480ed676 100644 --- a/internal/workloadidentity/identity.go +++ b/internal/workloadidentity/identity.go @@ -12,7 +12,7 @@ const ExecutionLabel = "druid.execution-id" var ErrUnauthorized = errors.New("invalid workload credential") var ErrUnavailable = errors.New("workload identity verification is unavailable") -var tokenPattern = regexp.MustCompile(`druid_wl_[A-Za-z0-9_-]{43}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`) +var tokenPattern = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`) type Record struct { RuntimeRoot string `json:"-"` // Backend runtime binding; not a daemon-session dependency. diff --git a/internal/workloadidentity/identity_test.go b/internal/workloadidentity/identity_test.go index b8482b4f..a3fb6b9a 100644 --- a/internal/workloadidentity/identity_test.go +++ b/internal/workloadidentity/identity_test.go @@ -1,14 +1,10 @@ package workloadidentity -import ( - "strings" - "testing" -) +import "testing" func TestCredentialRedaction(t *testing.T) { - for _, token := range []string{"druid_wl_" + strings.Repeat("A", 43), "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln"} { - if got := Redact("echo " + token); got != "echo [REDACTED]" { - t.Fatal("credential exposed") - } + token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln" + if got := Redact("echo " + token); got != "echo [REDACTED]" { + t.Fatal("credential exposed") } } From 913beef8e1aa016ce6f4a09fc768d61b428d6c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 02:43:01 +0100 Subject: [PATCH 06/14] refactor(tools)!: expose agents through MCP only Delete parallel agent REST routes and their owner-auth fallthrough. Keep existing owner REST endpoints registered once with normal auth; OpenHands continues using the unchanged MCP tools. BREAKING CHANGE: agent tools are available only at /:id/mcp. Kubernetes permissions and anonymous Docker MCP behavior are unchanged. --- README.md | 5 +- .../adapters/http/handlers/command_tools.go | 60 +----- .../http/handlers/command_tools_test.go | 184 +++++++----------- .../handlers/docker_command_tools_test.go | 32 +-- apps/druid/adapters/http/handlers/routes.go | 4 +- apps/druid/core/services/command_tools.go | 2 +- apps/druid/core/services/runtime_identity.go | 8 +- test/integration/workload_identity_test.go | 8 +- 8 files changed, 92 insertions(+), 211 deletions(-) diff --git a/README.md b/README.md index d893716c..d6017f0e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,10 @@ There is also websocket support for stdout. TTY is also supported. Runtime selection is daemon-only: start the daemon with `druid daemon --runtime docker`, then use `druid` to create, run, and inspect scrolls without passing a runtime. Docker runtime state stays in SQLite under the runtime state directory. Scroll specs and runtime data live together in one runtime root. -**Docker agent-tool caveat:** HTTP/MCP tool endpoints on the Docker runtime are +Agent tools are exposed only at `/:id/mcp`. Existing REST endpoints retain owner +authentication; there are no parallel agent REST routes. + +**Docker agent-tool caveat:** MCP tool endpoints on the Docker runtime are unauthenticated and ignore command permissions. Anyone who can reach them can invoke every tool for any deployment, including command creation/execution, stdin, log access and UI publication. Docker creates no workload credentials or diff --git a/apps/druid/adapters/http/handlers/command_tools.go b/apps/druid/adapters/http/handlers/command_tools.go index 5a9f2c6b..a9c62e9f 100644 --- a/apps/druid/adapters/http/handlers/command_tools.go +++ b/apps/druid/adapters/http/handlers/command_tools.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "io" "net/http" "strings" @@ -47,64 +46,7 @@ func bearer(header string) string { return parts[1] } -func (h *ScrollHandler) commandToolHTTP(name string) fiber.Handler { - return func(c *fiber.Ctx) error { - token := bearer(c.Get("Authorization")) - // Preserve the existing owner-authenticated endpoints. - if h.supervisor.CommandToolsRequireIdentity() && (name == "get_queue" || name == "publish_ui") { - if c.Get("Authorization") == "" { - return c.Next() - } - if h.authorizer != nil { - if owner, err := h.authorizer.CheckHeader(c); err == nil && owner != nil { - return c.Next() - } - } - } - input := services.ToolInput{} - if len(c.Body()) > 0 { - decoder := json.NewDecoder(bytes.NewReader(c.Body())) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&input); err != nil { - return fiber.NewError(400, "invalid tool request") - } - if err := decoder.Decode(new(any)); err != io.EOF { - return fiber.NewError(400, "invalid tool request") - } - } - input.Command = c.Params("command", input.Command) - input.Console = c.Params("console", input.Console) - input.ExecutionID = c.Params("execution", input.ExecutionID) - input.Scope = c.Params("scope", input.Scope) - if c.Method() == "GET" { - input.TailLines = c.QueryInt("tail_lines", 0) - input.TimeoutSeconds = c.QueryInt("timeout_seconds", 0) - } - result, err := h.supervisor.CallCommandTool(c.UserContext(), token, c.Params("id"), name, input) - if err != nil { - return fiber.NewError(commandToolStatus(err), commandToolMessage(err)) - } - if name == "run_command" { - c.Status(202) - } - if name == "create_command" { - c.Status(201) - } - return c.JSON(result) - } -} - -func (h *ScrollHandler) registerCommandTools(app *fiber.App) { - app.Get("/:id/api/v1/commands", h.commandToolHTTP("list_commands")) - app.Post("/:id/api/v1/commands", h.commandToolHTTP("create_command")) - app.Get("/:id/api/v1/queue", h.commandToolHTTP("get_queue")) - app.Post("/:id/api/v1/commands/:command", h.commandToolHTTP("run_command")) - app.Get("/:id/api/v1/commands/:command/executions/:execution/wait", h.commandToolHTTP("wait_for_command")) - app.Get("/:id/api/v1/consoles/:console/logs", h.commandToolHTTP("read_logs")) - app.Post("/:id/api/v1/consoles/:console/logs/search", h.commandToolHTTP("search_logs")) - app.Post("/:id/api/v1/consoles/:console/stdin", h.commandToolHTTP("send_stdin")) - app.Get("/:id/api/v1/status", h.commandToolHTTP("get_deployment_status")) - app.Post("/:id/api/v1/ui/packages/:scope/publish", h.commandToolHTTP("publish_ui")) +func (h *ScrollHandler) registerCommandMCP(app *fiber.App) { app.All("/:id/mcp", func(c *fiber.Ctx) error { deployment, token := c.Params("id"), bearer(c.Get("Authorization")) record, err := h.supervisor.AuthenticateCommand(c.UserContext(), token, deployment) diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go index 7c10df11..5b3b228b 100644 --- a/apps/druid/adapters/http/handlers/command_tools_test.go +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "net/http/httptest" - "reflect" "strings" "sync" "testing" @@ -143,7 +142,7 @@ func (t authenticatedTransport) RoundTrip(request *http.Request) (*http.Response return http.DefaultTransport.RoundTrip(copy) } -func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { +func TestCommandToolsRealMCPClient(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() token := "fixture-token" @@ -178,18 +177,40 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { } app := fiber.New(fiber.Config{DisableStartupMessage: true}) RegisterPublicRoutes(app, RouteHandlers{Server: NewRuntimeServer(NewHealthHandler(), scrolls), Websocket: NewWebsocketHandler()}) - request := httptest.NewRequest("GET", "/tools/api/v1/commands", nil) - request.Header.Set("Authorization", "Bearer "+string(token)) - response, err := app.Test(request) - if err != nil { - t.Fatal(err) + for _, route := range []struct{ method, path string }{ + {"GET", "/:id/api/v1/queue"}, + {"POST", "/:id/api/v1/ui/packages/:scope/publish"}, + } { + count := 0 + for _, registered := range app.GetRoutes() { + if registered.Method == route.method && registered.Path == route.path { + count++ + } + } + if count != 1 { + t.Fatalf("owner REST route %s is registered %d times", route.path, count) + } } - data, _ := io.ReadAll(response.Body) - response.Body.Close() - if response.StatusCode != 200 || strings.Contains(string(data), "must-not-leak") { - t.Fatal("metadata HTTP endpoint failed or leaked secrets") + for _, route := range []struct{ method, path string }{ + {"GET", "/tools/api/v1/commands"}, + {"POST", "/tools/api/v1/commands"}, + {"POST", "/tools/api/v1/commands/agent"}, + {"GET", "/tools/api/v1/commands/agent/executions/execution/wait"}, + {"GET", "/tools/api/v1/consoles/agent.0/logs"}, + {"POST", "/tools/api/v1/consoles/agent.0/logs/search"}, + {"POST", "/tools/api/v1/consoles/agent.0/stdin"}, + {"GET", "/tools/api/v1/status"}, + } { + response, err := app.Test(httptest.NewRequest(route.method, route.path, nil)) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 404 { + t.Fatalf("removed agent REST route %s: %d", route.path, response.StatusCode) + } } - for _, path := range []string{"/other/api/v1/commands", "/tools/api/v1/token", "/tools/api/v1/scroll"} { + for _, path := range []string{"/tools/api/v1/commands", "/other/api/v1/commands", "/tools/api/v1/token", "/tools/api/v1/scroll", "/tools/api/v1/queue", "/tools/api/v1/ui/packages/private/publish"} { request := httptest.NewRequest("GET", path, nil) request.Header.Set("Authorization", "Bearer "+string(token)) response, err := app.Test(request) @@ -216,6 +237,13 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { })) defer server.Close() client := mcp.NewClient(&mcp.Implementation{Name: "druid-test", Version: "1"}, nil) + for _, denied := range []struct{ deployment, token string }{{"tools", ""}, {"tools", "invalid"}, {"other", token}} { + deniedSession, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: server.URL + "/" + denied.deployment + "/mcp", HTTPClient: &http.Client{Transport: authenticatedTransport{denied.token}}}, nil) + if err == nil { + deniedSession.Close() + t.Fatal("MCP accepted an absent, invalid or cross-deployment credential") + } + } session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: server.URL + "/tools/mcp", HTTPClient: &http.Client{Transport: authenticatedTransport{string(token)}}}, nil) if err != nil { t.Fatal(err) @@ -232,52 +260,28 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { if err != nil || result.IsError { t.Fatal("MCP command listing failed") } - t.Run("HTTP and MCP sanitize workload logs and tool errors identically", func(t *testing.T) { + t.Run("MCP sanitizes workload logs and tool errors", func(t *testing.T) { jwt := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln" backend.logs = "application printed " + jwt for _, test := range []struct { - tool, method, path, body string - arguments map[string]any - failure bool + tool string + arguments map[string]any + failure bool }{ - {"read_logs", "GET", "/tools/api/v1/consoles/agent.0/logs", "", map[string]any{"console": "agent.0"}, false}, - {"search_logs", "POST", "/tools/api/v1/consoles/agent.0/logs/search", `{"query":"application"}`, map[string]any{"console": "agent.0", "query": "application"}, false}, - {"publish_ui", "POST", "/tools/api/v1/ui/packages/private/publish", `{}`, map[string]any{"scope": "private"}, true}, + {"read_logs", map[string]any{"console": "agent.0"}, false}, + {"search_logs", map[string]any{"console": "agent.0", "query": "application"}, false}, + {"publish_ui", map[string]any{"scope": "private"}, true}, } { if test.failure { backend.uploadError = &appservices.ToolError{Status: 400, Message: "upstream error " + jwt} } - request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) - request.Header.Set("Authorization", "Bearer "+token) - request.Header.Set("Content-Type", "application/json") - response, err := app.Test(request, -1) - if err != nil { - t.Fatal(err) + result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: test.tool, Arguments: test.arguments}) + if err != nil || result.IsError != test.failure || len(result.Content) != 1 { + t.Fatalf("%s MCP response: %v, %v", test.tool, result, err) } - data, err := io.ReadAll(response.Body) - response.Body.Close() - if err != nil { - t.Fatal(err) - } - mcpResult, err := session.CallTool(ctx, &mcp.CallToolParams{Name: test.tool, Arguments: test.arguments}) - if err != nil || mcpResult.IsError != test.failure || len(mcpResult.Content) != 1 { - t.Fatalf("%s MCP response: %v, %v", test.tool, mcpResult, err) - } - text := mcpResult.Content[0].(*mcp.TextContent).Text - for _, output := range []string{string(data), text} { - if strings.Contains(output, jwt) || !strings.Contains(output, "[REDACTED]") { - t.Fatalf("%s did not sanitize workload content", test.tool) - } - } - if test.failure { - if response.StatusCode != 400 || string(data) != text { - t.Fatal("HTTP/MCP tool errors differ") - } - } else { - var httpValue, mcpValue any - if response.StatusCode != 200 || json.Unmarshal(data, &httpValue) != nil || json.Unmarshal([]byte(text), &mcpValue) != nil || !reflect.DeepEqual(httpValue, mcpValue) { - t.Fatal("HTTP/MCP log results differ") - } + text := result.Content[0].(*mcp.TextContent).Text + if strings.Contains(text, jwt) || !strings.Contains(text, "[REDACTED]") { + t.Fatalf("%s did not sanitize workload content", test.tool) } backend.uploadError = nil } @@ -290,30 +294,6 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { if !strings.Contains(string(encodedPublication), "published") || strings.Contains(string(encodedPublication), "upload-private") { t.Fatal("unsafe publication projection") } - for _, test := range []struct { - path, body, token string - status int - }{ - {"/tools/api/v1/ui/packages/public/publish", `{}`, string(token), 200}, - {"/tools/api/v1/ui/packages/private/publish", `{"path":"public/app.wasm"}`, string(token), 400}, - {"/other/api/v1/ui/packages/private/publish", `{}`, string(token), 401}, - // Owner/local requests fall through to the unchanged owner endpoint. - {"/tools/api/v1/ui/packages/private/publish", `{}`, "", 200}, - } { - request := httptest.NewRequest("POST", test.path, strings.NewReader(test.body)) - request.Header.Set("Content-Type", "application/json") - if test.token != "" { - request.Header.Set("Authorization", "Bearer "+test.token) - } - response, err := app.Test(request, -1) - if err != nil { - t.Fatal(err) - } - response.Body.Close() - if response.StatusCode != test.status { - t.Fatalf("%s: got %d, want %d", test.path, response.StatusCode, test.status) - } - } invalid, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "list_commands", Arguments: map[string]any{"environment": map[string]string{"SECRET": "must-not-be-applied"}}}) if err == nil && !invalid.IsError { t.Fatal("MCP accepted an unknown override field") @@ -322,28 +302,6 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { if !strings.Contains(string(encoded), "agent") || strings.Contains(string(encoded), "must-not-leak") || strings.Contains(string(encoded), string(token)) { t.Fatal("MCP metadata projection failed") } - for _, test := range []struct { - body string - status int - }{ - {`{"command":"http-created","instructions":{"procedures":[{"image":"busybox","command":["true"]}]}}`, 201}, - {`{"command":"http-created","instructions":{"procedures":[{"image":"busybox"}]}}`, 409}, - {`{"command":"invalid","instructions":{"procedures":[]}}`, 400}, - {`{"command":"identity","instructions":{"identity":{},"procedures":[{"image":"busybox"}]}}`, 400}, - {`{"command":"unknown","instructions":{"arbitrary":true}}`, 400}, - } { - request := httptest.NewRequest("POST", "/tools/api/v1/commands", strings.NewReader(test.body)) - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Authorization", "Bearer "+string(token)) - response, err := app.Test(request) - if err != nil { - t.Fatal(err) - } - response.Body.Close() - if response.StatusCode != test.status { - t.Fatalf("HTTP create: expected %d, got %d", test.status, response.StatusCode) - } - } var arguments map[string]any _, example, found := strings.Cut(commandToolDefinitions()[0].Description, "Example: ") if !found || json.Unmarshal([]byte(example), &arguments) != nil { @@ -388,35 +346,31 @@ func TestCommandToolsHTTPAndRealMCPClient(t *testing.T) { if err == nil && !deniedPublish.IsError { t.Fatal("MCP accepted publishing without permission") } - request = httptest.NewRequest("POST", "/tools/api/v1/ui/packages/private/publish", strings.NewReader(`{}`)) - request.Header.Set("Authorization", "Bearer "+string(limitedToken)) - response, err = app.Test(request) - if err != nil { - t.Fatal(err) - } - response.Body.Close() - if response.StatusCode != 403 { - t.Fatal("HTTP accepted publishing without permission") - } deniedCreation, err := limitedSession.CallTool(ctx, &mcp.CallToolParams{Name: "create_command", Arguments: arguments}) if err == nil && !deniedCreation.IsError { t.Fatal("MCP accepted creation without its permission") } - for _, path := range []string{"/tools/api/v1/commands", "/other/api/v1/commands"} { - request := httptest.NewRequest("POST", path, strings.NewReader(`{"command":"denied","instructions":{"procedures":[{"image":"busybox"}]}}`)) + backend.credentials.Delete(token) + if _, err := session.ListTools(ctx, nil); err == nil { + t.Fatal("MCP discovery accepted a revoked credential") + } + // Existing dashboard REST endpoints still work with owner authentication. + scrolls.authorizer = ownerRouteAuth{} + for _, route := range []struct{ method, path string }{ + {"GET", "/tools/api/v1/queue"}, + {"POST", "/tools/api/v1/ui/packages/private/publish"}, + {"POST", "/tools/api/v1/ui/packages/public/publish"}, + } { + request := httptest.NewRequest(route.method, route.path, strings.NewReader(`{}`)) request.Header.Set("Content-Type", "application/json") - request.Header.Set("Authorization", "Bearer "+string(limitedToken)) - response, err := app.Test(request) + request.Header.Set("Authorization", "Bearer owner") + response, err := app.Test(request, -1) if err != nil { t.Fatal(err) } response.Body.Close() - if response.StatusCode != 401 && response.StatusCode != 403 { - t.Fatalf("unauthorized creation returned %d", response.StatusCode) + if response.StatusCode != 200 { + t.Fatalf("owner REST %s: %d", route.path, response.StatusCode) } } - backend.credentials.Delete(token) - if _, err := session.ListTools(ctx, nil); err == nil { - t.Fatal("MCP discovery accepted a revoked credential") - } } diff --git a/apps/druid/adapters/http/handlers/docker_command_tools_test.go b/apps/druid/adapters/http/handlers/docker_command_tools_test.go index 88f1af6e..4ba77a19 100644 --- a/apps/druid/adapters/http/handlers/docker_command_tools_test.go +++ b/apps/druid/adapters/http/handlers/docker_command_tools_test.go @@ -4,7 +4,6 @@ import ( "context" "net/http" "net/http/httptest" - "strings" "testing" "github.com/gofiber/fiber/v2" @@ -38,34 +37,23 @@ func TestDockerToolsAreAnonymousWhileOwnerRoutesRemainProtected(t *testing.T) { } app := fiber.New(fiber.Config{DisableStartupMessage: true}) RegisterPublicRoutes(app, RouteHandlers{Server: NewRuntimeServer(NewHealthHandler(), NewScrollHandler(supervisor)), Websocket: NewWebsocketHandler()}) - for _, id := range []string{"one", "other"} { - for _, path := range []string{"commands", "queue"} { - response, err := app.Test(httptest.NewRequest("GET", "/"+id+"/api/v1/"+path, nil)) - if err != nil { - t.Fatal(err) - } - response.Body.Close() - if response.StatusCode != 200 { - t.Fatalf("anonymous Docker %s: %d", path, response.StatusCode) - } + for _, path := range []string{"/one/api/v1/scroll", "/one/api/v1/queue", "/other/api/v1/queue"} { + response, err := app.Test(httptest.NewRequest("GET", path, nil)) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 401 { + t.Fatalf("owner REST route %s must remain protected: %d", path, response.StatusCode) } } - // The shared publish route must reach validation, not owner authentication. - response, err := app.Test(httptest.NewRequest("POST", "/one/api/v1/ui/packages/invalid/publish", strings.NewReader("{}"))) - if err != nil { - t.Fatal(err) - } - response.Body.Close() - if response.StatusCode != 400 { - t.Fatalf("Docker publish unexpectedly requires credentials: %d", response.StatusCode) - } - response, err = app.Test(httptest.NewRequest("GET", "/one/api/v1/scroll", nil)) + response, err := app.Test(httptest.NewRequest("POST", "/one/api/v1/ui/packages/private/publish", nil)) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != 401 { - t.Fatal("Docker tool exemption reached an unrelated owner route") + t.Fatal("Docker MCP exemption reached owner REST publication") } server := httptest.NewServer(adaptor.FiberApp(app)) defer server.Close() diff --git a/apps/druid/adapters/http/handlers/routes.go b/apps/druid/adapters/http/handlers/routes.go index e0bcdbfd..fdbf4855 100644 --- a/apps/druid/adapters/http/handlers/routes.go +++ b/apps/druid/adapters/http/handlers/routes.go @@ -64,9 +64,7 @@ func RegisterPublicRoutes(app *fiber.App, handlers RouteHandlers) { app.Get("/.well-known/jwks.json", RuntimeJWKS(authorizer)) app.Get("/:id/ws/v1/serve/:console", websocket.New(handlers.Websocket.AttachScrollConsole)) if handlers.Server != nil && handlers.Server.ScrollHandler != nil { - handlers.Server.ScrollHandler.registerCommandTools(app) - } - if handlers.Server != nil && handlers.Server.ScrollHandler != nil { + handlers.Server.ScrollHandler.registerCommandMCP(app) app.Use("/:id", handlers.Server.PublicAuth) } app.Get("/:id/api/v1/health", handlers.Server.GetHealthAuth) diff --git a/apps/druid/core/services/command_tools.go b/apps/druid/core/services/command_tools.go index 5bb2262d..22ef3d80 100644 --- a/apps/druid/core/services/command_tools.go +++ b/apps/druid/core/services/command_tools.go @@ -44,7 +44,7 @@ type ToolError struct { func (e *ToolError) Error() string { return e.Message } func toolError(status int, message string) error { return &ToolError{Status: status, Message: message} } -// CallCommandTool is the single permission/execution seam shared by HTTP and MCP. +// CallCommandTool is the permission/execution seam for MCP tools. func (s *RuntimeSupervisor) CallCommandTool(ctx context.Context, token, deployment, name string, input ToolInput) (any, error) { record, err := s.AuthenticateCommand(ctx, token, deployment) if err != nil { diff --git a/apps/druid/core/services/runtime_identity.go b/apps/druid/core/services/runtime_identity.go index 52eb0d4a..358eda26 100644 --- a/apps/druid/core/services/runtime_identity.go +++ b/apps/druid/core/services/runtime_identity.go @@ -51,13 +51,9 @@ func (s *RuntimeSession) assignExecutionIdentity(command, execution string) erro return s.store.UpdateScroll(s.runtimeScroll) } -// Docker tools are deliberately unauthenticated: trusted local development only. -func (s *RuntimeSupervisor) CommandToolsRequireIdentity() bool { - return s.runtimeBackend.Name() != "docker" -} - func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token string, deployment string) (workloadidentity.Record, error) { - if !s.CommandToolsRequireIdentity() { + // Docker MCP tools are deliberately unauthenticated: trusted local use only. + if s.runtimeBackend.Name() == "docker" { record := workloadidentity.Record{DeploymentID: deployment} for _, permission := range ToolPermissions { record.Permissions = append(record.Permissions, permission) diff --git a/test/integration/workload_identity_test.go b/test/integration/workload_identity_test.go index ea18196f..34916d6f 100644 --- a/test/integration/workload_identity_test.go +++ b/test/integration/workload_identity_test.go @@ -125,7 +125,7 @@ commands: procedures: - id: agent-console image: node:22-alpine - command: [node, -e, 'const fs=require("node:fs"); const headers={}; if(process.env.DRUID_TOKEN_FILE){headers.Authorization="Bearer "+fs.readFileSync(process.env.DRUID_TOKEN_FILE,"utf8"); try{fs.writeFileSync(process.env.DRUID_TOKEN_FILE,"bad");process.exit(9)}catch{}} fetch(process.env.DRUID_API_URL+"/api/v1/commands", {headers}).then(r=>{if(!r.ok)process.exit(8); console.log("MOUNT_API_OK")}).catch(()=>process.exit(7)); require("node:readline").createInterface({input:process.stdin}).on("line",line=>console.log("INPUT:"+line)); console.log("READY");'] + command: [node, -e, 'const fs=require("node:fs"); const headers={}; if(process.env.DRUID_TOKEN_FILE){headers.Authorization="Bearer "+fs.readFileSync(process.env.DRUID_TOKEN_FILE,"utf8"); try{fs.writeFileSync(process.env.DRUID_TOKEN_FILE,"bad");process.exit(9)}catch{}} fetch(process.env.DRUID_API_URL+"/mcp", {method:"POST",headers:{...headers,"Content-Type":"application/json",Accept:"application/json, text/event-stream"},body:JSON.stringify({jsonrpc:"2.0",id:1,method:"initialize",params:{protocolVersion:"2025-03-26",capabilities:{},clientInfo:{name:"mount-fixture",version:"1"}}})}).then(async r=>{if(!r.ok||!(await r.json()).result)process.exit(8); console.log("MOUNT_MCP_OK")}).catch(()=>process.exit(7)); require("node:readline").createInterface({input:process.stdin}).on("line",line=>console.log("INPUT:"+line)); console.log("READY");'] `} if err := runtime.Store.CreateScroll(scroll); err != nil { t.Fatal(err) @@ -229,13 +229,13 @@ commands: call := func(name string, input appservices.ToolInput) (any, error) { return supervisor.CallCommandTool(ctx, token, id, name, input) } - eventually("read-only mount and workload-to-host HTTP", func() bool { + eventually("read-only mount and workload-to-host MCP", func() bool { result, err := call("read_logs", appservices.ToolInput{Console: "agent-console"}) if err != nil { return false } data, _ := json.Marshal(result) - return bytes.Contains(data, []byte("MOUNT_API_OK")) + return bytes.Contains(data, []byte("MOUNT_MCP_OK")) }) client := mcp.NewClient(&mcp.Implementation{Name: "runtime-integration", Version: "1"}, nil) connect := func() *mcp.ClientSession { @@ -409,5 +409,5 @@ commands: if _, err := supervisor.AuthenticateCommand(ctx, oldToken, id); kubernetes != nil && err == nil { t.Fatal("old credential resurrected after replacement") } - t.Log("verified runtime HTTP/MCP, tool discovery, command creation/execution, stdin/logs, completed logs, disconnection, daemon reattachment, and backend-specific authentication") + t.Log("verified runtime MCP, tool discovery, command creation/execution, stdin/logs, completed logs, disconnection, daemon reattachment, and backend-specific authentication") } From 27d45c0c7eaae69af9eaa9be77adb42ceb8d2f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 12:50:35 +0100 Subject: [PATCH 07/14] fix(runtime): keep command updates explicit Updating a stored command definition must not implicitly stop its current execution or discard queue/status state. Pod grants are immutable for the running execution; stop and restart remain explicit operations. MCP create_command continues rejecting existing command names. --- apps/druid/core/services/runtime_commands.go | 13 +-------- apps/druid/core/services/runtime_dev_test.go | 30 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/druid/core/services/runtime_commands.go b/apps/druid/core/services/runtime_commands.go index 61ee0f6c..427724ff 100644 --- a/apps/druid/core/services/runtime_commands.go +++ b/apps/druid/core/services/runtime_commands.go @@ -2,7 +2,6 @@ package services import ( "fmt" - "reflect" "strings" "github.com/highcard-dev/daemon/internal/core/domain" @@ -41,7 +40,7 @@ func (s *RuntimeSession) AddCommand(command string, instruction *domain.CommandI return fmt.Errorf("command instruction is required") } s.mu.Lock() - existing := s.scrollService.GetFile().Commands[command] + defer s.mu.Unlock() file := *s.scrollService.GetFile() file.Commands = make(map[string]*domain.CommandInstructionSet, len(s.scrollService.GetFile().Commands)+1) for name, existing := range s.scrollService.GetFile().Commands { @@ -51,7 +50,6 @@ func (s *RuntimeSession) AddCommand(command string, instruction *domain.CommandI file.Commands = map[string]*domain.CommandInstructionSet{} } file.Commands[command] = instruction - s.mu.Unlock() data, err := yaml.Marshal(file) if err != nil { return err @@ -60,15 +58,6 @@ func (s *RuntimeSession) AddCommand(command string, instruction *domain.CommandI if err != nil { return err } - // A changed definition is a replacement, never an upgrade of the existing - // execution's permissions. Validate first, then stop and revoke that run. - if existing != nil && !reflect.DeepEqual(existing, instruction) { - if err := s.RemoveCommand(command); err != nil { - return err - } - } - s.mu.Lock() - defer s.mu.Unlock() s.runtimeScroll.ScrollYAML = string(data) if err := s.store.UpdateScroll(s.runtimeScroll); err != nil { return err diff --git a/apps/druid/core/services/runtime_dev_test.go b/apps/druid/core/services/runtime_dev_test.go index 6a21c6ee..84dc34fe 100644 --- a/apps/druid/core/services/runtime_dev_test.go +++ b/apps/druid/core/services/runtime_dev_test.go @@ -1,6 +1,7 @@ package services import ( + "errors" "strings" "testing" "unsafe" @@ -8,6 +9,35 @@ import ( "github.com/highcard-dev/daemon/internal/core/domain" ) +type rejectImplicitStopBackend struct{ *fakeWorkerBackend } + +func (*rejectImplicitStopBackend) StopCommand(string, string) error { + return errors.New("AddCommand must not stop a running command") +} + +func TestAddCommandUpdatesDefinitionWithoutStoppingExecution(t *testing.T) { + status := domain.LockStatus{ExecutionID: "current-run", Status: domain.ScrollLockStatusRunning} + session := newRuntimeSessionForTest(t, map[string]domain.LockStatus{"start": status}, cachedScrollYAML("")) + session.runtimeBackend = &rejectImplicitStopBackend{&fakeWorkerBackend{}} + queued := &runtimeQueueItem{inFlight: true} + session.queue["start"] = queued + instruction := &domain.CommandInstructionSet{Procedures: []*domain.Procedure{{Image: "busybox:1.37.0", Command: []string{"true"}}}} + if err := session.AddCommand("start", instruction); err != nil { + t.Fatal(err) + } + if session.queue["start"] != queued || session.Snapshot()["start"]["start.0"] != status { + t.Fatal("updating the definition changed the current execution") + } + persisted, err := session.store.GetScroll(session.runtimeScroll.ID) + if err != nil { + t.Fatal(err) + } + scroll, err := domain.NewScrollFromBytes(persisted.Root, []byte(persisted.ScrollYAML)) + if err != nil || scroll.Commands["start"].Procedures[0].Image != "busybox:1.37.0" { + t.Fatal("updated definition was not persisted") + } +} + func TestAddCommandOwnsBorrowedCommandName(t *testing.T) { session := newRuntimeSessionForTest(t, map[string]domain.LockStatus{}, cachedScrollYAML("")) buffer := []byte("init_private") From 09ca2b0fc7c3c07bd071fdafd22ca14ae20deff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 12:50:36 +0100 Subject: [PATCH 08/14] refactor(auth): remove obsolete identity state Remove recovery-only flags, duplicate queue execution IDs, unused workload environment metadata and the no-op runtime closer. Narrow authenticated identity to deployment binding and permissions; retain Pod ownership checks and execution tracking independently. Authorize a bounded command wait at acceptance, not on every poll. An accepted wait can finish after revocation within 30 seconds; subsequent requests still require current credentials. Cover both behaviors and hydration without legacy recovery flags. --- README.md | 4 ++ apps/druid/adapters/cli/daemon.go | 1 - .../http/handlers/command_tools_test.go | 12 ++--- apps/druid/core/services/command_tools.go | 11 +---- .../druid/core/services/command_tools_test.go | 47 +++++++++++++++---- apps/druid/core/services/runtime_identity.go | 10 ++-- .../core/services/runtime_identity_test.go | 34 ++++++++++++-- apps/druid/core/services/runtime_lifecycle.go | 4 +- .../services/runtime_session_execution.go | 9 +--- .../core/services/runtime_session_queue.go | 21 ++------- internal/core/ports/command_runtime.go | 4 +- internal/runtime/backend.go | 8 ---- .../kubernetes/command_authentication_test.go | 10 ++-- .../runtime/kubernetes/command_identity.go | 19 ++++---- internal/runtime/kubernetes/resources.go | 1 - internal/workloadidentity/identity.go | 13 ++--- test/integration/agent_ui_test.go | 1 - test/integration/workload_identity_test.go | 2 - 18 files changed, 114 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index d6017f0e..e09820ef 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ Runtime selection is daemon-only: start the daemon with `druid daemon --runtime Agent tools are exposed only at `/:id/mcp`. Existing REST endpoints retain owner authentication; there are no parallel agent REST routes. +Kubernetes authorizes each tool request when it is accepted. An accepted command +wait may finish after its caller's credential is revoked (at most 30 seconds); +subsequent requests must pass authentication again. + **Docker agent-tool caveat:** MCP tool endpoints on the Docker runtime are unauthenticated and ignore command permissions. Anyone who can reach them can invoke every tool for any deployment, including command creation/execution, diff --git a/apps/druid/adapters/cli/daemon.go b/apps/druid/adapters/cli/daemon.go index f74d5561..5a4a3098 100644 --- a/apps/druid/adapters/cli/daemon.go +++ b/apps/druid/adapters/cli/daemon.go @@ -144,7 +144,6 @@ func runRuntimeDaemon() error { if err != nil { return err } - defer runtime.Close() manager := services.NewRuntimeScrollManager(runtime.Store) supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, manager, runtime) if err != nil { diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go index 5b3b228b..5deb0ae9 100644 --- a/apps/druid/adapters/http/handlers/command_tools_test.go +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -108,14 +108,14 @@ type toolRouteBackend struct { uploadError error } -func (b *toolRouteBackend) PrepareCommandIdentity(context.Context, workloadidentity.Record) error { +func (b *toolRouteBackend) PrepareCommandIdentity(context.Context, string) error { return nil } -func (b *toolRouteBackend) AuthenticateCommandIdentity(_ context.Context, token string) (workloadidentity.Record, error) { +func (b *toolRouteBackend) AuthenticateCommandIdentity(_ context.Context, token string) (workloadidentity.Identity, error) { if record, ok := b.credentials.Load(token); ok { - return record.(workloadidentity.Record), nil + return record.(workloadidentity.Identity), nil } - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized } func (b *toolRouteBackend) Name() string { return "fixture" } func (b *toolRouteBackend) RunCommand(ports.RuntimeCommand) (*int, error) { return nil, nil } @@ -156,7 +156,7 @@ func TestCommandToolsRealMCPClient(t *testing.T) { t.Fatal(err) } backend := &toolRouteBackend{} - backend.credentials.Store(token, workloadidentity.Record{ExecutionID: "execution", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list", "commands:create", "queue:read", "ui:publish", "logs:read"}}) + backend.credentials.Store(token, workloadidentity.Identity{DeploymentID: "tools", Permissions: []string{"commands:list", "commands:create", "queue:read", "ui:publish", "logs:read"}}) supervisor, err := appservices.NewRuntimeSupervisor(store, coreservices.NewRuntimeScrollManager(store), ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) if err != nil { t.Fatal(err) @@ -332,7 +332,7 @@ func TestCommandToolsRealMCPClient(t *testing.T) { } // A credential with only commands:list must neither discover nor call creation. limitedToken := "limited-token" - backend.credentials.Store(limitedToken, workloadidentity.Record{ExecutionID: "limited", DeploymentID: "tools", Command: "agent", Permissions: []string{"commands:list"}}) + backend.credentials.Store(limitedToken, workloadidentity.Identity{DeploymentID: "tools", Permissions: []string{"commands:list"}}) limitedSession, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: server.URL + "/tools/mcp", HTTPClient: &http.Client{Transport: authenticatedTransport{string(limitedToken)}}}, nil) if err != nil { t.Fatal(err) diff --git a/apps/druid/core/services/command_tools.go b/apps/druid/core/services/command_tools.go index 22ef3d80..6219a0e9 100644 --- a/apps/druid/core/services/command_tools.go +++ b/apps/druid/core/services/command_tools.go @@ -97,7 +97,7 @@ func (s *RuntimeSupervisor) CallCommandTool(ctx context.Context, token, deployme case "run_command": return s.enqueueToolCommand(deployment, input.Command) case "wait_for_command": - return s.waitToolCommand(ctx, token, deployment, input) + return s.waitToolCommand(ctx, deployment, input) case "get_deployment_status": runtime, err := s.Get(deployment) if err != nil { @@ -175,7 +175,7 @@ func (s *RuntimeSupervisor) enqueueToolCommand(deployment, command string) (any, return map[string]any{"command": command, "execution_id": execution, "accepted": true}, nil } -func (s *RuntimeSupervisor) waitToolCommand(ctx context.Context, token, deployment string, input ToolInput) (any, error) { +func (s *RuntimeSupervisor) waitToolCommand(ctx context.Context, deployment string, input ToolInput) (any, error) { if input.Command == "" || input.ExecutionID == "" { return nil, toolError(400, "command and execution_id are required") } @@ -188,13 +188,6 @@ func (s *RuntimeSupervisor) waitToolCommand(ctx context.Context, token, deployme } deadline := time.Now().Add(time.Duration(timeout) * time.Second) for { - record, err := s.AuthenticateCommand(ctx, token, deployment) - if err != nil { - return nil, err - } - if !record.Allows("queue:read") { - return nil, toolError(403, "workload permission denied") - } queue, err := s.Queue(deployment) if err != nil { return nil, err diff --git a/apps/druid/core/services/command_tools_test.go b/apps/druid/core/services/command_tools_test.go index fdb388b4..f93e96e9 100644 --- a/apps/druid/core/services/command_tools_test.go +++ b/apps/druid/core/services/command_tools_test.go @@ -6,7 +6,9 @@ import ( "errors" "strings" "sync" + "sync/atomic" "testing" + "time" "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/ports" @@ -179,19 +181,21 @@ func TestPublishUIToolPermissionsProjectionAndPaths(t *testing.T) { type toolTestBackend struct { *fakeWorkerBackend - credentials sync.Map - logs string - input string + credentials sync.Map + authentications atomic.Int32 + logs string + input string } -func (b *toolTestBackend) PrepareCommandIdentity(context.Context, workloadidentity.Record) error { +func (b *toolTestBackend) PrepareCommandIdentity(context.Context, string) error { return nil } -func (b *toolTestBackend) AuthenticateCommandIdentity(_ context.Context, token string) (workloadidentity.Record, error) { +func (b *toolTestBackend) AuthenticateCommandIdentity(_ context.Context, token string) (workloadidentity.Identity, error) { if record, ok := b.credentials.Load(token); ok { - return record.(workloadidentity.Record), nil + b.authentications.Add(1) + return record.(workloadidentity.Identity), nil } - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized } func (b *toolTestBackend) ReadConsoleLogs(_ context.Context, _, _ string, _ int, _ int64) (string, bool, error) { return b.logs, false, nil @@ -239,7 +243,7 @@ commands: supervisor.sessions[runtime.ID] = session t.Cleanup(session.stopDeploymentQueue) token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln" - backend.credentials.Store(token, workloadidentity.Record{ExecutionID: "agent-execution", DeploymentID: "tools", Command: "agent", Permissions: permissions}) + backend.credentials.Store(token, workloadidentity.Identity{DeploymentID: "tools", Permissions: permissions}) return supervisor, backend, token } @@ -310,6 +314,33 @@ func TestCommandToolsEnqueueWaitAndReplacedExecution(t *testing.T) { } } +func TestCommandWaitAuthenticatesOnceAndRejectsSubsequentRevokedRequests(t *testing.T) { + supervisor, backend, token := commandToolsFixture(t, "queue:read") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := supervisor.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "agent", ExecutionID: "agent-execution", TimeoutSeconds: 1}) + done <- err + }() + for backend.authentications.Load() == 0 { + if ctx.Err() != nil { + t.Fatal("wait was not accepted") + } + time.Sleep(time.Millisecond) + } + backend.credentials.Delete(token) + // Keep the wait pending beyond a poll, then complete its accepted execution. + time.Sleep(150 * time.Millisecond) + supervisor.sessions["tools"].SetCommandStatus("agent", domain.ScrollLockStatusDone, nil) + if err := <-done; err != nil || backend.authentications.Load() != 1 { + t.Fatalf("accepted wait repeated authentication: %v", err) + } + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "get_queue", ToolInput{}); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("new request accepted a revoked credential") + } +} + func TestLateProcedureEventCannotOverwriteReplacementExecution(t *testing.T) { supervisor, _, _ := commandToolsFixture(t) session := supervisor.sessions["tools"] diff --git a/apps/druid/core/services/runtime_identity.go b/apps/druid/core/services/runtime_identity.go index 358eda26..c428715d 100644 --- a/apps/druid/core/services/runtime_identity.go +++ b/apps/druid/core/services/runtime_identity.go @@ -51,10 +51,10 @@ func (s *RuntimeSession) assignExecutionIdentity(command, execution string) erro return s.store.UpdateScroll(s.runtimeScroll) } -func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token string, deployment string) (workloadidentity.Record, error) { +func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token string, deployment string) (workloadidentity.Identity, error) { // Docker MCP tools are deliberately unauthenticated: trusted local use only. if s.runtimeBackend.Name() == "docker" { - record := workloadidentity.Record{DeploymentID: deployment} + record := workloadidentity.Identity{DeploymentID: deployment} for _, permission := range ToolPermissions { record.Permissions = append(record.Permissions, permission) } @@ -62,14 +62,14 @@ func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token strin } backend, ok := s.runtimeBackend.(ports.CommandIdentityBackend) if !ok { - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized } record, err := backend.AuthenticateCommandIdentity(ctx, token) if err != nil { - return workloadidentity.Record{}, err + return workloadidentity.Identity{}, err } if record.DeploymentID != deployment { - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized } return record, nil } diff --git a/apps/druid/core/services/runtime_identity_test.go b/apps/druid/core/services/runtime_identity_test.go index 0f0c4663..ec2583ab 100644 --- a/apps/druid/core/services/runtime_identity_test.go +++ b/apps/druid/core/services/runtime_identity_test.go @@ -5,21 +5,47 @@ import ( "errors" "testing" + "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/ports" "github.com/highcard-dev/daemon/internal/workloadidentity" ) +func TestHydratedIdentityCommandUsesExecutionStatusWithoutRecoveryFlags(t *testing.T) { + for _, execution := range []string{"existing-run", ""} { + t.Run("execution="+execution, func(t *testing.T) { + supervisor, backend, _ := commandToolsFixture(t) + session := supervisor.sessions["tools"] + session.workloadAPIURL = "http://runtime.local" + session.runtimeScroll.Procedures["agent"]["agent-console"] = domain.LockStatus{ExecutionID: execution, Status: domain.ScrollLockStatusRunning} + if err := session.HydrateFromState(session.Snapshot()); err != nil { + t.Fatal(err) + } + var seen ports.RuntimeCommand + backend.runCommand = func(command ports.RuntimeCommand) (*int, error) { seen = command; return nil, nil } + if err := session.runCommand("agent"); err != nil { + t.Fatal(err) + } + if seen.ExecutionID == "" || (execution != "" && seen.ExecutionID != execution) || !seen.Command.Procedures[0].RuntimeIdentity.Enabled { + t.Fatal("hydration lost execution identity or token projection") + } + if _, present := seen.ProcedureEnv["agent-console"]["DRUID_EXECUTION_ID"]; present { + t.Fatal("unused execution metadata leaked into workload environment") + } + }) + } +} + type podIdentityBackend struct { ports.RuntimeBackendInterface - record workloadidentity.Record + record workloadidentity.Identity name string } -func (b podIdentityBackend) AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Record, error) { +func (b podIdentityBackend) AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Identity, error) { return b.record, nil } -func (b podIdentityBackend) PrepareCommandIdentity(context.Context, workloadidentity.Record) error { +func (b podIdentityBackend) PrepareCommandIdentity(context.Context, string) error { return nil } func (b podIdentityBackend) Name() string { @@ -30,7 +56,7 @@ func (b podIdentityBackend) Name() string { } func TestCommandAuthenticationNeedsNoDaemonState(t *testing.T) { - backend := podIdentityBackend{record: workloadidentity.Record{DeploymentID: "deployment", Permissions: []string{"commands:list"}}} + backend := podIdentityBackend{record: workloadidentity.Identity{DeploymentID: "deployment", Permissions: []string{"commands:list"}}} supervisor, err := NewRuntimeSupervisor(nil, nil, ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) if err != nil { t.Fatal(err) diff --git a/apps/druid/core/services/runtime_lifecycle.go b/apps/druid/core/services/runtime_lifecycle.go index b4451be2..e6870c30 100644 --- a/apps/druid/core/services/runtime_lifecycle.go +++ b/apps/druid/core/services/runtime_lifecycle.go @@ -1,8 +1,6 @@ package services -import ( - "github.com/highcard-dev/daemon/internal/core/domain" -) +import "github.com/highcard-dev/daemon/internal/core/domain" func (s *RuntimeSupervisor) Delete(id string) error { return s.DeleteWithPolicy(id, false) diff --git a/apps/druid/core/services/runtime_session_execution.go b/apps/druid/core/services/runtime_session_execution.go index fe0fbc57..f0c5d149 100644 --- a/apps/druid/core/services/runtime_session_execution.go +++ b/apps/druid/core/services/runtime_session_execution.go @@ -75,9 +75,6 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin } requiresIdentity := s.runtimeBackend.Name() != "docker" executionID := "" - s.queueMu.Lock() - recovering := s.queue[cmd] != nil && s.queue[cmd].recovered - s.queueMu.Unlock() for _, status := range s.Snapshot()[cmd] { if status.ExecutionID != "" { executionID = status.ExecutionID @@ -85,9 +82,6 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin } } if executionID == "" { - if recovering && command.Identity != nil && requiresIdentity { - return fmt.Errorf("identity-enabled command requires a fresh execution") - } executionID = uuid.NewString() if err := s.assignExecutionIdentity(cmd, executionID); err != nil { return err @@ -99,7 +93,7 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin if !ok || s.workloadAPIURL == "" { return fmt.Errorf("command identity requires runtime support and workload API URL") } - if err := backend.PrepareCommandIdentity(context.Background(), workloadidentity.Record{RuntimeRoot: root}); err != nil { + if err := backend.PrepareCommandIdentity(context.Background(), root); err != nil { return err } identity.Enabled = true @@ -116,7 +110,6 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin if procedureEnv[name] == nil { procedureEnv[name] = map[string]string{} } - procedureEnv[name]["DRUID_EXECUTION_ID"] = executionID if s.workloadAPIURL != "" && (command.Identity != nil || !requiresIdentity) { procedureEnv[name]["DRUID_TOKEN_FILE"] = "" if requiresIdentity { diff --git a/apps/druid/core/services/runtime_session_queue.go b/apps/druid/core/services/runtime_session_queue.go index 2b241488..7c851ff5 100644 --- a/apps/druid/core/services/runtime_session_queue.go +++ b/apps/druid/core/services/runtime_session_queue.go @@ -13,8 +13,6 @@ import ( ) type runtimeQueueItem struct { - recovered bool - executionID string err error doneChan chan struct{} inFlight bool @@ -100,8 +98,8 @@ func (s *RuntimeSession) enqueueExecution(cmd string, options coreservices.AddIt if options.Wait { doneChan = make(chan struct{}) } - item = &runtimeQueueItem{doneChan: doneChan, procedureEnv: procedureEnv, executionID: executionID} - if err := s.beginExecution(cmd, item.executionID); err != nil { + item = &runtimeQueueItem{doneChan: doneChan, procedureEnv: procedureEnv} + if err := s.beginExecution(cmd, executionID); err != nil { s.queueMu.Unlock() return nil, err } @@ -164,13 +162,6 @@ func (s *RuntimeSession) rememberHydratedItem(cmd string) { item.err = nil item.rememberDone = false item.runRequested = true - item.recovered = true - for _, status := range s.Snapshot()[cmd] { - if status.ExecutionID != "" { - item.executionID = status.ExecutionID - break - } - } } func (s *RuntimeSession) triggerRunQueue() { @@ -294,12 +285,10 @@ func (s *RuntimeSession) RunQueue() { logger.Log().Info("Persistent command running", zap.String("command", c)) } else if isRestartMode { s.queueMu.Lock() - i.executionID = uuid.NewString() - i.recovered = false - identityErr := s.beginExecution(c, i.executionID) + executionErr := s.beginExecution(c, uuid.NewString()) s.queueMu.Unlock() - if identityErr != nil { - s.setQueueError(c, identityErr) + if executionErr != nil { + s.setQueueError(c, executionErr) return } s.setQueueStatus(c, domain.ScrollLockStatusWaiting, nil) diff --git a/internal/core/ports/command_runtime.go b/internal/core/ports/command_runtime.go index 3682dde5..3b18d7d1 100644 --- a/internal/core/ports/command_runtime.go +++ b/internal/core/ports/command_runtime.go @@ -8,8 +8,8 @@ import ( // CommandIdentityBackend is implemented by runtimes that authenticate command workloads. // It does not replace infrastructure ServiceAccount authentication. type CommandIdentityBackend interface { - PrepareCommandIdentity(context.Context, workloadidentity.Record) error - AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Record, error) + PrepareCommandIdentity(context.Context, string) error + AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Identity, error) } type CommandConsoleBackend interface { diff --git a/internal/runtime/backend.go b/internal/runtime/backend.go index 477fb665..144672b0 100644 --- a/internal/runtime/backend.go +++ b/internal/runtime/backend.go @@ -2,7 +2,6 @@ package runtime import ( "fmt" - "io" "github.com/highcard-dev/daemon/internal/core/ports" "github.com/highcard-dev/daemon/internal/runtime/docker" @@ -15,13 +14,6 @@ type Runtime struct { create ports.RuntimeBackendFactoryFunc } -func (r *Runtime) Close() error { - if closer, ok := r.Backend.(io.Closer); ok { - return closer.Close() - } - return nil -} - type Options struct { Kubernetes runtimekubernetes.Config Docker docker.Config diff --git a/internal/runtime/kubernetes/command_authentication_test.go b/internal/runtime/kubernetes/command_authentication_test.go index ee3c2c69..f6319480 100644 --- a/internal/runtime/kubernetes/command_authentication_test.go +++ b/internal/runtime/kubernetes/command_authentication_test.go @@ -48,7 +48,7 @@ func TestCommandServiceAccountAuthentication(t *testing.T) { {name: "worker pod", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Labels[labelComponent] = "worker-pull" }}, {name: "missing deployment", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { delete(p.Annotations, commandDeploymentAnnotation) }}, {name: "mismatched command", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Annotations[labelCommand] = "other" }}, - {name: "missing execution", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { delete(p.Labels, workloadidentity.ExecutionLabel) }}, + {name: "no execution label", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { delete(p.Labels, workloadidentity.ExecutionLabel) }}, {name: "wrong runtime root", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Spec.Volumes[0].PersistentVolumeClaim.ClaimName = "other-data" }}, @@ -82,9 +82,9 @@ func TestCommandServiceAccountAuthentication(t *testing.T) { }) backend := NewWithClient(Config{Namespace: "default"}, client) record, err := backend.AuthenticateCommandIdentity(context.Background(), "credential") - if test.name == "valid" || test.name == "no grants" { - granted := test.name == "valid" - if err != nil || record.DeploymentID != "full-deployment-id" || record.Command != "agent_command" || record.RuntimeRoot != ref("games", "data") || record.ExecutionID != "execution" || record.Allows("commands:list") != granted || record.Allows("logs:read") != granted || record.Allows("commands:run") { + if test.name == "valid" || test.name == "no grants" || test.name == "no execution label" { + granted := test.name != "no grants" + if err != nil || record.DeploymentID != "full-deployment-id" || record.Allows("commands:list") != granted || record.Allows("logs:read") != granted || record.Allows("commands:run") { t.Fatalf("record=%+v err=%v", record, err) } // No cache or registry is required after daemon reconstruction. @@ -108,7 +108,7 @@ func TestCommandIdentityProvisioningOnlyCreatesAnUnprivilegedServiceAccount(t *t client := fake.NewSimpleClientset() backend := NewWithClient(Config{Namespace: "default"}, client) for index := 0; index < 2; index++ { - if err := backend.PrepareCommandIdentity(context.Background(), workloadidentity.Record{RuntimeRoot: ref("games", "data")}); err != nil { + if err := backend.PrepareCommandIdentity(context.Background(), ref("games", "data")); err != nil { t.Fatal(err) } } diff --git a/internal/runtime/kubernetes/command_identity.go b/internal/runtime/kubernetes/command_identity.go index c4590ee6..908e5b71 100644 --- a/internal/runtime/kubernetes/command_identity.go +++ b/internal/runtime/kubernetes/command_identity.go @@ -21,32 +21,31 @@ func commandIdentityAnnotations(identity *domain.RuntimeCommandIdentity, command return map[string]string{commandDeploymentAnnotation: identity.DeploymentID, labelCommand: command, commandPermissionsAnnotation: strings.Join(identity.Permissions, " ")} } -func (b *Backend) PrepareCommandIdentity(ctx context.Context, record workloadidentity.Record) error { - namespace, _, err := parseRef(record.RuntimeRoot) +func (b *Backend) PrepareCommandIdentity(ctx context.Context, root string) error { + namespace, _, err := parseRef(root) if err == nil { err = b.ensureRuntimeServiceAccount(ctx, namespace, commandServiceAccount) } return err } -func (b *Backend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Record, error) { +func (b *Backend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Identity, error) { pod, err := b.authenticateBoundPod(ctx, token, commandTokenAudience) if err != nil { - return workloadidentity.Record{}, err + return workloadidentity.Identity{}, err } deployment, command := pod.Annotations[commandDeploymentAnnotation], pod.Annotations[labelCommand] - execution := pod.Labels[workloadidentity.ExecutionLabel] - if pod.Spec.ServiceAccountName != commandServiceAccount || pod.DeletionTimestamp != nil || (pod.Status.Phase != corev1.PodRunning && pod.Status.Phase != corev1.PodPending) || pod.Labels[labelManagedBy] != "druid" || pod.Labels[labelComponent] != "runtime" || deployment == "" || command == "" || execution == "" || pod.Labels[labelCommand] != dnsLabel(command) { - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + if pod.Spec.ServiceAccountName != commandServiceAccount || pod.DeletionTimestamp != nil || (pod.Status.Phase != corev1.PodRunning && pod.Status.Phase != corev1.PodPending) || pod.Labels[labelManagedBy] != "druid" || pod.Labels[labelComponent] != "runtime" || deployment == "" || command == "" || pod.Labels[labelCommand] != dnsLabel(command) { + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized } for _, volume := range pod.Spec.Volumes { if volume.Name == "data" && volume.PersistentVolumeClaim != nil && pod.Labels[labelScrollID] == dnsLabel(volume.PersistentVolumeClaim.ClaimName) { root := ref(pod.Namespace, volume.PersistentVolumeClaim.ClaimName) if pod.Labels[labelRuntimeID] != dnsLabel(runtimeID(root)) { - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized } - return workloadidentity.Record{RuntimeRoot: root, DeploymentID: deployment, Command: command, ExecutionID: execution, Permissions: strings.Fields(pod.Annotations[commandPermissionsAnnotation])}, nil + return workloadidentity.Identity{DeploymentID: deployment, Permissions: strings.Fields(pod.Annotations[commandPermissionsAnnotation])}, nil } } - return workloadidentity.Record{}, workloadidentity.ErrUnauthorized + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized } diff --git a/internal/runtime/kubernetes/resources.go b/internal/runtime/kubernetes/resources.go index a8c619e8..33e01869 100644 --- a/internal/runtime/kubernetes/resources.go +++ b/internal/runtime/kubernetes/resources.go @@ -369,7 +369,6 @@ func applyCommandIdentity(pod *corev1.PodSpec, labels map[string]string, identit return } pod.ServiceAccountName = commandServiceAccount - pod.AutomountServiceAccountToken = ptrBool(false) expiration := int64(3600) pod.Volumes = append(pod.Volumes, corev1.Volume{Name: "command-identity", VolumeSource: corev1.VolumeSource{Projected: &corev1.ProjectedVolumeSource{Sources: []corev1.VolumeProjection{{ServiceAccountToken: &corev1.ServiceAccountTokenProjection{Audience: commandTokenAudience, ExpirationSeconds: &expiration, Path: "token"}}}}}}) mount := corev1.VolumeMount{Name: "command-identity", MountPath: "/run/secrets/druid", ReadOnly: true} diff --git a/internal/workloadidentity/identity.go b/internal/workloadidentity/identity.go index 480ed676..4c80f0c1 100644 --- a/internal/workloadidentity/identity.go +++ b/internal/workloadidentity/identity.go @@ -1,4 +1,4 @@ -// Package workloadidentity implements command-execution credentials, distinct +// Package workloadidentity defines command-workload authorization, distinct // from owner JWTs and infrastructure Kubernetes ServiceAccount identities. package workloadidentity @@ -14,15 +14,12 @@ var ErrUnauthorized = errors.New("invalid workload credential") var ErrUnavailable = errors.New("workload identity verification is unavailable") var tokenPattern = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`) -type Record struct { - RuntimeRoot string `json:"-"` // Backend runtime binding; not a daemon-session dependency. - ExecutionID string `json:"execution_id"` - DeploymentID string `json:"deployment_id"` - Command string `json:"command"` - Permissions []string `json:"permissions"` +type Identity struct { + DeploymentID string + Permissions []string } -func (r Record) Allows(permission string) bool { +func (r Identity) Allows(permission string) bool { for _, allowed := range r.Permissions { if allowed == permission { return true diff --git a/test/integration/agent_ui_test.go b/test/integration/agent_ui_test.go index 6ae45489..b29ea418 100644 --- a/test/integration/agent_ui_test.go +++ b/test/integration/agent_ui_test.go @@ -92,7 +92,6 @@ func TestAgentUIBuildAndPublish(t *testing.T) { if err != nil { t.Fatal(err) } - defer runtime.Close() supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, coreservices.NewRuntimeScrollManager(runtime.Store), runtime) if err != nil { t.Fatal(err) diff --git a/test/integration/workload_identity_test.go b/test/integration/workload_identity_test.go index 34916d6f..0864a62e 100644 --- a/test/integration/workload_identity_test.go +++ b/test/integration/workload_identity_test.go @@ -98,7 +98,6 @@ func TestWorkloadIdentityRealRuntime(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = runtime.Close() }) supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, coreservices.NewRuntimeScrollManager(runtime.Store), runtime) if err != nil { t.Fatal(err) @@ -359,7 +358,6 @@ commands: if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = reattached.Close() }) second, err := appservices.NewRuntimeSupervisor(reattached.Store, coreservices.NewRuntimeScrollManager(reattached.Store), reattached) if err != nil { t.Fatal(err) From 6367af940e039b12796670ce57854d04fa2b6103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 13:29:30 +0100 Subject: [PATCH 09/14] feat(tools)!: add removal and resource REST APIs Expose every workload tool through resource REST routes and MCP. Reuse existing queue, run, removal and publication owner handlers. BREAKING CHANGE: remove execution IDs from runtime status and tool payloads. Waiting observes the current command, including later runs. New Kubernetes agents need commands:remove to remove commands. --- README.md | 38 ++++- apps/druid/adapters/http/handlers/auth.go | 15 ++ .../adapters/http/handlers/command_tools.go | 59 ++++++- .../http/handlers/command_tools_test.go | 144 ++++++++++++++---- .../handlers/docker_command_tools_test.go | 14 +- apps/druid/adapters/http/handlers/routes.go | 6 +- apps/druid/core/services/command_tools.go | 29 ++-- .../druid/core/services/command_tools_test.go | 86 ++++++++--- apps/druid/core/services/runtime_dev_test.go | 2 +- apps/druid/core/services/runtime_identity.go | 30 ---- .../core/services/runtime_identity_test.go | 37 ++--- .../core/services/runtime_session_commands.go | 10 -- .../services/runtime_session_execution.go | 20 +-- .../core/services/runtime_session_queue.go | 16 -- .../druid/core/services/runtime_supervisor.go | 2 +- internal/core/domain/command_identity.go | 4 +- internal/core/domain/command_identity_test.go | 2 +- internal/core/domain/command_status.go | 1 - internal/core/ports/services_ports.go | 12 +- .../runtime/docker/command_identity_test.go | 9 +- internal/runtime/docker/procedures.go | 11 +- internal/runtime/docker/spec.go | 46 +++--- .../kubernetes/command_authentication_test.go | 5 +- .../runtime/kubernetes/command_identity.go | 2 +- .../kubernetes/command_identity_test.go | 27 ++-- internal/runtime/kubernetes/procedures.go | 11 +- internal/runtime/kubernetes/resources.go | 11 +- internal/workloadidentity/identity.go | 1 - test/integration/agent_ui_test.go | 4 +- test/integration/workload_identity_test.go | 39 +++-- 30 files changed, 410 insertions(+), 283 deletions(-) diff --git a/README.md b/README.md index e09820ef..ec6cb48a 100644 --- a/README.md +++ b/README.md @@ -67,14 +67,46 @@ There is also websocket support for stdout. TTY is also supported. Runtime selection is daemon-only: start the daemon with `druid daemon --runtime docker`, then use `druid` to create, run, and inspect scrolls without passing a runtime. Docker runtime state stays in SQLite under the runtime state directory. Scroll specs and runtime data live together in one runtime root. -Agent tools are exposed only at `/:id/mcp`. Existing REST endpoints retain owner -authentication; there are no parallel agent REST routes. +Agent tools are exposed through `/:id/mcp` and normal REST resource routes. +Existing queue, command-run/removal and UI-publication routes are registered once +and accept either their existing owner authentication or scoped workload credentials. +Other owner REST endpoints are unchanged. + +REST paths below are relative to `/:id/api/v1`. Tool arguments are JSON bodies; +GET limits use `tail_lines` or `timeout_seconds` query parameters. Workload +responses use the same result objects as MCP. Existing owner handlers preserve +their previous response shapes and status codes. + +| MCP tool | REST operation | +| --- | --- | +| `list_commands` | `GET /commands` | +| `create_command` | `POST /commands` | +| `remove_command` | `DELETE /scroll/commands/:command` | +| `run_command` | `POST /command` | +| `wait_for_command` | `GET /commands/:command/wait` | +| `get_queue` | `GET /queue` | +| `read_logs` | `GET /consoles/:console/logs` | +| `search_logs` | `POST /consoles/:console/logs/search` | +| `send_stdin` | `POST /consoles/:console/stdin` | +| `get_deployment_status` | `GET /status` | +| `publish_ui` | `POST /ui/packages/:scope/publish` | + +`remove_command` requires `commands:remove`, stops the command and removes its +definition, queue/status and runtime containers. Its console/log access is also +removed; deployment data and other commands are retained. New OpenHands commands +request this permission; existing Kubernetes agents require explicit recreation +to gain it. Kubernetes authorizes each tool request when it is accepted. An accepted command wait may finish after its caller's credential is revoked (at most 30 seconds); subsequent requests must pass authentication again. -**Docker agent-tool caveat:** MCP tool endpoints on the Docker runtime are +Commands have no execution IDs. `run_command` acknowledges the command name and +`wait_for_command` observes its current status. Restarted/replaced runs are not +distinguished, and delayed status events from older containers may update the +current command status. + +**Docker agent-tool caveat:** HTTP/MCP tool endpoints on the Docker runtime are unauthenticated and ignore command permissions. Anyone who can reach them can invoke every tool for any deployment, including command creation/execution, stdin, log access and UI publication. Docker creates no workload credentials or diff --git a/apps/druid/adapters/http/handlers/auth.go b/apps/druid/adapters/http/handlers/auth.go index 71cf9acd..777e58a1 100644 --- a/apps/druid/adapters/http/handlers/auth.go +++ b/apps/druid/adapters/http/handlers/auth.go @@ -8,6 +8,21 @@ import ( const ownerLocal = "druid-owner-id" +// Shared REST routes keep their owner handlers and accept permission-scoped workloads. +func (h *ScrollHandler) ownerOrWorkload(workload fiber.Handler) fiber.Handler { + return func(c *fiber.Ctx) error { + if c.Get("Authorization") == "" && h.allowUnauthenticatedPublic { + return c.Next() + } + if h.authorizer != nil { + if owner, err := h.authorizer.CheckHeader(c); err == nil && owner != nil { + return c.Next() + } + } + return workload(c) + } +} + func (h *ScrollHandler) PublicAuth(c *fiber.Ctx) error { if h.authorizer == nil { if h.allowUnauthenticatedPublic && c.Get("Authorization") == "" { diff --git a/apps/druid/adapters/http/handlers/command_tools.go b/apps/druid/adapters/http/handlers/command_tools.go index a9c62e9f..ad459901 100644 --- a/apps/druid/adapters/http/handlers/command_tools.go +++ b/apps/druid/adapters/http/handlers/command_tools.go @@ -46,7 +46,51 @@ func bearer(header string) string { return parts[1] } -func (h *ScrollHandler) registerCommandMCP(app *fiber.App) { +func commandToolInput(data []byte) (services.ToolInput, error) { + var input services.ToolInput + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err := decoder.Decode(&input) + return input, err +} + +func (h *ScrollHandler) commandToolHTTP(name string) fiber.Handler { + return func(c *fiber.Ctx) error { + var input services.ToolInput + if len(c.Body()) > 0 { + var err error + input, err = commandToolInput(c.Body()) + if err != nil { + return fiber.NewError(400, "invalid tool request") + } + } + input.Command = c.Params("command", input.Command) + input.Console = c.Params("console", input.Console) + input.Scope = c.Params("scope", input.Scope) + if c.Method() == "GET" { + input.TailLines = c.QueryInt("tail_lines", 0) + input.TimeoutSeconds = c.QueryInt("timeout_seconds", 0) + } + result, err := h.supervisor.CallCommandTool(c.UserContext(), bearer(c.Get("Authorization")), c.Params("id"), name, input) + if err != nil { + return fiber.NewError(commandToolStatus(err), commandToolMessage(err)) + } + return c.JSON(result) + } +} + +func (h *ScrollHandler) registerCommandTools(app *fiber.App) { + app.Get("/:id/api/v1/commands", h.commandToolHTTP("list_commands")) + app.Post("/:id/api/v1/commands", h.commandToolHTTP("create_command")) + app.Get("/:id/api/v1/commands/:command/wait", h.commandToolHTTP("wait_for_command")) + app.Get("/:id/api/v1/consoles/:console/logs", h.commandToolHTTP("read_logs")) + app.Post("/:id/api/v1/consoles/:console/logs/search", h.commandToolHTTP("search_logs")) + app.Post("/:id/api/v1/consoles/:console/stdin", h.commandToolHTTP("send_stdin")) + app.Get("/:id/api/v1/status", h.commandToolHTTP("get_deployment_status")) + app.Get("/:id/api/v1/queue", h.ownerOrWorkload(h.commandToolHTTP("get_queue")), h.PublicAuth, h.GetDaemonQueue) + app.Post("/:id/api/v1/command", h.ownerOrWorkload(h.commandToolHTTP("run_command")), h.PublicAuth, h.RunDaemonCommand) + app.Delete("/:id/api/v1/scroll/commands/:command", h.ownerOrWorkload(h.commandToolHTTP("remove_command")), h.PublicAuth, h.RemoveDaemonCommand) + app.Post("/:id/api/v1/ui/packages/:scope/publish", h.ownerOrWorkload(h.commandToolHTTP("publish_ui")), h.PublicAuth, h.PublishDaemonUIPackage) app.All("/:id/mcp", func(c *fiber.Ctx) error { deployment, token := c.Params("id"), bearer(c.Get("Authorization")) record, err := h.supervisor.AuthenticateCommand(c.UserContext(), token, deployment) @@ -61,10 +105,8 @@ func (h *ScrollHandler) registerCommandMCP(app *fiber.App) { continue } server.AddTool(definition, func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var input services.ToolInput - decoder := json.NewDecoder(bytes.NewReader(request.Params.Arguments)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&input); err != nil { + input, err := commandToolInput(request.Params.Arguments) + if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "invalid tool request"}}}, nil } result, err := h.supervisor.CallCommandTool(ctx, token, deployment, name, input) @@ -140,10 +182,11 @@ func commandToolDefinitions() []*mcp.Tool { return []*mcp.Tool{ tool("create_command", `Register a new persistent deployment command without executing it. Instructions define container images, arguments, environments and deployment-data mounts; this creates container workloads, not host-shell execution. Existing names and workload identity grants are rejected. Use run_command afterward. Example: {"command":"example-echo","instructions":{"procedures":[{"image":"busybox:1.37","command":["echo","hello"]}]}}`, map[string]any{"command": stringField("New command name"), "instructions": instructions}, "command", "instructions"), tool("list_commands", "List existing Scroll commands and console IDs, without secrets.", nil), - tool("get_queue", "Read current command/procedure status and execution IDs.", nil), + tool("remove_command", "Stop a named command and remove its stored definition, queue/status entry and runtime containers. This does not delete the deployment or its data. Removing a command also removes its console/log access.", map[string]any{"command": stringField("Command name to stop and remove")}, "command"), + tool("get_queue", "Read current command/procedure status.", nil), tool("get_deployment_status", "Read deployment state, installed release and ports/endpoints.", nil), - tool("run_command", "Enqueue an existing named command; inspect completion using its execution ID.", map[string]any{"command": stringField("Existing command name")}, "command"), - tool("wait_for_command", "Wait up to 30 seconds for a specific execution; never switches to a replacement.", map[string]any{"command": stringField("Command name"), "execution_id": stringField("Execution ID returned by run_command"), "timeout_seconds": integerField(30)}, "command", "execution_id"), + tool("run_command", "Enqueue an existing named command; inspect its current status and logs for completion.", map[string]any{"command": stringField("Existing command name")}, "command"), + tool("wait_for_command", "Wait up to 30 seconds for the named command's current status. A restart or replacement under the same name is not distinguished.", map[string]any{"command": stringField("Command name"), "timeout_seconds": integerField(30)}, "command"), tool("read_logs", "Read a finite recent console snapshot (default 200 lines, maximum 64 KiB). Application logs may contain secrets.", map[string]any{"console": stringField("Console ID from list_commands"), "tail_lines": integerField(1000)}, "console"), tool("search_logs", "Case-insensitive literal search of recent logs; maximum 50 matches and 1 MiB scanned.", map[string]any{"console": stringField("Console ID"), "query": stringField("Literal search text"), "tail_lines": integerField(10000)}, "console", "query"), tool("send_stdin", "Write exact text to an existing running console. Include a newline to submit a line. Does not start a shell.", map[string]any{"console": stringField("Console ID"), "text": stringField("Exact UTF-8 input, at most 8 KiB")}, "console", "text"), diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go index 5deb0ae9..28c6be66 100644 --- a/apps/druid/adapters/http/handlers/command_tools_test.go +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -36,6 +36,22 @@ func TestPublishUIDescriptionRequiresSeparateCustomerApproval(t *testing.T) { t.Fatal("publish_ui tool is missing") } +func TestCommandWaitSchemaUsesOnlyCommandNameAndTimeout(t *testing.T) { + for _, tool := range commandToolDefinitions() { + if tool.Name != "wait_for_command" { + continue + } + schema := tool.InputSchema.(map[string]any) + properties := schema["properties"].(map[string]any) + required := schema["required"].([]string) + if len(properties) != 2 || properties["command"] == nil || properties["timeout_seconds"] == nil || len(required) != 1 || required[0] != "command" { + t.Fatal("command waiting requires more than a name and optional timeout") + } + return + } + t.Fatal("wait tool missing") +} + func TestConcreteToolSchemaNormalizesMapValuesWithoutChangingRequiredFields(t *testing.T) { schema := &jsonschema.Schema{Type: "object", Required: []string{"required"}, Properties: map[string]*jsonschema.Schema{ "required": {Types: []string{"string", "null"}}, @@ -126,6 +142,9 @@ func (b *toolRouteBackend) CreateUIPackageUpload(_ context.Context, action ports return ports.RuntimeUIPackageUpload{UploadURL: "http://upload-private/wasm", DocumentUploadURL: "http://upload-private/document", URL: "http://packages/" + string(action.Scope) + "/app.wasm"}, nil } func (b *toolRouteBackend) StopCommand(string, string) error { return nil } +func (b *toolRouteBackend) ExpectedPorts(string, map[string]*domain.CommandInstructionSet, []domain.Port) ([]domain.RuntimePortStatus, error) { + return nil, nil +} func (b *toolRouteBackend) ReadConsoleLogs(context.Context, string, string, int, int64) (string, bool, error) { return b.logs, false, nil } @@ -156,7 +175,7 @@ func TestCommandToolsRealMCPClient(t *testing.T) { t.Fatal(err) } backend := &toolRouteBackend{} - backend.credentials.Store(token, workloadidentity.Identity{DeploymentID: "tools", Permissions: []string{"commands:list", "commands:create", "queue:read", "ui:publish", "logs:read"}}) + backend.credentials.Store(token, workloadidentity.Identity{DeploymentID: "tools", Permissions: []string{"commands:list", "commands:create", "commands:remove", "queue:read", "ui:publish", "logs:read"}}) supervisor, err := appservices.NewRuntimeSupervisor(store, coreservices.NewRuntimeScrollManager(store), ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) if err != nil { t.Fatal(err) @@ -179,6 +198,8 @@ func TestCommandToolsRealMCPClient(t *testing.T) { RegisterPublicRoutes(app, RouteHandlers{Server: NewRuntimeServer(NewHealthHandler(), scrolls), Websocket: NewWebsocketHandler()}) for _, route := range []struct{ method, path string }{ {"GET", "/:id/api/v1/queue"}, + {"POST", "/:id/api/v1/command"}, + {"DELETE", "/:id/api/v1/scroll/commands/:command"}, {"POST", "/:id/api/v1/ui/packages/:scope/publish"}, } { count := 0 @@ -188,29 +209,18 @@ func TestCommandToolsRealMCPClient(t *testing.T) { } } if count != 1 { - t.Fatalf("owner REST route %s is registered %d times", route.path, count) + t.Fatalf("shared REST route %s is registered %d times", route.path, count) } } - for _, route := range []struct{ method, path string }{ - {"GET", "/tools/api/v1/commands"}, - {"POST", "/tools/api/v1/commands"}, - {"POST", "/tools/api/v1/commands/agent"}, - {"GET", "/tools/api/v1/commands/agent/executions/execution/wait"}, - {"GET", "/tools/api/v1/consoles/agent.0/logs"}, - {"POST", "/tools/api/v1/consoles/agent.0/logs/search"}, - {"POST", "/tools/api/v1/consoles/agent.0/stdin"}, - {"GET", "/tools/api/v1/status"}, - } { - response, err := app.Test(httptest.NewRequest(route.method, route.path, nil)) - if err != nil { - t.Fatal(err) - } - response.Body.Close() - if response.StatusCode != 404 { - t.Fatalf("removed agent REST route %s: %d", route.path, response.StatusCode) - } + response, err := app.Test(httptest.NewRequest("POST", "/tools/api/v1/tools/list_commands", strings.NewReader("{}"))) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 404 { + t.Fatal("generic tool RPC route must not exist") } - for _, path := range []string{"/tools/api/v1/commands", "/other/api/v1/commands", "/tools/api/v1/token", "/tools/api/v1/scroll", "/tools/api/v1/queue", "/tools/api/v1/ui/packages/private/publish"} { + for _, path := range []string{"/other/api/v1/commands", "/tools/api/v1/token", "/tools/api/v1/scroll"} { request := httptest.NewRequest("GET", path, nil) request.Header.Set("Authorization", "Bearer "+string(token)) response, err := app.Test(request) @@ -253,13 +263,17 @@ func TestCommandToolsRealMCPClient(t *testing.T) { if err != nil { t.Fatal(err) } - if len(listed.Tools) != 7 { + if len(listed.Tools) != 8 { t.Fatalf("permission-filtered discovery returned %d tools", len(listed.Tools)) } result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "list_commands", Arguments: map[string]any{}}) if err != nil || result.IsError { t.Fatal("MCP command listing failed") } + waited, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "wait_for_command", Arguments: map[string]any{"command": "agent", "timeout_seconds": 1}}) + if err != nil || waited.IsError || !strings.Contains(waited.Content[0].(*mcp.TextContent).Text, `"timed_out":true`) { + t.Fatalf("MCP waiting by command name failed: %v, %v", waited, err) + } t.Run("MCP sanitizes workload logs and tool errors", func(t *testing.T) { jwt := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln" backend.logs = "application printed " + jwt @@ -330,6 +344,45 @@ func TestCommandToolsRealMCPClient(t *testing.T) { if err != nil || signal.IsError { t.Fatalf("signal procedure schema regressed: %v, %v", err, signal) } + allToken := "all-tools-token" + var permissions []string + for _, permission := range appservices.ToolPermissions { + permissions = append(permissions, permission) + } + backend.credentials.Store(allToken, workloadidentity.Identity{DeploymentID: "tools", Permissions: permissions}) + for _, test := range []struct{ method, path, body string }{ + {"GET", "/tools/api/v1/commands", ""}, + {"GET", "/tools/api/v1/queue", ""}, + {"GET", "/tools/api/v1/status", ""}, + {"GET", "/tools/api/v1/consoles/agent.0/logs", ""}, + {"POST", "/tools/api/v1/consoles/agent.0/logs/search", `{"query":"application"}`}, + {"POST", "/tools/api/v1/consoles/agent.0/stdin", `{"text":"hello\\n"}`}, + {"POST", "/tools/api/v1/commands", `{"command":"http-created","instructions":{"procedures":[{"image":"busybox","command":["true"]}]}}`}, + {"POST", "/tools/api/v1/command", `{"command":"http-created"}`}, + {"GET", "/tools/api/v1/commands/http-created/wait?timeout_seconds=1", ""}, + {"POST", "/tools/api/v1/ui/packages/private/publish", `{}`}, + {"DELETE", "/tools/api/v1/scroll/commands/example-echo", ""}, + } { + request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+allToken) + response, err := app.Test(request, -1) + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.StatusCode != 200 { + t.Fatalf("HTTP %s: %d, %s", test.path, response.StatusCode, data) + } + if strings.Contains(test.path, "/logs") && !strings.Contains(string(data), "[REDACTED]") { + t.Fatal("HTTP output was not sanitized like MCP output") + } + } + removed, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "remove_command", Arguments: map[string]any{"command": "http-created"}}) + if err != nil || removed.IsError || !strings.Contains(removed.Content[0].(*mcp.TextContent).Text, `"removed":true`) { + t.Fatalf("MCP removal of HTTP-created command failed: %v, %v", removed, err) + } // A credential with only commands:list must neither discover nor call creation. limitedToken := "limited-token" backend.credentials.Store(limitedToken, workloadidentity.Identity{DeploymentID: "tools", Permissions: []string{"commands:list"}}) @@ -342,6 +395,33 @@ func TestCommandToolsRealMCPClient(t *testing.T) { if err != nil || len(limitedTools.Tools) != 1 || limitedTools.Tools[0].Name != "list_commands" { t.Fatal("creation leaked through filtered discovery") } + for _, test := range []struct { + method, path, token, body string + status int + }{ + {"GET", "/tools/api/v1/commands", "invalid", "", 401}, + {"DELETE", "/other/api/v1/scroll/commands/agent", allToken, "", 401}, + {"DELETE", "/tools/api/v1/scroll/commands/agent", limitedToken, "", 403}, + {"POST", "/tools/api/v1/commands", allToken, `{}`, 400}, + {"DELETE", "/tools/api/v1/scroll/commands/agent", allToken, `{"unknown":true}`, 400}, + {"POST", "/tools/api/v1/commands", allToken, `{`, 400}, + } { + request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + request.Header.Set("Authorization", "Bearer "+test.token) + request.Header.Set("Content-Type", "application/json") + response, err := app.Test(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != test.status { + t.Fatalf("HTTP denial %s: %d, want %d", test.path, response.StatusCode, test.status) + } + } + deniedRemove, err := limitedSession.CallTool(ctx, &mcp.CallToolParams{Name: "remove_command", Arguments: map[string]any{"command": "agent"}}) + if err == nil && !deniedRemove.IsError { + t.Fatal("MCP accepted removal without permission") + } deniedPublish, err := limitedSession.CallTool(ctx, &mcp.CallToolParams{Name: "publish_ui", Arguments: map[string]any{"scope": "private"}}) if err == nil && !deniedPublish.IsError { t.Fatal("MCP accepted publishing without permission") @@ -356,12 +436,20 @@ func TestCommandToolsRealMCPClient(t *testing.T) { } // Existing dashboard REST endpoints still work with owner authentication. scrolls.authorizer = ownerRouteAuth{} - for _, route := range []struct{ method, path string }{ - {"GET", "/tools/api/v1/queue"}, - {"POST", "/tools/api/v1/ui/packages/private/publish"}, - {"POST", "/tools/api/v1/ui/packages/public/publish"}, + if err := supervisor.AddCommand("tools", "owner-cleanup", &domain.CommandInstructionSet{Procedures: []*domain.Procedure{{Image: "busybox"}}}); err != nil { + t.Fatal(err) + } + for _, route := range []struct { + method, path, body string + status int + }{ + {"GET", "/tools/api/v1/queue", `{}`, 200}, + {"POST", "/tools/api/v1/ui/packages/private/publish", `{}`, 200}, + {"POST", "/tools/api/v1/ui/packages/public/publish", `{}`, 200}, + {"POST", "/tools/api/v1/command", `{"command":"owner-cleanup"}`, 200}, + {"DELETE", "/tools/api/v1/scroll/commands/owner-cleanup", ``, 204}, } { - request := httptest.NewRequest(route.method, route.path, strings.NewReader(`{}`)) + request := httptest.NewRequest(route.method, route.path, strings.NewReader(route.body)) request.Header.Set("Content-Type", "application/json") request.Header.Set("Authorization", "Bearer owner") response, err := app.Test(request, -1) @@ -369,7 +457,7 @@ func TestCommandToolsRealMCPClient(t *testing.T) { t.Fatal(err) } response.Body.Close() - if response.StatusCode != 200 { + if response.StatusCode != route.status { t.Fatalf("owner REST %s: %d", route.path, response.StatusCode) } } diff --git a/apps/druid/adapters/http/handlers/docker_command_tools_test.go b/apps/druid/adapters/http/handlers/docker_command_tools_test.go index 4ba77a19..9416476a 100644 --- a/apps/druid/adapters/http/handlers/docker_command_tools_test.go +++ b/apps/druid/adapters/http/handlers/docker_command_tools_test.go @@ -37,7 +37,7 @@ func TestDockerToolsAreAnonymousWhileOwnerRoutesRemainProtected(t *testing.T) { } app := fiber.New(fiber.Config{DisableStartupMessage: true}) RegisterPublicRoutes(app, RouteHandlers{Server: NewRuntimeServer(NewHealthHandler(), NewScrollHandler(supervisor)), Websocket: NewWebsocketHandler()}) - for _, path := range []string{"/one/api/v1/scroll", "/one/api/v1/queue", "/other/api/v1/queue"} { + for _, path := range []string{"/one/api/v1/scroll"} { response, err := app.Test(httptest.NewRequest("GET", path, nil)) if err != nil { t.Fatal(err) @@ -52,8 +52,16 @@ func TestDockerToolsAreAnonymousWhileOwnerRoutesRemainProtected(t *testing.T) { t.Fatal(err) } response.Body.Close() - if response.StatusCode != 401 { - t.Fatal("Docker MCP exemption reached owner REST publication") + if response.StatusCode != 200 { + t.Fatal("Docker HTTP publication unexpectedly requires credentials") + } + response, err = app.Test(httptest.NewRequest("GET", "/one/api/v1/commands", nil)) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 200 { + t.Fatal("Docker HTTP tools unexpectedly require credentials") } server := httptest.NewServer(adaptor.FiberApp(app)) defer server.Close() diff --git a/apps/druid/adapters/http/handlers/routes.go b/apps/druid/adapters/http/handlers/routes.go index fdbf4855..0b5bd603 100644 --- a/apps/druid/adapters/http/handlers/routes.go +++ b/apps/druid/adapters/http/handlers/routes.go @@ -64,7 +64,7 @@ func RegisterPublicRoutes(app *fiber.App, handlers RouteHandlers) { app.Get("/.well-known/jwks.json", RuntimeJWKS(authorizer)) app.Get("/:id/ws/v1/serve/:console", websocket.New(handlers.Websocket.AttachScrollConsole)) if handlers.Server != nil && handlers.Server.ScrollHandler != nil { - handlers.Server.ScrollHandler.registerCommandMCP(app) + handlers.Server.ScrollHandler.registerCommandTools(app) app.Use("/:id", handlers.Server.PublicAuth) } app.Get("/:id/api/v1/health", handlers.Server.GetHealthAuth) @@ -73,11 +73,7 @@ func RegisterPublicRoutes(app *fiber.App, handlers RouteHandlers) { app.Get("/:id/api/v1/release", func(c *fiber.Ctx) error { return handlers.Server.GetInstalledRelease(c, c.Params("id")) }) app.Post("/:id/api/v1/update", func(c *fiber.Ctx) error { return handlers.Server.UpdateScroll(c, c.Params("id")) }) app.Put("/:id/api/v1/scroll/commands/:command", handlers.Server.AddDaemonCommand) - app.Delete("/:id/api/v1/scroll/commands/:command", handlers.Server.RemoveDaemonCommand) - app.Post("/:id/api/v1/command", handlers.Server.RunDaemonCommand) - app.Get("/:id/api/v1/queue", handlers.Server.GetDaemonQueue) app.Get("/:id/api/v1/consoles", handlers.Server.GetDaemonConsoles) app.Get("/:id/api/v1/ports", handlers.Server.GetDaemonPorts) app.Get("/:id/api/v1/ui/packages", handlers.Server.GetDaemonUIPackages) - app.Post("/:id/api/v1/ui/packages/:scope/publish", handlers.Server.PublishDaemonUIPackage) } diff --git a/apps/druid/core/services/command_tools.go b/apps/druid/core/services/command_tools.go index 6219a0e9..04e6e9d6 100644 --- a/apps/druid/core/services/command_tools.go +++ b/apps/druid/core/services/command_tools.go @@ -9,7 +9,6 @@ import ( "time" "unicode/utf8" - "github.com/google/uuid" "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/ports" coreservices "github.com/highcard-dev/daemon/internal/core/services" @@ -21,7 +20,6 @@ type ToolInput struct { Path string `json:"path,omitempty"` Instructions *domain.CommandInstructionSet `json:"instructions,omitempty"` Command string `json:"command,omitempty"` - ExecutionID string `json:"execution_id,omitempty"` Console string `json:"console,omitempty"` Text string `json:"text,omitempty"` Query string `json:"query,omitempty"` @@ -32,6 +30,7 @@ type ToolInput struct { var ToolPermissions = map[string]string{ "publish_ui": "ui:publish", "create_command": "commands:create", + "remove_command": "commands:remove", "list_commands": "commands:list", "get_queue": "queue:read", "run_command": "commands:run", "wait_for_command": "queue:read", "read_logs": "logs:read", "search_logs": "logs:read", "send_stdin": "console:write", "get_deployment_status": "runtime:read", } @@ -44,7 +43,7 @@ type ToolError struct { func (e *ToolError) Error() string { return e.Message } func toolError(status int, message string) error { return &ToolError{Status: status, Message: message} } -// CallCommandTool is the permission/execution seam for MCP tools. +// CallCommandTool is the permission/execution seam shared by HTTP and MCP. func (s *RuntimeSupervisor) CallCommandTool(ctx context.Context, token, deployment, name string, input ToolInput) (any, error) { record, err := s.AuthenticateCommand(ctx, token, deployment) if err != nil { @@ -70,6 +69,14 @@ func (s *RuntimeSupervisor) CallCommandTool(ctx context.Context, token, deployme return map[string]any{"scope": scope, "published": true, "package": updated.UIPackages[scope]}, nil case "create_command": return s.createToolCommand(deployment, input) + case "remove_command": + if input.Command == "" { + return nil, toolError(400, "command is required") + } + if err := s.RemoveCommand(deployment, input.Command); err != nil { + return nil, err + } + return map[string]any{"command": input.Command, "removed": true}, nil case "list_commands": file, err := s.ScrollFile(deployment) if err != nil { @@ -164,20 +171,19 @@ func (s *RuntimeSupervisor) enqueueToolCommand(deployment, command string) (any, } session.Start() session.rememberDoneDependencies(definition, map[string]bool{}) - execution := uuid.NewString() - _, err = session.enqueueExecution(command, coreservices.AddItemOptions{}, nil, execution) + _, err = session.enqueueQueueItem(command, coreservices.AddItemOptions{}, nil) if errors.Is(err, coreservices.ErrAlreadyInQueue) || errors.Is(err, coreservices.ErrCommandDoneOnce) { return nil, toolError(409, err.Error()) } if err != nil { return nil, err } - return map[string]any{"command": command, "execution_id": execution, "accepted": true}, nil + return map[string]any{"command": command, "accepted": true}, nil } func (s *RuntimeSupervisor) waitToolCommand(ctx context.Context, deployment string, input ToolInput) (any, error) { - if input.Command == "" || input.ExecutionID == "" { - return nil, toolError(400, "command and execution_id are required") + if input.Command == "" { + return nil, toolError(400, "command is required") } timeout := input.TimeoutSeconds if timeout == 0 { @@ -194,14 +200,11 @@ func (s *RuntimeSupervisor) waitToolCommand(ctx context.Context, deployment stri } statuses := queue[input.Command] if len(statuses) == 0 { - return nil, toolError(410, "execution stopped or unavailable") + return nil, toolError(410, "command stopped or unavailable") } state := domain.ScrollLockStatusDone var exitCode *int for _, status := range statuses { - if status.ExecutionID != input.ExecutionID { - return nil, toolError(409, "execution was replaced") - } if status.Status == domain.ScrollLockStatusError { state = domain.ScrollLockStatusError exitCode = status.ExitCode @@ -218,7 +221,7 @@ func (s *RuntimeSupervisor) waitToolCommand(ctx context.Context, deployment stri terminal := state == domain.ScrollLockStatusDone || state == domain.ScrollLockStatusError timedOut := !terminal && !time.Now().Before(deadline) if terminal || timedOut { - return map[string]any{"command": input.Command, "execution_id": input.ExecutionID, "status": state, "exit_code": exitCode, "timed_out": timedOut}, nil + return map[string]any{"command": input.Command, "status": state, "exit_code": exitCode, "timed_out": timedOut}, nil } select { case <-ctx.Done(): diff --git a/apps/druid/core/services/command_tools_test.go b/apps/druid/core/services/command_tools_test.go index f93e96e9..99b4c330 100644 --- a/apps/druid/core/services/command_tools_test.go +++ b/apps/druid/core/services/command_tools_test.go @@ -43,10 +43,10 @@ func TestCreateCommandPersistsWithoutRunningAndCanExecute(t *testing.T) { t.Fatal("new command was not listed") } run, err := s.CallCommandTool(ctx, token, "tools", "run_command", ToolInput{Command: "created"}) - if err != nil { + if err != nil || run.(map[string]any)["accepted"] != true { t.Fatal(err) } - _, err = s.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "created", ExecutionID: run.(map[string]any)["execution_id"].(string), TimeoutSeconds: 1}) + _, err = s.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "created", TimeoutSeconds: 1}) if err != nil { t.Fatal(err) } @@ -73,7 +73,7 @@ func TestCreateCommandRejectionsAndConcurrentDuplicates(t *testing.T) { } } if _, err := backend.AuthenticateCommandIdentity(ctx, token); err != nil { - t.Fatal("duplicate creation disturbed existing execution identity") + t.Fatal("duplicate creation disturbed existing workload credentials") } if _, err := s.CallCommandTool(ctx, token, "other", "create_command", ToolInput{Command: "new", Instructions: definition}); !errors.Is(err, workloadidentity.ErrUnauthorized) { t.Fatal("cross-deployment creation accepted") @@ -185,6 +185,51 @@ type toolTestBackend struct { authentications atomic.Int32 logs string input string + stopped string + stopError error +} + +func (b *toolTestBackend) StopCommand(_ string, command string) error { + b.stopped = command + return b.stopError +} + +func TestRemoveCommandStopsAndPersistsRemoval(t *testing.T) { + supervisor, backend, token := commandToolsFixture(t, "commands:remove") + result, err := supervisor.CallCommandTool(context.Background(), token, "tools", "remove_command", ToolInput{Command: "agent"}) + if err != nil || result.(map[string]any)["removed"] != true || backend.stopped != "agent" { + t.Fatalf("command removal failed: %v, %v", result, err) + } + stored, err := supervisor.store.GetScroll("tools") + if err != nil { + t.Fatal(err) + } + reloaded, err := NewRuntimeSession(supervisor.store, stored, backend) + if err != nil || reloaded.scrollService.GetFile().Commands["agent"] != nil || reloaded.Snapshot()["agent"] != nil || reloaded.scrollService.GetFile().Commands["echo"] == nil { + t.Fatal("removal did not persist or changed another command") + } +} + +func TestRemoveCommandPermissionsBindingValidationAndStopFailure(t *testing.T) { + ctx := context.Background() + supervisor, backend, token := commandToolsFixture(t, "commands:list") + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "remove_command", ToolInput{Command: "agent"}); err == nil || backend.stopped != "" { + t.Fatal("removal bypassed permission checks") + } + backend.credentials.Store(token, workloadidentity.Identity{DeploymentID: "tools", Permissions: []string{"commands:remove"}}) + if _, err := supervisor.CallCommandTool(ctx, token, "other", "remove_command", ToolInput{Command: "agent"}); !errors.Is(err, workloadidentity.ErrUnauthorized) || backend.stopped != "" { + t.Fatal("removal crossed deployment binding") + } + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "remove_command", ToolInput{}); err == nil || backend.stopped != "" { + t.Fatal("removal accepted an empty command") + } + backend.stopError = errors.New("fixture stop failed") + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "remove_command", ToolInput{Command: "agent"}); !errors.Is(err, backend.stopError) { + t.Fatal("stop failure was not propagated") + } + if supervisor.sessions["tools"].scrollService.GetFile().Commands["agent"] == nil { + t.Fatal("failed stop removed the definition") + } } func (b *toolTestBackend) PrepareCommandIdentity(context.Context, string) error { @@ -231,7 +276,7 @@ commands: procedures: - image: busybox command: [true] -`, Procedures: domain.ProcedureStatusMap{"agent": {"agent-console": {ExecutionID: "agent-execution", Status: domain.ScrollLockStatusRunning}}}} +`, Procedures: domain.ProcedureStatusMap{"agent": {"agent-console": {Status: domain.ScrollLockStatusRunning}}}} if err := store.CreateScroll(runtime); err != nil { t.Fatal(err) } @@ -291,36 +336,43 @@ func TestCommandToolsPermissionsProjectionAndConsoles(t *testing.T) { } } -func TestCommandToolsEnqueueWaitAndReplacedExecution(t *testing.T) { +func TestCommandToolsEnqueueAndWaitForCurrentCommand(t *testing.T) { supervisor, _, token := commandToolsFixture(t, "commands:run", "queue:read") ctx := context.Background() result, err := supervisor.CallCommandTool(ctx, token, "tools", "run_command", ToolInput{Command: "echo"}) if err != nil { t.Fatal(err) } - execution := result.(map[string]any)["execution_id"].(string) - if execution == "" { - t.Fatal("missing execution ID") + if result.(map[string]any)["accepted"] != true || result.(map[string]any)["command"] != "echo" { + t.Fatal("command was not accepted") } - waited, err := supervisor.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "echo", ExecutionID: execution, TimeoutSeconds: 1}) + waited, err := supervisor.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "echo", TimeoutSeconds: 1}) if err != nil || waited.(map[string]any)["timed_out"] != false { t.Fatalf("execution did not complete: %v", err) } - if _, err := supervisor.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "echo", ExecutionID: "different", TimeoutSeconds: 1}); err == nil { - t.Fatal("replaced execution accepted") + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "missing", TimeoutSeconds: 1}); err == nil { + t.Fatal("missing command accepted") } if _, err := supervisor.CallCommandTool(ctx, token, "tools", "run_command", ToolInput{Command: "unknown"}); err == nil { t.Fatal("unknown command accepted") } } +func TestCommandWaitReturnsCurrentStatusOnTimeout(t *testing.T) { + supervisor, _, token := commandToolsFixture(t, "queue:read") + result, err := supervisor.CallCommandTool(context.Background(), token, "tools", "wait_for_command", ToolInput{Command: "agent", TimeoutSeconds: 1}) + if err != nil || result.(map[string]any)["timed_out"] != true || result.(map[string]any)["status"] != domain.ScrollLockStatusRunning { + t.Fatalf("wait did not return the current running status: %v, %v", result, err) + } +} + func TestCommandWaitAuthenticatesOnceAndRejectsSubsequentRevokedRequests(t *testing.T) { supervisor, backend, token := commandToolsFixture(t, "queue:read") ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() done := make(chan error, 1) go func() { - _, err := supervisor.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "agent", ExecutionID: "agent-execution", TimeoutSeconds: 1}) + _, err := supervisor.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "agent", TimeoutSeconds: 1}) done <- err }() for backend.authentications.Load() == 0 { @@ -341,13 +393,13 @@ func TestCommandWaitAuthenticatesOnceAndRejectsSubsequentRevokedRequests(t *test } } -func TestLateProcedureEventCannotOverwriteReplacementExecution(t *testing.T) { +func TestProcedureEventsUpdateCurrentCommandStatus(t *testing.T) { supervisor, _, _ := commandToolsFixture(t) session := supervisor.sessions["tools"] - session.beginExecution("agent", "replacement") - supervisor.ObserveProcedureStatus(ports.ProcedureStatusUpdate{RuntimeID: "tools", Command: "agent", Procedure: "agent-console", ExecutionID: "agent-execution", Status: domain.ScrollLockStatusError}) + session.SetCommandStatus("agent", domain.ScrollLockStatusWaiting, nil) + supervisor.ObserveProcedureStatus(ports.ProcedureStatusUpdate{RuntimeID: "tools", Command: "agent", Procedure: "agent-console", Status: domain.ScrollLockStatusError}) status := session.Snapshot()["agent"]["agent-console"] - if status.ExecutionID != "replacement" || status.Status != domain.ScrollLockStatusWaiting { - t.Fatal("stale event overwrote replacement") + if status.Status != domain.ScrollLockStatusError { + t.Fatal("procedure event did not update the named command") } } diff --git a/apps/druid/core/services/runtime_dev_test.go b/apps/druid/core/services/runtime_dev_test.go index 84dc34fe..90ecf231 100644 --- a/apps/druid/core/services/runtime_dev_test.go +++ b/apps/druid/core/services/runtime_dev_test.go @@ -16,7 +16,7 @@ func (*rejectImplicitStopBackend) StopCommand(string, string) error { } func TestAddCommandUpdatesDefinitionWithoutStoppingExecution(t *testing.T) { - status := domain.LockStatus{ExecutionID: "current-run", Status: domain.ScrollLockStatusRunning} + status := domain.LockStatus{Status: domain.ScrollLockStatusRunning} session := newRuntimeSessionForTest(t, map[string]domain.LockStatus{"start": status}, cachedScrollYAML("")) session.runtimeBackend = &rejectImplicitStopBackend{&fakeWorkerBackend{}} queued := &runtimeQueueItem{inFlight: true} diff --git a/apps/druid/core/services/runtime_identity.go b/apps/druid/core/services/runtime_identity.go index c428715d..46a7e64a 100644 --- a/apps/druid/core/services/runtime_identity.go +++ b/apps/druid/core/services/runtime_identity.go @@ -5,9 +5,7 @@ import ( "fmt" "net/url" "strings" - "time" - "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/ports" "github.com/highcard-dev/daemon/internal/workloadidentity" ) @@ -23,34 +21,6 @@ func (s *RuntimeSupervisor) SetWorkloadAPIURL(base string) error { return nil } -func (s *RuntimeSession) beginExecution(command, execution string) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.runtimeScroll.Procedures == nil { - s.runtimeScroll.Procedures = domain.ProcedureStatusMap{} - } - definition := s.scrollService.GetFile().Commands[command] - if definition == nil { - return fmt.Errorf("command is unavailable") - } - statuses := map[string]domain.LockStatus{} - for index, procedure := range definition.Procedures { - statuses[domain.ProcedureName(command, index, procedure)] = domain.LockStatus{ExecutionID: execution, Status: domain.ScrollLockStatusWaiting, LastStatusChange: time.Now().Unix()} - } - s.runtimeScroll.Procedures[command] = statuses - return s.store.UpdateScroll(s.runtimeScroll) -} - -func (s *RuntimeSession) assignExecutionIdentity(command, execution string) error { - s.mu.Lock() - defer s.mu.Unlock() - for procedure, status := range s.runtimeScroll.Procedures[command] { - status.ExecutionID = execution - s.runtimeScroll.Procedures[command][procedure] = status - } - return s.store.UpdateScroll(s.runtimeScroll) -} - func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token string, deployment string) (workloadidentity.Identity, error) { // Docker MCP tools are deliberately unauthenticated: trusted local use only. if s.runtimeBackend.Name() == "docker" { diff --git a/apps/druid/core/services/runtime_identity_test.go b/apps/druid/core/services/runtime_identity_test.go index ec2583ab..8690eef3 100644 --- a/apps/druid/core/services/runtime_identity_test.go +++ b/apps/druid/core/services/runtime_identity_test.go @@ -10,28 +10,21 @@ import ( "github.com/highcard-dev/daemon/internal/workloadidentity" ) -func TestHydratedIdentityCommandUsesExecutionStatusWithoutRecoveryFlags(t *testing.T) { - for _, execution := range []string{"existing-run", ""} { - t.Run("execution="+execution, func(t *testing.T) { - supervisor, backend, _ := commandToolsFixture(t) - session := supervisor.sessions["tools"] - session.workloadAPIURL = "http://runtime.local" - session.runtimeScroll.Procedures["agent"]["agent-console"] = domain.LockStatus{ExecutionID: execution, Status: domain.ScrollLockStatusRunning} - if err := session.HydrateFromState(session.Snapshot()); err != nil { - t.Fatal(err) - } - var seen ports.RuntimeCommand - backend.runCommand = func(command ports.RuntimeCommand) (*int, error) { seen = command; return nil, nil } - if err := session.runCommand("agent"); err != nil { - t.Fatal(err) - } - if seen.ExecutionID == "" || (execution != "" && seen.ExecutionID != execution) || !seen.Command.Procedures[0].RuntimeIdentity.Enabled { - t.Fatal("hydration lost execution identity or token projection") - } - if _, present := seen.ProcedureEnv["agent-console"]["DRUID_EXECUTION_ID"]; present { - t.Fatal("unused execution metadata leaked into workload environment") - } - }) +func TestHydratedIdentityCommandRetainsTokenProjection(t *testing.T) { + supervisor, backend, _ := commandToolsFixture(t) + session := supervisor.sessions["tools"] + session.workloadAPIURL = "http://runtime.local" + session.runtimeScroll.Procedures["agent"]["agent-console"] = domain.LockStatus{Status: domain.ScrollLockStatusRunning} + if err := session.HydrateFromState(session.Snapshot()); err != nil { + t.Fatal(err) + } + var seen ports.RuntimeCommand + backend.runCommand = func(command ports.RuntimeCommand) (*int, error) { seen = command; return nil, nil } + if err := session.runCommand("agent"); err != nil { + t.Fatal(err) + } + if seen.Command.Procedures[0].RuntimeIdentity == nil { + t.Fatal("hydration lost token projection") } } diff --git a/apps/druid/core/services/runtime_session_commands.go b/apps/druid/core/services/runtime_session_commands.go index 5a17eee5..0b2e8ff2 100644 --- a/apps/druid/core/services/runtime_session_commands.go +++ b/apps/druid/core/services/runtime_session_commands.go @@ -230,7 +230,6 @@ func (s *RuntimeSession) persistCommandStatus(command string, status domain.Scro for idx, procedure := range commandDefinition.Procedures { procedureName := domain.ProcedureName(command, idx, procedure) s.runtimeScroll.Procedures[command][procedureName] = domain.LockStatus{ - ExecutionID: s.runtimeScroll.Procedures[command][procedureName].ExecutionID, Status: status, ExitCode: exitCode, LastStatusChange: time.Now().Unix(), @@ -253,10 +252,6 @@ func (s *RuntimeSession) SetCommandStatus(command string, status domain.ScrollLo } func (s *RuntimeSession) persistProcedureStatus(command string, procedure string, status domain.ScrollLockStatus, exitCode *int) { - s.persistProcedureExecutionStatus(command, procedure, status, exitCode, "") -} - -func (s *RuntimeSession) persistProcedureExecutionStatus(command string, procedure string, status domain.ScrollLockStatus, exitCode *int, executionID string) { s.mu.Lock() defer s.mu.Unlock() commands := s.scrollService.GetFile().Commands @@ -269,12 +264,7 @@ func (s *RuntimeSession) persistProcedureExecutionStatus(command string, procedu if s.runtimeScroll.Procedures[command] == nil { s.runtimeScroll.Procedures[command] = map[string]domain.LockStatus{} } - currentExecution := s.runtimeScroll.Procedures[command][procedure].ExecutionID - if executionID != "" && currentExecution != "" && executionID != currentExecution { - return - } s.runtimeScroll.Procedures[command][procedure] = domain.LockStatus{ - ExecutionID: currentExecution, Status: status, ExitCode: exitCode, LastStatusChange: time.Now().Unix(), diff --git a/apps/druid/core/services/runtime_session_execution.go b/apps/druid/core/services/runtime_session_execution.go index f0c5d149..1874a001 100644 --- a/apps/druid/core/services/runtime_session_execution.go +++ b/apps/druid/core/services/runtime_session_execution.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "github.com/google/uuid" "net/url" "github.com/highcard-dev/daemon/internal/core/domain" @@ -74,20 +73,7 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin } } requiresIdentity := s.runtimeBackend.Name() != "docker" - executionID := "" - for _, status := range s.Snapshot()[cmd] { - if status.ExecutionID != "" { - executionID = status.ExecutionID - break - } - } - if executionID == "" { - executionID = uuid.NewString() - if err := s.assignExecutionIdentity(cmd, executionID); err != nil { - return err - } - } - identity := &domain.RuntimeCommandIdentity{ExecutionID: executionID, DeploymentID: scrollID} + var identity *domain.RuntimeCommandIdentity if command.Identity != nil && requiresIdentity { backend, ok := s.runtimeBackend.(ports.CommandIdentityBackend) if !ok || s.workloadAPIURL == "" { @@ -96,8 +82,7 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin if err := backend.PrepareCommandIdentity(context.Background(), root); err != nil { return err } - identity.Enabled = true - identity.Permissions = append([]string(nil), command.Identity.Permissions...) + identity = &domain.RuntimeCommandIdentity{DeploymentID: scrollID, Permissions: command.Identity.Permissions} } // Never mutate the persisted Scroll or leak transient mount references. copyCommand := *command @@ -119,7 +104,6 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin } } exitCode, err := s.runtimeBackend.RunCommand(ports.RuntimeCommand{ - ExecutionID: executionID, Name: cmd, ScrollID: scrollID, Command: ©Command, diff --git a/apps/druid/core/services/runtime_session_queue.go b/apps/druid/core/services/runtime_session_queue.go index 7c851ff5..d1104195 100644 --- a/apps/druid/core/services/runtime_session_queue.go +++ b/apps/druid/core/services/runtime_session_queue.go @@ -3,7 +3,6 @@ package services import ( "context" "errors" - "github.com/google/uuid" "time" "github.com/highcard-dev/daemon/internal/core/domain" @@ -68,10 +67,6 @@ func (s *RuntimeSession) addQueueItemWithEnv(cmd string, options coreservices.Ad // enqueueQueueItem separates admission from waiting. Supervisors serialize // admission with maintenance without holding a lock while a command runs. func (s *RuntimeSession) enqueueQueueItem(cmd string, options coreservices.AddItemOptions, procedureEnv map[string]map[string]string) (func() error, error) { - return s.enqueueExecution(cmd, options, procedureEnv, uuid.NewString()) -} - -func (s *RuntimeSession) enqueueExecution(cmd string, options coreservices.AddItemOptions, procedureEnv map[string]map[string]string, executionID string) (func() error, error) { logger.Log().Debug("Running command", zap.String("cmd", cmd)) command, err := s.scrollService.GetCommand(cmd) @@ -99,10 +94,6 @@ func (s *RuntimeSession) enqueueExecution(cmd string, options coreservices.AddIt doneChan = make(chan struct{}) } item = &runtimeQueueItem{doneChan: doneChan, procedureEnv: procedureEnv} - if err := s.beginExecution(cmd, executionID); err != nil { - s.queueMu.Unlock() - return nil, err - } s.queue[cmd] = item s.setQueueStatusLocked(cmd, item, domain.ScrollLockStatusWaiting, nil) s.queueMu.Unlock() @@ -284,13 +275,6 @@ func (s *RuntimeSession) RunQueue() { if isPersistentMode { logger.Log().Info("Persistent command running", zap.String("command", c)) } else if isRestartMode { - s.queueMu.Lock() - executionErr := s.beginExecution(c, uuid.NewString()) - s.queueMu.Unlock() - if executionErr != nil { - s.setQueueError(c, executionErr) - return - } s.setQueueStatus(c, domain.ScrollLockStatusWaiting, nil) s.queueMu.Lock() if time.Since(startedAt) < 30*time.Second { diff --git a/apps/druid/core/services/runtime_supervisor.go b/apps/druid/core/services/runtime_supervisor.go index 218fc0bd..17b14cae 100644 --- a/apps/druid/core/services/runtime_supervisor.go +++ b/apps/druid/core/services/runtime_supervisor.go @@ -90,7 +90,7 @@ func (s *RuntimeSupervisor) ObserveProcedureStatus(update ports.ProcedureStatusU logger.Log().Warn("Ignoring procedure status for an unknown runtime", zap.String("runtime", update.RuntimeID), zap.String("command", update.Command), zap.String("procedure", update.Procedure), zap.String("status", string(update.Status))) return } - session.persistProcedureExecutionStatus(update.Command, update.Procedure, update.Status, update.ExitCode, update.ExecutionID) + session.persistProcedureStatus(update.Command, update.Procedure, update.Status, update.ExitCode) } func (s *RuntimeSupervisor) SetWorkerCallbacks(callbacks *WorkerCallbackManager, callbackURL string) { diff --git a/internal/core/domain/command_identity.go b/internal/core/domain/command_identity.go index d4188f31..dc7c4e64 100644 --- a/internal/core/domain/command_identity.go +++ b/internal/core/domain/command_identity.go @@ -7,15 +7,13 @@ type CommandIdentity struct { // RuntimeCommandIdentity is transient mount metadata, never serialized in a Scroll. type RuntimeCommandIdentity struct { - ExecutionID string DeploymentID string - Enabled bool Permissions []string } func ValidCommandPermission(permission string) bool { switch permission { - case "commands:list", "commands:run", "commands:create", "queue:read", "logs:read", "console:write", "runtime:read", "ui:publish": + case "commands:list", "commands:run", "commands:create", "commands:remove", "queue:read", "logs:read", "console:write", "runtime:read", "ui:publish": return true } return false diff --git a/internal/core/domain/command_identity_test.go b/internal/core/domain/command_identity_test.go index 9edc4fd3..6db72795 100644 --- a/internal/core/domain/command_identity_test.go +++ b/internal/core/domain/command_identity_test.go @@ -6,7 +6,7 @@ import ( ) func TestCommandIdentityValidation(t *testing.T) { - for _, permission := range []string{"commands:list", "queue:read", "commands:run", "commands:create", "logs:read", "console:write", "runtime:read", "ui:publish"} { + for _, permission := range []string{"commands:list", "queue:read", "commands:run", "commands:create", "commands:remove", "logs:read", "console:write", "runtime:read", "ui:publish"} { if !ValidCommandPermission(permission) { t.Fatalf("known permission rejected: %s", permission) } diff --git a/internal/core/domain/command_status.go b/internal/core/domain/command_status.go index e2535ff3..a44f2af1 100644 --- a/internal/core/domain/command_status.go +++ b/internal/core/domain/command_status.go @@ -12,7 +12,6 @@ const ( ) type LockStatus struct { - ExecutionID string `json:"execution_id,omitempty"` Status ScrollLockStatus `json:"status"` ExitCode *int `json:"exit_code"` LastStatusChange int64 `json:"last_status_change"` diff --git a/internal/core/ports/services_ports.go b/internal/core/ports/services_ports.go index 827295cb..cc6cfa2f 100644 --- a/internal/core/ports/services_ports.go +++ b/internal/core/ports/services_ports.go @@ -95,7 +95,6 @@ type RuntimeScrollStore interface { } type RuntimeCommand struct { - ExecutionID string // Name identifies the command within the Scroll. Name string // ScrollID identifies the runtime Scroll whose command is being executed. @@ -114,12 +113,11 @@ type RuntimeCommand struct { // ProcedureStatusUpdate identifies one backend-reported procedure transition. type ProcedureStatusUpdate struct { - ExecutionID string - RuntimeID string - Command string - Procedure string - Status domain.ScrollLockStatus - ExitCode *int + RuntimeID string + Command string + Procedure string + Status domain.ScrollLockStatus + ExitCode *int } // ProcedureStatusObserver receives procedure transitions from a runtime backend. diff --git a/internal/runtime/docker/command_identity_test.go b/internal/runtime/docker/command_identity_test.go index 16e44ce3..7d461225 100644 --- a/internal/runtime/docker/command_identity_test.go +++ b/internal/runtime/docker/command_identity_test.go @@ -2,17 +2,16 @@ package docker import ( "github.com/highcard-dev/daemon/internal/core/domain" - "github.com/highcard-dev/daemon/internal/workloadidentity" "testing" ) -func TestDockerCommandHasExecutionLabelButNoCredentialMount(t *testing.T) { - procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: &domain.RuntimeCommandIdentity{ExecutionID: "execution"}} - config, host, err := containerSpec("automation", procedure, "docker-volume://data", nil, nil, nil) +func TestDockerCommandHasNoCredentialMount(t *testing.T) { + procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: &domain.RuntimeCommandIdentity{DeploymentID: "deployment", Permissions: []string{"commands:list"}}} + _, host, err := containerSpec("automation", procedure, "docker-volume://data", nil, nil, nil) if err != nil { t.Fatal(err) } - if config.Labels[workloadidentity.ExecutionLabel] != "execution" || len(host.Mounts) != 0 { + if len(host.Mounts) != 0 { t.Fatal("Docker command identity created credential state") } } diff --git a/internal/runtime/docker/procedures.go b/internal/runtime/docker/procedures.go index 08db1917..a937e9d1 100644 --- a/internal/runtime/docker/procedures.go +++ b/internal/runtime/docker/procedures.go @@ -77,12 +77,11 @@ func (b *Backend) observeProcedureStatus(command ports.RuntimeCommand, procedure return } b.procedureStatusObserver.ObserveProcedureStatus(ports.ProcedureStatusUpdate{ - ExecutionID: command.ExecutionID, - RuntimeID: command.ScrollID, - Command: command.Name, - Procedure: procedure, - Status: status, - ExitCode: exitCode, + RuntimeID: command.ScrollID, + Command: command.Name, + Procedure: procedure, + Status: status, + ExitCode: exitCode, }) } diff --git a/internal/runtime/docker/spec.go b/internal/runtime/docker/spec.go index 06c6a869..564092af 100644 --- a/internal/runtime/docker/spec.go +++ b/internal/runtime/docker/spec.go @@ -11,7 +11,6 @@ import ( "github.com/docker/docker/api/types/mount" "github.com/docker/go-connections/nat" "github.com/highcard-dev/daemon/internal/core/domain" - "github.com/highcard-dev/daemon/internal/workloadidentity" ) func containerSpec(commandName string, procedure *domain.Procedure, root string, globalPorts []domain.Port, routing []domain.RuntimeRouteAssignment, env map[string]string) (*container.Config, *container.HostConfig, error) { @@ -48,31 +47,26 @@ func containerSpec(commandName string, procedure *domain.Procedure, root string, mounts = append(mounts, dockerMount) } - config := &container.Config{ - Image: procedure.Image, - Cmd: procedure.Command, - WorkingDir: procedure.WorkingDir, - Env: envArgs(env), - ExposedPorts: exposedPorts, - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - OpenStdin: true, - Tty: procedure.TTY, - Labels: map[string]string{ - "druid.command": commandName, - "druid.root-hash": rootHash(root), - }, - } - hostConfig := &container.HostConfig{ - Mounts: mounts, - PortBindings: portBindings, - ExtraHosts: dockerExtraHosts(), - } - if identity := procedure.RuntimeIdentity; identity != nil { - config.Labels[workloadidentity.ExecutionLabel] = identity.ExecutionID - } - return config, hostConfig, nil + return &container.Config{ + Image: procedure.Image, + Cmd: procedure.Command, + WorkingDir: procedure.WorkingDir, + Env: envArgs(env), + ExposedPorts: exposedPorts, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + OpenStdin: true, + Tty: procedure.TTY, + Labels: map[string]string{ + "druid.command": commandName, + "druid.root-hash": rootHash(root), + }, + }, &container.HostConfig{ + Mounts: mounts, + PortBindings: portBindings, + ExtraHosts: dockerExtraHosts(), + }, nil } func routeAssignmentForPort(portName string, routing []domain.RuntimeRouteAssignment) (domain.RuntimeRouteAssignment, bool) { diff --git a/internal/runtime/kubernetes/command_authentication_test.go b/internal/runtime/kubernetes/command_authentication_test.go index f6319480..71dcd2c6 100644 --- a/internal/runtime/kubernetes/command_authentication_test.go +++ b/internal/runtime/kubernetes/command_authentication_test.go @@ -18,7 +18,7 @@ import ( func agentPod() *corev1.Pod { return &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "agent-0", Namespace: "games", UID: types.UID("pod-uid"), - Labels: map[string]string{labelManagedBy: "druid", labelComponent: "runtime", labelScrollID: "data", labelRuntimeID: "data", labelCommand: "agent-command", workloadidentity.ExecutionLabel: "execution"}, + Labels: map[string]string{labelManagedBy: "druid", labelComponent: "runtime", labelScrollID: "data", labelRuntimeID: "data", labelCommand: "agent-command"}, Annotations: map[string]string{commandDeploymentAnnotation: "full-deployment-id", labelCommand: "agent_command", commandPermissionsAnnotation: "commands:list logs:read"}}, Spec: corev1.PodSpec{ServiceAccountName: commandServiceAccount, Volumes: []corev1.Volume{pvcVolume("data", "data")}}, Status: corev1.PodStatus{Phase: corev1.PodRunning}, @@ -48,7 +48,6 @@ func TestCommandServiceAccountAuthentication(t *testing.T) { {name: "worker pod", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Labels[labelComponent] = "worker-pull" }}, {name: "missing deployment", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { delete(p.Annotations, commandDeploymentAnnotation) }}, {name: "mismatched command", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Annotations[labelCommand] = "other" }}, - {name: "no execution label", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { delete(p.Labels, workloadidentity.ExecutionLabel) }}, {name: "wrong runtime root", mutate: func(p *corev1.Pod, _ *authv1.TokenReviewStatus) { p.Spec.Volumes[0].PersistentVolumeClaim.ClaimName = "other-data" }}, @@ -82,7 +81,7 @@ func TestCommandServiceAccountAuthentication(t *testing.T) { }) backend := NewWithClient(Config{Namespace: "default"}, client) record, err := backend.AuthenticateCommandIdentity(context.Background(), "credential") - if test.name == "valid" || test.name == "no grants" || test.name == "no execution label" { + if test.name == "valid" || test.name == "no grants" { granted := test.name != "no grants" if err != nil || record.DeploymentID != "full-deployment-id" || record.Allows("commands:list") != granted || record.Allows("logs:read") != granted || record.Allows("commands:run") { t.Fatalf("record=%+v err=%v", record, err) diff --git a/internal/runtime/kubernetes/command_identity.go b/internal/runtime/kubernetes/command_identity.go index 908e5b71..5bb91892 100644 --- a/internal/runtime/kubernetes/command_identity.go +++ b/internal/runtime/kubernetes/command_identity.go @@ -15,7 +15,7 @@ const commandDeploymentAnnotation = "druid.gg/deployment-id" const commandPermissionsAnnotation = "druid.gg/permissions" func commandIdentityAnnotations(identity *domain.RuntimeCommandIdentity, command string) map[string]string { - if identity == nil || !identity.Enabled { + if identity == nil { return nil } return map[string]string{commandDeploymentAnnotation: identity.DeploymentID, labelCommand: command, commandPermissionsAnnotation: strings.Join(identity.Permissions, " ")} diff --git a/internal/runtime/kubernetes/command_identity_test.go b/internal/runtime/kubernetes/command_identity_test.go index ac32c85e..32f09bb1 100644 --- a/internal/runtime/kubernetes/command_identity_test.go +++ b/internal/runtime/kubernetes/command_identity_test.go @@ -2,20 +2,16 @@ package kubernetes import ( "github.com/highcard-dev/daemon/internal/core/domain" - "github.com/highcard-dev/daemon/internal/workloadidentity" "testing" ) func TestCommandIdentityMountedInJobsAndPersistentProcedures(t *testing.T) { - identity := &domain.RuntimeCommandIdentity{ExecutionID: "execution", DeploymentID: "deployment", Enabled: true, Permissions: []string{"commands:list", "logs:read"}} + identity := &domain.RuntimeCommandIdentity{DeploymentID: "deployment", Permissions: []string{"commands:list", "logs:read"}} procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: identity} job, err := procedureJobSpec("runtime", ref("runtime", "data"), "automation", "automation.0", "job", 1, procedure, nil, "", "") if err != nil { t.Fatal(err) } - if job.Labels[workloadidentity.ExecutionLabel] != "execution" || job.Spec.Template.Labels[workloadidentity.ExecutionLabel] != "execution" { - t.Fatal("execution label missing") - } pod := job.Spec.Template.Spec if job.Spec.Template.Annotations[commandDeploymentAnnotation] != "deployment" || job.Spec.Template.Annotations[commandPermissionsAnnotation] != "commands:list logs:read" { t.Fatal("Pod authorization grants missing") @@ -37,24 +33,19 @@ func TestCommandIdentityMountedInJobsAndPersistentProcedures(t *testing.T) { if len(set.Spec.Template.Spec.InitContainers[0].VolumeMounts) != 1 || len(set.Spec.Template.Spec.Containers[0].VolumeMounts) != 1 { t.Fatal("persistent command procedures did not share the execution credential") } - if _, ok := set.Spec.Selector.MatchLabels[workloadidentity.ExecutionLabel]; ok { - t.Fatal("execution identity changed immutable StatefulSet selector") - } if set.Spec.Template.Annotations[commandPermissionsAnnotation] != "commands:list logs:read" { t.Fatal("persistent Pod authorization grants missing") } } func TestCommandsWithoutIdentityReceiveNoCredential(t *testing.T) { - for _, identity := range []*domain.RuntimeCommandIdentity{nil, {ExecutionID: "execution"}} { - procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: identity} - job, err := procedureJobSpec("runtime", ref("runtime", "data"), "command", "command.0", "job", 1, procedure, nil, "", "") - if err != nil { - t.Fatal(err) - } - pod := job.Spec.Template.Spec - if len(pod.Volumes) != 1 || pod.ServiceAccountName != "" || pod.AutomountServiceAccountToken == nil || *pod.AutomountServiceAccountToken || len(job.Spec.Template.Annotations) != 0 { - t.Fatal("non-identity command received credentials") - } + procedure := &domain.Procedure{Image: "busybox"} + job, err := procedureJobSpec("runtime", ref("runtime", "data"), "command", "command.0", "job", 1, procedure, nil, "", "") + if err != nil { + t.Fatal(err) + } + pod := job.Spec.Template.Spec + if len(pod.Volumes) != 1 || pod.ServiceAccountName != "" || pod.AutomountServiceAccountToken == nil || *pod.AutomountServiceAccountToken || len(job.Spec.Template.Annotations) != 0 { + t.Fatal("non-identity command received credentials") } } diff --git a/internal/runtime/kubernetes/procedures.go b/internal/runtime/kubernetes/procedures.go index 951f651e..5a41f789 100644 --- a/internal/runtime/kubernetes/procedures.go +++ b/internal/runtime/kubernetes/procedures.go @@ -119,12 +119,11 @@ func (b *Backend) observeProcedureStatus(command ports.RuntimeCommand, procedure return } b.procedureStatusObserver.ObserveProcedureStatus(ports.ProcedureStatusUpdate{ - ExecutionID: command.ExecutionID, - RuntimeID: command.ScrollID, - Command: command.Name, - Procedure: procedure, - Status: status, - ExitCode: exitCode, + RuntimeID: command.ScrollID, + Command: command.Name, + Procedure: procedure, + Status: status, + ExitCode: exitCode, }) } diff --git a/internal/runtime/kubernetes/resources.go b/internal/runtime/kubernetes/resources.go index 33e01869..000c48d5 100644 --- a/internal/runtime/kubernetes/resources.go +++ b/internal/runtime/kubernetes/resources.go @@ -14,7 +14,6 @@ import ( "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/ports" - "github.com/highcard-dev/daemon/internal/workloadidentity" ) func pvcSpec(namespace string, name string, storageClass string, storageRequest string) *corev1.PersistentVolumeClaim { @@ -228,7 +227,7 @@ func procedureJobSpec(namespace string, root string, commandName string, procedu Containers: []corev1.Container{container}, Volumes: []corev1.Volume{pvcVolume("data", pvc)}, } - applyCommandIdentity(&podSpec, labels, procedure.RuntimeIdentity) + applyCommandIdentity(&podSpec, procedure.RuntimeIdentity) if registrySecret != "" { podSpec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: registrySecret}} } @@ -337,7 +336,7 @@ func persistentStatefulSetSpec(namespace string, root string, commandName string Containers: []corev1.Container{container}, Volumes: []corev1.Volume{pvcVolume("data", pvc)}, } - applyCommandIdentity(&podSpec, labels, mainProcedure.RuntimeIdentity) + applyCommandIdentity(&podSpec, mainProcedure.RuntimeIdentity) if registrySecret != "" { podSpec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: registrySecret}} } @@ -359,15 +358,11 @@ func persistentStatefulSetSpec(namespace string, root string, commandName string }, nil } -func applyCommandIdentity(pod *corev1.PodSpec, labels map[string]string, identity *domain.RuntimeCommandIdentity) { +func applyCommandIdentity(pod *corev1.PodSpec, identity *domain.RuntimeCommandIdentity) { pod.AutomountServiceAccountToken = ptrBool(false) if identity == nil { return } - labels[workloadidentity.ExecutionLabel] = identity.ExecutionID - if !identity.Enabled { - return - } pod.ServiceAccountName = commandServiceAccount expiration := int64(3600) pod.Volumes = append(pod.Volumes, corev1.Volume{Name: "command-identity", VolumeSource: corev1.VolumeSource{Projected: &corev1.ProjectedVolumeSource{Sources: []corev1.VolumeProjection{{ServiceAccountToken: &corev1.ServiceAccountTokenProjection{Audience: commandTokenAudience, ExpirationSeconds: &expiration, Path: "token"}}}}}}) diff --git a/internal/workloadidentity/identity.go b/internal/workloadidentity/identity.go index 4c80f0c1..af081207 100644 --- a/internal/workloadidentity/identity.go +++ b/internal/workloadidentity/identity.go @@ -8,7 +8,6 @@ import ( ) const TokenFile = "/run/secrets/druid/token" -const ExecutionLabel = "druid.execution-id" var ErrUnauthorized = errors.New("invalid workload credential") var ErrUnavailable = errors.New("workload identity verification is unavailable") diff --git a/test/integration/agent_ui_test.go b/test/integration/agent_ui_test.go index b29ea418..97a99ca6 100644 --- a/test/integration/agent_ui_test.go +++ b/test/integration/agent_ui_test.go @@ -202,9 +202,9 @@ commands: "mounts": []any{map[string]any{"path": "/workspace", "sub_path": "."}}, "command": []string{"sh", "-ec", "npm ci --include=optional && npm run build && test -s dist/app.wasm && test -s dist/app.openui"}, }}}}) - accepted := call("run_command", map[string]any{"command": "build_fixture"}) + call("run_command", map[string]any{"command": "build_fixture"}) for { - wait := call("wait_for_command", map[string]any{"command": "build_fixture", "execution_id": accepted["execution_id"], "timeout_seconds": 30}) + wait := call("wait_for_command", map[string]any{"command": "build_fixture", "timeout_seconds": 30}) if wait["timed_out"] == true { continue } diff --git a/test/integration/workload_identity_test.go b/test/integration/workload_identity_test.go index 0864a62e..575fc62d 100644 --- a/test/integration/workload_identity_test.go +++ b/test/integration/workload_identity_test.go @@ -120,7 +120,7 @@ commands: agent: run: persistent identity: - permissions: [commands:list, commands:create, queue:read, commands:run, logs:read, console:write, runtime:read] + permissions: [commands:list, commands:create, commands:remove, queue:read, commands:run, logs:read, console:write, runtime:read] procedures: - id: agent-console image: node:22-alpine @@ -163,7 +163,7 @@ commands: if _, err := supervisor.Run(id, "agent"); err != nil { t.Fatal(err) } - var token, execution string + var token string eventually := func(label string, check func() bool) { t.Helper() deadline := time.Now().Add(90 * time.Second) @@ -181,21 +181,16 @@ commands: if err != nil { return false } - execution = "" - for _, status := range queue["agent"] { - execution = status.ExecutionID - break - } - if execution == "" { + if len(queue["agent"]) == 0 { return false } if kubernetes != nil { - pods, err := kubernetes.CoreV1().Pods(id).List(ctx, metav1.ListOptions{LabelSelector: workloadidentity.ExecutionLabel + "=" + execution}) + pods, err := kubernetes.CoreV1().Pods(id).List(ctx, metav1.ListOptions{LabelSelector: "druid.gg/command=agent"}) if err != nil { return false } for _, pod := range pods.Items { - if pod.Status.Phase != corev1.PodRunning { + if pod.Status.Phase != corev1.PodRunning || pod.DeletionTimestamp != nil { continue } data, err := exec.CommandContext(ctx, "kubectl", "--context", "k3d-druid-gs", "exec", "-n", id, pod.Name, "--", "cat", workloadidentity.TokenFile).Output() @@ -287,14 +282,8 @@ commands: if err != nil || accepted.IsError { t.Fatal("MCP enqueue failed") } - var run struct { - ExecutionID string `json:"execution_id"` - } - if err := json.Unmarshal([]byte(accepted.Content[0].(*mcp.TextContent).Text), &run); err != nil { - t.Fatal(err) - } _ = session.Close() // Accepted work must survive the caller going away. - if _, err := call("wait_for_command", appservices.ToolInput{Command: "echo", ExecutionID: run.ExecutionID, TimeoutSeconds: 30}); err != nil { + if _, err := call("wait_for_command", appservices.ToolInput{Command: "echo", TimeoutSeconds: 30}); err != nil { t.Fatal(err) } result, err := call("read_logs", appservices.ToolInput{Console: "echo-console"}) @@ -305,8 +294,18 @@ commands: if !bytes.Contains(data, []byte("COMPLETED_AFTER_DISCONNECT")) { t.Fatal("completed container logs unavailable") } + removeSession := connect() + removed, err := removeSession.CallTool(ctx, &mcp.CallToolParams{Name: "remove_command", Arguments: map[string]any{"command": "echo"}}) + _ = removeSession.Close() + if err != nil || removed.IsError { + t.Fatalf("real MCP command removal failed: %v, %v", removed, err) + } + updated, err := supervisor.ScrollFile(id) + if err != nil || updated.Commands["echo"] != nil { + t.Fatal("removed command remained in runtime definition") + } if _, err := supervisor.AuthenticateCommand(ctx, token, id); err != nil { - t.Fatal("surviving execution lost its credential") + t.Fatal("surviving agent lost its credential") } if launcher := os.Getenv("DRUID_WORKLOAD_TEST_LAUNCHER"); launcher != "" { bridgeConsole := "bridge-console" @@ -389,7 +388,7 @@ commands: t.Fatal("Docker created identity registry files") } } - oldToken, oldExecution := token, execution + oldToken := token if err := supervisor.AddCommand(id, "agent", agent); err != nil { t.Fatal(err) } @@ -401,7 +400,7 @@ commands: if !readCredential() { return false } - return execution != oldExecution && (kubernetes == nil || token != oldToken) + return kubernetes == nil || token != oldToken }) if _, err := supervisor.AuthenticateCommand(ctx, oldToken, id); kubernetes != nil && err == nil { From 57143cc1458f065ef4fe4dbea05c7a33d9ca887c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 13:45:53 +0100 Subject: [PATCH 10/14] refactor(runtime): move identity setup to backends Provision worker and agent ServiceAccounts together during materialization, not command execution. Keep token metadata and environment injection inside each runtime backend. No migrations, legacy handling or automatic repair. --- .../druid/core/services/command_tools_test.go | 3 -- .../core/services/runtime_identity_test.go | 9 ++-- .../services/runtime_session_execution.go | 50 +++-------------- internal/core/ports/command_runtime.go | 1 - internal/core/ports/services_ports.go | 2 + internal/runtime/docker/procedures.go | 10 ++++ .../kubernetes/command_authentication_test.go | 2 +- .../runtime/kubernetes/command_identity.go | 44 +++++++++++++-- .../kubernetes/command_identity_test.go | 54 ++++++++++++++++++- internal/runtime/kubernetes/procedures.go | 5 ++ internal/runtime/kubernetes/resources_test.go | 4 +- .../runtime/kubernetes/workload_identity.go | 5 +- .../kubernetes/workload_identity_test.go | 24 ++++++++- test/integration/workload_identity_test.go | 8 +++ 14 files changed, 157 insertions(+), 64 deletions(-) diff --git a/apps/druid/core/services/command_tools_test.go b/apps/druid/core/services/command_tools_test.go index 99b4c330..0396c584 100644 --- a/apps/druid/core/services/command_tools_test.go +++ b/apps/druid/core/services/command_tools_test.go @@ -232,9 +232,6 @@ func TestRemoveCommandPermissionsBindingValidationAndStopFailure(t *testing.T) { } } -func (b *toolTestBackend) PrepareCommandIdentity(context.Context, string) error { - return nil -} func (b *toolTestBackend) AuthenticateCommandIdentity(_ context.Context, token string) (workloadidentity.Identity, error) { if record, ok := b.credentials.Load(token); ok { b.authentications.Add(1) diff --git a/apps/druid/core/services/runtime_identity_test.go b/apps/druid/core/services/runtime_identity_test.go index 8690eef3..00266c4a 100644 --- a/apps/druid/core/services/runtime_identity_test.go +++ b/apps/druid/core/services/runtime_identity_test.go @@ -10,7 +10,7 @@ import ( "github.com/highcard-dev/daemon/internal/workloadidentity" ) -func TestHydratedIdentityCommandRetainsTokenProjection(t *testing.T) { +func TestHydratedIdentityCommandPassesConfigurationToBackend(t *testing.T) { supervisor, backend, _ := commandToolsFixture(t) session := supervisor.sessions["tools"] session.workloadAPIURL = "http://runtime.local" @@ -23,8 +23,8 @@ func TestHydratedIdentityCommandRetainsTokenProjection(t *testing.T) { if err := session.runCommand("agent"); err != nil { t.Fatal(err) } - if seen.Command.Procedures[0].RuntimeIdentity == nil { - t.Fatal("hydration lost token projection") + if seen.Command.Identity == nil || seen.WorkloadAPIURL != "http://runtime.local" || seen.Command.Procedures[0].RuntimeIdentity != nil { + t.Fatal("session must pass identity configuration without preparing credentials") } } @@ -38,9 +38,6 @@ func (b podIdentityBackend) AuthenticateCommandIdentity(context.Context, string) return b.record, nil } -func (b podIdentityBackend) PrepareCommandIdentity(context.Context, string) error { - return nil -} func (b podIdentityBackend) Name() string { if b.name != "" { return b.name diff --git a/apps/druid/core/services/runtime_session_execution.go b/apps/druid/core/services/runtime_session_execution.go index 1874a001..d3993958 100644 --- a/apps/druid/core/services/runtime_session_execution.go +++ b/apps/druid/core/services/runtime_session_execution.go @@ -1,16 +1,12 @@ package services import ( - "context" "errors" - "fmt" - "net/url" "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/ports" coreservices "github.com/highcard-dev/daemon/internal/core/services" "github.com/highcard-dev/daemon/internal/utils/logger" - "github.com/highcard-dev/daemon/internal/workloadidentity" "go.uber.org/zap" ) @@ -72,45 +68,15 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin procedureEnv[procedure][key] = value } } - requiresIdentity := s.runtimeBackend.Name() != "docker" - var identity *domain.RuntimeCommandIdentity - if command.Identity != nil && requiresIdentity { - backend, ok := s.runtimeBackend.(ports.CommandIdentityBackend) - if !ok || s.workloadAPIURL == "" { - return fmt.Errorf("command identity requires runtime support and workload API URL") - } - if err := backend.PrepareCommandIdentity(context.Background(), root); err != nil { - return err - } - identity = &domain.RuntimeCommandIdentity{DeploymentID: scrollID, Permissions: command.Identity.Permissions} - } - // Never mutate the persisted Scroll or leak transient mount references. - copyCommand := *command - copyCommand.Procedures = make([]*domain.Procedure, len(command.Procedures)) - for index, procedure := range command.Procedures { - copyProcedure := *procedure - copyProcedure.RuntimeIdentity = identity - copyCommand.Procedures[index] = ©Procedure - name := domain.ProcedureName(cmd, index, procedure) - if procedureEnv[name] == nil { - procedureEnv[name] = map[string]string{} - } - if s.workloadAPIURL != "" && (command.Identity != nil || !requiresIdentity) { - procedureEnv[name]["DRUID_TOKEN_FILE"] = "" - if requiresIdentity { - procedureEnv[name]["DRUID_TOKEN_FILE"] = workloadidentity.TokenFile - } - procedureEnv[name]["DRUID_API_URL"] = s.workloadAPIURL + "/" + url.PathEscape(scrollID) - } - } exitCode, err := s.runtimeBackend.RunCommand(ports.RuntimeCommand{ - Name: cmd, - ScrollID: scrollID, - Command: ©Command, - Root: root, - Ports: runtimePorts, - Routing: routing, - ProcedureEnv: procedureEnv, + WorkloadAPIURL: s.workloadAPIURL, + Name: cmd, + ScrollID: scrollID, + Command: command, + Root: root, + Ports: runtimePorts, + Routing: routing, + ProcedureEnv: procedureEnv, }) if err != nil { s.setCommandProcedureStatus(cmd, command, domain.ScrollLockStatusError, exitCode) diff --git a/internal/core/ports/command_runtime.go b/internal/core/ports/command_runtime.go index 3b18d7d1..2a6cbbbb 100644 --- a/internal/core/ports/command_runtime.go +++ b/internal/core/ports/command_runtime.go @@ -8,7 +8,6 @@ import ( // CommandIdentityBackend is implemented by runtimes that authenticate command workloads. // It does not replace infrastructure ServiceAccount authentication. type CommandIdentityBackend interface { - PrepareCommandIdentity(context.Context, string) error AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Identity, error) } diff --git a/internal/core/ports/services_ports.go b/internal/core/ports/services_ports.go index cc6cfa2f..6eca1aa8 100644 --- a/internal/core/ports/services_ports.go +++ b/internal/core/ports/services_ports.go @@ -95,6 +95,8 @@ type RuntimeScrollStore interface { } type RuntimeCommand struct { + // WorkloadAPIURL is the configured base URL for backend-provided workload tools. + WorkloadAPIURL string // Name identifies the command within the Scroll. Name string // ScrollID identifies the runtime Scroll whose command is being executed. diff --git a/internal/runtime/docker/procedures.go b/internal/runtime/docker/procedures.go index a937e9d1..2d2d3068 100644 --- a/internal/runtime/docker/procedures.go +++ b/internal/runtime/docker/procedures.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "maps" + "net/url" "github.com/docker/docker/api/types/container" "github.com/highcard-dev/daemon/internal/core/domain" @@ -19,6 +21,14 @@ func (b *Backend) RunCommand(command ports.RuntimeCommand) (*int, error) { if env == nil { env = procedure.Env } + if command.WorkloadAPIURL != "" { + env = maps.Clone(env) + if env == nil { + env = make(map[string]string) + } + env["DRUID_TOKEN_FILE"] = "" + env["DRUID_API_URL"] = command.WorkloadAPIURL + "/" + url.PathEscape(command.ScrollID) + } logger.Log().Info("Starting Docker procedure", zap.String("scroll_id", command.ScrollID), zap.String("command", command.Name), diff --git a/internal/runtime/kubernetes/command_authentication_test.go b/internal/runtime/kubernetes/command_authentication_test.go index 71dcd2c6..3ef787e0 100644 --- a/internal/runtime/kubernetes/command_authentication_test.go +++ b/internal/runtime/kubernetes/command_authentication_test.go @@ -107,7 +107,7 @@ func TestCommandIdentityProvisioningOnlyCreatesAnUnprivilegedServiceAccount(t *t client := fake.NewSimpleClientset() backend := NewWithClient(Config{Namespace: "default"}, client) for index := 0; index < 2; index++ { - if err := backend.PrepareCommandIdentity(context.Background(), ref("games", "data")); err != nil { + if err := backend.ensureRuntimeServiceAccounts(context.Background(), "games"); err != nil { t.Fatal(err) } } diff --git a/internal/runtime/kubernetes/command_identity.go b/internal/runtime/kubernetes/command_identity.go index 5bb91892..e823747a 100644 --- a/internal/runtime/kubernetes/command_identity.go +++ b/internal/runtime/kubernetes/command_identity.go @@ -2,9 +2,13 @@ package kubernetes import ( "context" + "fmt" + "maps" + "net/url" "strings" "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" "github.com/highcard-dev/daemon/internal/workloadidentity" corev1 "k8s.io/api/core/v1" ) @@ -21,12 +25,42 @@ func commandIdentityAnnotations(identity *domain.RuntimeCommandIdentity, command return map[string]string{commandDeploymentAnnotation: identity.DeploymentID, labelCommand: command, commandPermissionsAnnotation: strings.Join(identity.Permissions, " ")} } -func (b *Backend) PrepareCommandIdentity(ctx context.Context, root string) error { - namespace, _, err := parseRef(root) - if err == nil { - err = b.ensureRuntimeServiceAccount(ctx, namespace, commandServiceAccount) +func prepareCommandIdentity(command ports.RuntimeCommand) (ports.RuntimeCommand, error) { + if command.Command.Identity == nil { + return command, nil } - return err + if command.WorkloadAPIURL == "" { + return command, fmt.Errorf("command identity requires workload API URL") + } + definition := *command.Command + definition.Procedures = make([]*domain.Procedure, len(command.Command.Procedures)) + command.ProcedureEnv = maps.Clone(command.ProcedureEnv) + if command.ProcedureEnv == nil { + command.ProcedureEnv = make(map[string]map[string]string) + } + identity := &domain.RuntimeCommandIdentity{DeploymentID: command.ScrollID, Permissions: definition.Identity.Permissions} + for index, procedure := range command.Command.Procedures { + if procedure == nil { + continue + } + copyProcedure := *procedure + copyProcedure.RuntimeIdentity = identity + definition.Procedures[index] = ©Procedure + name := domain.ProcedureName(command.Name, index, procedure) + env := command.ProcedureEnv[name] + if env == nil { + env = procedure.Env + } + env = maps.Clone(env) + if env == nil { + env = make(map[string]string) + } + env["DRUID_TOKEN_FILE"] = workloadidentity.TokenFile + env["DRUID_API_URL"] = command.WorkloadAPIURL + "/" + url.PathEscape(command.ScrollID) + command.ProcedureEnv[name] = env + } + command.Command = &definition + return command, nil } func (b *Backend) AuthenticateCommandIdentity(ctx context.Context, token string) (workloadidentity.Identity, error) { diff --git a/internal/runtime/kubernetes/command_identity_test.go b/internal/runtime/kubernetes/command_identity_test.go index 32f09bb1..e1a146c0 100644 --- a/internal/runtime/kubernetes/command_identity_test.go +++ b/internal/runtime/kubernetes/command_identity_test.go @@ -1,10 +1,62 @@ package kubernetes import ( - "github.com/highcard-dev/daemon/internal/core/domain" + "reflect" "testing" + + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/highcard-dev/daemon/internal/core/ports" + k8sfake "k8s.io/client-go/kubernetes/fake" ) +func TestPrepareCommandIdentityIsBackendLocal(t *testing.T) { + procedure := &domain.Procedure{Image: "busybox", Env: map[string]string{"SOURCE": "procedure"}} + resolved := map[string]string{"SOURCE": "resolved"} + definition := &domain.CommandInstructionSet{Identity: &domain.CommandIdentity{Permissions: []string{"commands:list"}}, Procedures: []*domain.Procedure{procedure, procedure}} + input := ports.RuntimeCommand{Name: "agent", ScrollID: "deployment", WorkloadAPIURL: "http://runtime.local", Command: definition, ProcedureEnv: map[string]map[string]string{"agent.0": resolved}} + prepared, err := prepareCommandIdentity(input) + if err != nil { + t.Fatal(err) + } + for index, p := range prepared.Command.Procedures { + name := domain.ProcedureName("agent", index, p) + if p.RuntimeIdentity == nil || p.RuntimeIdentity.DeploymentID != "deployment" || !reflect.DeepEqual(p.RuntimeIdentity.Permissions, definition.Identity.Permissions) { + t.Fatal("missing backend identity metadata") + } + env := prepared.ProcedureEnv[name] + if env["DRUID_TOKEN_FILE"] != "/run/secrets/druid/token" || env["DRUID_API_URL"] != "http://runtime.local/deployment" { + t.Fatalf("environment=%v", env) + } + } + if prepared.ProcedureEnv["agent.0"]["SOURCE"] != "resolved" || prepared.ProcedureEnv["agent.1"]["SOURCE"] != "procedure" { + t.Fatal("existing environment was lost") + } + if procedure.RuntimeIdentity != nil || len(procedure.Env) != 1 || len(resolved) != 1 || len(input.ProcedureEnv) != 1 { + t.Fatal("backend mutated input definition/environment") + } + input.WorkloadAPIURL = "" + if _, err := prepareCommandIdentity(input); err == nil { + t.Fatal("missing API URL accepted") + } + input.Command = &domain.CommandInstructionSet{Procedures: []*domain.Procedure{procedure}} + input.WorkloadAPIURL = "http://runtime.local" + unchanged, err := prepareCommandIdentity(input) + if err != nil || unchanged.Command != input.Command || unchanged.Command.Procedures[0].RuntimeIdentity != nil { + t.Fatal("non-identity command was changed") + } +} + +func TestRunCommandDoesNotProvisionServiceAccounts(t *testing.T) { + client := k8sfake.NewSimpleClientset() + backend := NewWithClient(Config{Namespace: "games"}, client) + _, _ = backend.RunCommand(ports.RuntimeCommand{Name: "agent", ScrollID: "deployment", Root: ref("games", "data"), WorkloadAPIURL: "http://runtime.local", Command: &domain.CommandInstructionSet{Identity: &domain.CommandIdentity{}, Procedures: []*domain.Procedure{{}}}}) + for _, action := range client.Actions() { + if action.GetResource().Resource == "serviceaccounts" { + t.Fatalf("command execution accessed ServiceAccounts: %v", action) + } + } +} + func TestCommandIdentityMountedInJobsAndPersistentProcedures(t *testing.T) { identity := &domain.RuntimeCommandIdentity{DeploymentID: "deployment", Permissions: []string{"commands:list", "logs:read"}} procedure := &domain.Procedure{Image: "busybox", RuntimeIdentity: identity} diff --git a/internal/runtime/kubernetes/procedures.go b/internal/runtime/kubernetes/procedures.go index 5a41f789..eba3ef6d 100644 --- a/internal/runtime/kubernetes/procedures.go +++ b/internal/runtime/kubernetes/procedures.go @@ -19,6 +19,11 @@ func (b *Backend) RunCommand(command ports.RuntimeCommand) (*int, error) { logger.Log().Error("Cannot run Kubernetes command", zap.String("scroll_id", command.ScrollID), zap.String("command", command.Name), zap.Error(err)) return nil, err } + var err error + command, err = prepareCommandIdentity(command) + if err != nil { + return nil, err + } logger.Log().Info("Running Kubernetes command", zap.String("scroll_id", command.ScrollID), zap.String("command", command.Name), diff --git a/internal/runtime/kubernetes/resources_test.go b/internal/runtime/kubernetes/resources_test.go index 288e96d3..c7844fd4 100644 --- a/internal/runtime/kubernetes/resources_test.go +++ b/internal/runtime/kubernetes/resources_test.go @@ -528,8 +528,8 @@ func TestSpawnPullWorkerCreateUsesFinalPVCAndWorkerJob(t *testing.T) { if err != nil { t.Fatal(err) } - if len(accounts.Items) != 1 || accounts.Items[0].Name != runtimeWorkerServiceAccount { - t.Fatalf("service accounts = %#v, want worker identity", accounts.Items) + if len(accounts.Items) != 2 { + t.Fatalf("service accounts = %#v, want worker and agent identities", accounts.Items) } if len(jobs) != 1 { t.Fatalf("jobs = %d, want 1", len(jobs)) diff --git a/internal/runtime/kubernetes/workload_identity.go b/internal/runtime/kubernetes/workload_identity.go index c44909c9..9cfd49a9 100644 --- a/internal/runtime/kubernetes/workload_identity.go +++ b/internal/runtime/kubernetes/workload_identity.go @@ -92,7 +92,10 @@ func (b *Backend) ensureRuntimeServiceAccount(ctx context.Context, namespace str } func (b *Backend) ensureRuntimeServiceAccounts(ctx context.Context, namespace string) error { - return b.ensureRuntimeServiceAccount(ctx, namespace, runtimeWorkerServiceAccount) + if err := b.ensureRuntimeServiceAccount(ctx, namespace, runtimeWorkerServiceAccount); err != nil { + return err + } + return b.ensureRuntimeServiceAccount(ctx, namespace, commandServiceAccount) } func (b *Backend) isOperatorServiceAccount(namespace string, serviceAccount string) bool { diff --git a/internal/runtime/kubernetes/workload_identity_test.go b/internal/runtime/kubernetes/workload_identity_test.go index b40170fa..0192e872 100644 --- a/internal/runtime/kubernetes/workload_identity_test.go +++ b/internal/runtime/kubernetes/workload_identity_test.go @@ -2,6 +2,7 @@ package kubernetes import ( "context" + "errors" "testing" authv1 "k8s.io/api/authentication/v1" @@ -13,6 +14,25 @@ import ( k8stesting "k8s.io/client-go/testing" ) +func TestEnsureRuntimeServiceAccountsPropagatesFailures(t *testing.T) { + for _, failing := range []string{runtimeWorkerServiceAccount, commandServiceAccount} { + t.Run(failing, func(t *testing.T) { + client := k8sfake.NewSimpleClientset() + expected := errors.New("provisioning failed") + client.PrependReactor("create", "serviceaccounts", func(action k8stesting.Action) (bool, runtime.Object, error) { + if action.(k8stesting.CreateAction).GetObject().(*corev1.ServiceAccount).Name == failing { + return true, nil, expected + } + return false, nil, nil + }) + backend := NewWithClient(Config{Namespace: "games"}, client) + if err := backend.ensureRuntimeServiceAccounts(context.Background(), "games"); !errors.Is(err, expected) { + t.Fatalf("error=%v", err) + } + }) + } +} + func TestAuthenticateWorkloadRejectsUnrecognizedServiceAccount(t *testing.T) { client := k8sfake.NewSimpleClientset(&corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "other-0", Namespace: "games", UID: types.UID("pod-uid"), Labels: map[string]string{ @@ -70,8 +90,8 @@ func TestEnsureRuntimeServiceAccountsCreatesBothAndIsIdempotent(t *testing.T) { if err != nil { t.Fatal(err) } - if len(accounts.Items) != 1 { - t.Fatalf("service accounts = %#v, want one", accounts.Items) + if len(accounts.Items) != 2 { + t.Fatalf("service accounts = %#v, want two", accounts.Items) } for _, account := range accounts.Items { if account.Labels[labelManagedBy] != "druid" || account.Labels[labelComponent] != "workload-identity" { diff --git a/test/integration/workload_identity_test.go b/test/integration/workload_identity_test.go index 575fc62d..a619802c 100644 --- a/test/integration/workload_identity_test.go +++ b/test/integration/workload_identity_test.go @@ -81,6 +81,14 @@ func TestWorkloadIdentityRealRuntime(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = kubernetes.CoreV1().Namespaces().Delete(context.Background(), id, metav1.DeleteOptions{}) }) + // This fixture creates runtime storage directly instead of materializing a Scroll. + // Provision the namespace accounts normally created during materialization. + for _, name := range []string{"druid-runtime-worker", "druid-workload-agent"} { + disabled := false + if _, err := kubernetes.CoreV1().ServiceAccounts(id).Create(ctx, &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: name}, AutomountServiceAccountToken: &disabled}, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } // A dedicated kubeconfig prevents the backend from using a different // current context than the explicitly selected local test client. raw, err := config.RawConfig() From a07cf1195d180babf40cf1f5d45b207085db8cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Thu, 29 Oct 2026 15:40:46 +0100 Subject: [PATCH 11/14] fix(dev): verify dashboard JWTs locally Use local Auth JWKS so supplied dashboard credentials validate instead of failing under anonymous-only local configuration. --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 95e26591..2acf1a51 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ DRUID_WORKER_CALLBACK_URL_WINDOWS ?= http://$(WSL_HOST_IP):8083 DRUID_WATCH_ARGS ?= daemon --runtime kubernetes --listen 127.0.0.1:8081 --public-listen 127.0.0.1:8082 --worker-callback-listen $(DRUID_WORKER_CALLBACK_LISTEN) --worker-callback-url $(DRUID_WORKER_CALLBACK_URL) --unsafe-allow-unauthenticated-management --unsafe-allow-unauthenticated-public --k8s-namespace $(DRUID_K8S_NAMESPACE) --k8s-pull-image $(DRUID_K8S_PULL_IMAGE) export DRUID_K8S_UI_S3_BUCKET ?= druid-ui export DRUID_WORKLOAD_API_URL ?= http://host.docker.internal:8082 +export DRUID_AUTH_JWKS_URL ?= http://127.0.0.1:3010/v2/jwks export DRUID_K8S_UI_S3_PUBLIC_BASE_URL ?= http://127.0.0.1:9000/druid-ui export DRUID_K8S_UI_S3_REGION ?= us-east-1 export DRUID_K8S_UI_S3_ENDPOINT ?= http://host.docker.internal:9000 From 7a498dcfee87d05bce62943cc8c3e4bfa81788f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Fri, 30 Oct 2026 15:31:18 +0100 Subject: [PATCH 12/14] refactor(runtime): separate REST and MCP handlers Configure the workload API URL during supervisor construction so backend creation cannot observe incomplete configuration. --- apps/druid/adapters/cli/daemon.go | 5 +- .../adapters/http/handlers/command_handler.go | 61 ++++++ .../http/handlers/command_tool_request.go | 48 +++++ .../adapters/http/handlers/command_tools.go | 198 ------------------ .../http/handlers/command_tools_test.go | 38 ++-- .../adapters/http/handlers/console_handler.go | 17 ++ .../handlers/docker_command_tools_test.go | 9 +- apps/druid/adapters/http/handlers/mcp.go | 63 ++++++ .../adapters/http/handlers/mcp_commands.go | 40 ++++ .../adapters/http/handlers/mcp_console.go | 7 + .../adapters/http/handlers/mcp_schema.go | 45 ++++ .../adapters/http/handlers/mcp_status.go | 5 + apps/druid/adapters/http/handlers/mcp_ui.go | 16 ++ apps/druid/adapters/http/handlers/routes.go | 14 +- .../adapters/http/handlers/routes_test.go | 2 +- apps/druid/core/services/runtime_identity.go | 14 -- .../core/services/runtime_identity_test.go | 31 ++- .../druid/core/services/runtime_supervisor.go | 19 +- .../core/services/runtime_supervisor_test.go | 2 +- test/integration/agent_ui_test.go | 5 +- test/integration/workload_identity_test.go | 22 +- 21 files changed, 391 insertions(+), 270 deletions(-) create mode 100644 apps/druid/adapters/http/handlers/command_handler.go create mode 100644 apps/druid/adapters/http/handlers/command_tool_request.go delete mode 100644 apps/druid/adapters/http/handlers/command_tools.go create mode 100644 apps/druid/adapters/http/handlers/console_handler.go create mode 100644 apps/druid/adapters/http/handlers/mcp.go create mode 100644 apps/druid/adapters/http/handlers/mcp_commands.go create mode 100644 apps/druid/adapters/http/handlers/mcp_console.go create mode 100644 apps/druid/adapters/http/handlers/mcp_schema.go create mode 100644 apps/druid/adapters/http/handlers/mcp_status.go create mode 100644 apps/druid/adapters/http/handlers/mcp_ui.go diff --git a/apps/druid/adapters/cli/daemon.go b/apps/druid/adapters/cli/daemon.go index 5a4a3098..d83580a2 100644 --- a/apps/druid/adapters/cli/daemon.go +++ b/apps/druid/adapters/cli/daemon.go @@ -145,14 +145,11 @@ func runRuntimeDaemon() error { return err } manager := services.NewRuntimeScrollManager(runtime.Store) - supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, manager, runtime) + supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, manager, runtime, runtimeWorkloadAPIURL) if err != nil { return err } callbacks := appservices.NewWorkerCallbackManager() - if err := supervisor.SetWorkloadAPIURL(runtimeWorkloadAPIURL); err != nil { - return err - } supervisor.SetWorkerTimeout(runtimeWorkerTimeout) callbackConfig := ports.RuntimeWorkerCallbackConfig{ Listen: runtimeWorkerCallbackListen, diff --git a/apps/druid/adapters/http/handlers/command_handler.go b/apps/druid/adapters/http/handlers/command_handler.go new file mode 100644 index 00000000..e1262267 --- /dev/null +++ b/apps/druid/adapters/http/handlers/command_handler.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" + services "github.com/highcard-dev/daemon/apps/druid/core/services" +) + +func (h *ScrollHandler) callCommandToolHTTP(c *fiber.Ctx, name string) error { + var input services.ToolInput + if len(c.Body()) > 0 { + var err error + input, err = commandToolInput(c.Body()) + if err != nil { + return fiber.NewError(400, "invalid tool request") + } + } + input.Command = c.Params("command", input.Command) + input.Console = c.Params("console", input.Console) + input.Scope = c.Params("scope", input.Scope) + if c.Method() == "GET" { + input.TailLines = c.QueryInt("tail_lines", 0) + input.TimeoutSeconds = c.QueryInt("timeout_seconds", 0) + } + result, err := h.supervisor.CallCommandTool(c.UserContext(), bearer(c.Get("Authorization")), c.Params("id"), name, input) + if err != nil { + return fiber.NewError(commandToolStatus(err), commandToolMessage(err)) + } + return c.JSON(result) +} + +func (h *ScrollHandler) GetDaemonCommands(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "list_commands") +} + +func (h *ScrollHandler) CreateDaemonCommand(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "create_command") +} + +func (h *ScrollHandler) WaitDaemonCommand(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "wait_for_command") +} + +func (h *ScrollHandler) GetWorkloadQueue(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "get_queue") +} + +func (h *ScrollHandler) RunWorkloadCommand(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "run_command") +} + +func (h *ScrollHandler) RemoveWorkloadCommand(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "remove_command") +} + +func (h *ScrollHandler) PublishWorkloadUIPackage(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "publish_ui") +} + +func (h *ScrollHandler) GetDaemonStatus(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "get_deployment_status") +} diff --git a/apps/druid/adapters/http/handlers/command_tool_request.go b/apps/druid/adapters/http/handlers/command_tool_request.go new file mode 100644 index 00000000..10ac57f7 --- /dev/null +++ b/apps/druid/adapters/http/handlers/command_tool_request.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + + services "github.com/highcard-dev/daemon/apps/druid/core/services" + "github.com/highcard-dev/daemon/internal/workloadidentity" +) + +func commandToolStatus(err error) int { + var failure *services.ToolError + if errors.As(err, &failure) { + return failure.Status + } + if errors.Is(err, workloadidentity.ErrUnauthorized) { + return 401 + } + if errors.Is(err, workloadidentity.ErrUnavailable) { + return 503 + } + return 500 +} + +func commandToolMessage(err error) string { + if commandToolStatus(err) == 500 { + return "runtime tool failed" + } + return workloadidentity.Redact(err.Error()) +} + +func bearer(header string) string { + parts := strings.Fields(header) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "" + } + return parts[1] +} + +func commandToolInput(data []byte) (services.ToolInput, error) { + var input services.ToolInput + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err := decoder.Decode(&input) + return input, err +} diff --git a/apps/druid/adapters/http/handlers/command_tools.go b/apps/druid/adapters/http/handlers/command_tools.go deleted file mode 100644 index ad459901..00000000 --- a/apps/druid/adapters/http/handlers/command_tools.go +++ /dev/null @@ -1,198 +0,0 @@ -package handlers - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net/http" - "strings" - - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/adaptor" - "github.com/google/jsonschema-go/jsonschema" - services "github.com/highcard-dev/daemon/apps/druid/core/services" - "github.com/highcard-dev/daemon/internal/core/domain" - "github.com/highcard-dev/daemon/internal/workloadidentity" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -func commandToolStatus(err error) int { - var failure *services.ToolError - if errors.As(err, &failure) { - return failure.Status - } - if errors.Is(err, workloadidentity.ErrUnauthorized) { - return 401 - } - if errors.Is(err, workloadidentity.ErrUnavailable) { - return 503 - } - return 500 -} - -func commandToolMessage(err error) string { - if commandToolStatus(err) == 500 { - return "runtime tool failed" - } - return workloadidentity.Redact(err.Error()) -} - -func bearer(header string) string { - parts := strings.Fields(header) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - return "" - } - return parts[1] -} - -func commandToolInput(data []byte) (services.ToolInput, error) { - var input services.ToolInput - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - err := decoder.Decode(&input) - return input, err -} - -func (h *ScrollHandler) commandToolHTTP(name string) fiber.Handler { - return func(c *fiber.Ctx) error { - var input services.ToolInput - if len(c.Body()) > 0 { - var err error - input, err = commandToolInput(c.Body()) - if err != nil { - return fiber.NewError(400, "invalid tool request") - } - } - input.Command = c.Params("command", input.Command) - input.Console = c.Params("console", input.Console) - input.Scope = c.Params("scope", input.Scope) - if c.Method() == "GET" { - input.TailLines = c.QueryInt("tail_lines", 0) - input.TimeoutSeconds = c.QueryInt("timeout_seconds", 0) - } - result, err := h.supervisor.CallCommandTool(c.UserContext(), bearer(c.Get("Authorization")), c.Params("id"), name, input) - if err != nil { - return fiber.NewError(commandToolStatus(err), commandToolMessage(err)) - } - return c.JSON(result) - } -} - -func (h *ScrollHandler) registerCommandTools(app *fiber.App) { - app.Get("/:id/api/v1/commands", h.commandToolHTTP("list_commands")) - app.Post("/:id/api/v1/commands", h.commandToolHTTP("create_command")) - app.Get("/:id/api/v1/commands/:command/wait", h.commandToolHTTP("wait_for_command")) - app.Get("/:id/api/v1/consoles/:console/logs", h.commandToolHTTP("read_logs")) - app.Post("/:id/api/v1/consoles/:console/logs/search", h.commandToolHTTP("search_logs")) - app.Post("/:id/api/v1/consoles/:console/stdin", h.commandToolHTTP("send_stdin")) - app.Get("/:id/api/v1/status", h.commandToolHTTP("get_deployment_status")) - app.Get("/:id/api/v1/queue", h.ownerOrWorkload(h.commandToolHTTP("get_queue")), h.PublicAuth, h.GetDaemonQueue) - app.Post("/:id/api/v1/command", h.ownerOrWorkload(h.commandToolHTTP("run_command")), h.PublicAuth, h.RunDaemonCommand) - app.Delete("/:id/api/v1/scroll/commands/:command", h.ownerOrWorkload(h.commandToolHTTP("remove_command")), h.PublicAuth, h.RemoveDaemonCommand) - app.Post("/:id/api/v1/ui/packages/:scope/publish", h.ownerOrWorkload(h.commandToolHTTP("publish_ui")), h.PublicAuth, h.PublishDaemonUIPackage) - app.All("/:id/mcp", func(c *fiber.Ctx) error { - deployment, token := c.Params("id"), bearer(c.Get("Authorization")) - record, err := h.supervisor.AuthenticateCommand(c.UserContext(), token, deployment) - if err != nil { - return fiber.NewError(commandToolStatus(err), commandToolMessage(err)) - } - handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { - server := mcp.NewServer(&mcp.Implementation{Name: "druid", Version: "1.0.0"}, nil) - for _, definition := range commandToolDefinitions() { - name := definition.Name - if !record.Allows(services.ToolPermissions[name]) { - continue - } - server.AddTool(definition, func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - input, err := commandToolInput(request.Params.Arguments) - if err != nil { - return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "invalid tool request"}}}, nil - } - result, err := h.supervisor.CallCommandTool(ctx, token, deployment, name, input) - if err != nil { - return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: commandToolMessage(err)}}}, nil - } - data, err := json.Marshal(result) - if err != nil { - return nil, err - } - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}}, nil - }) - } - return server - }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) - return adaptor.HTTPHandler(handler)(c) - }) -} - -// OpenHands drops items/properties for nullable type arrays. Optional fields can -// be omitted instead; advertise concrete types without duplicating domain types. -func concreteToolSchema(schema *jsonschema.Schema) { - if schema == nil { - return - } - if len(schema.Types) == 2 { - for index, kind := range schema.Types { - if kind == "null" { - schema.Type = schema.Types[1-index] - schema.Types = nil - break - } - } - } - for _, property := range schema.Properties { - concreteToolSchema(property) - } - concreteToolSchema(schema.Items) - concreteToolSchema(schema.AdditionalProperties) -} - -func commandToolDefinitions() []*mcp.Tool { - // Derive the vocabulary from the existing command type, not a second schema. - instructions, err := jsonschema.For[domain.CommandInstructionSet](nil) - if err != nil { - panic(err) // A programming error in the fixed command type. - } - delete(instructions.Properties, "identity") - concreteToolSchema(instructions) - procedures := instructions.Properties["procedures"] - minimumProcedures := 1 - procedures.MinItems = &minimumProcedures - // The runtime accepts omitted ignore_failure as false. - procedure := procedures.Items - procedure.Required = nil - procedure.Properties["type"].Enum = []any{string(domain.ProcedureTypeContainer), string(domain.ProcedureTypeSignal)} - run := instructions.Properties["run"] - run.Enum = []any{string(domain.RunModeAlways), string(domain.RunModeOnce), string(domain.RunModeRestart), string(domain.RunModePersistent)} - stringField := func(description string) map[string]any { - return map[string]any{"type": "string", "description": description} - } - integerField := func(max int) map[string]any { return map[string]any{"type": "integer", "minimum": 1, "maximum": max} } - tool := func(name, description string, properties map[string]any, required ...string) *mcp.Tool { - if properties == nil { - properties = map[string]any{} - } - schema := map[string]any{"type": "object", "properties": properties, "additionalProperties": false} - if len(required) > 0 { - schema["required"] = required - } - return &mcp.Tool{Name: name, Description: description, InputSchema: schema} - } - return []*mcp.Tool{ - tool("create_command", `Register a new persistent deployment command without executing it. Instructions define container images, arguments, environments and deployment-data mounts; this creates container workloads, not host-shell execution. Existing names and workload identity grants are rejected. Use run_command afterward. Example: {"command":"example-echo","instructions":{"procedures":[{"image":"busybox:1.37","command":["echo","hello"]}]}}`, map[string]any{"command": stringField("New command name"), "instructions": instructions}, "command", "instructions"), - tool("list_commands", "List existing Scroll commands and console IDs, without secrets.", nil), - tool("remove_command", "Stop a named command and remove its stored definition, queue/status entry and runtime containers. This does not delete the deployment or its data. Removing a command also removes its console/log access.", map[string]any{"command": stringField("Command name to stop and remove")}, "command"), - tool("get_queue", "Read current command/procedure status.", nil), - tool("get_deployment_status", "Read deployment state, installed release and ports/endpoints.", nil), - tool("run_command", "Enqueue an existing named command; inspect its current status and logs for completion.", map[string]any{"command": stringField("Existing command name")}, "command"), - tool("wait_for_command", "Wait up to 30 seconds for the named command's current status. A restart or replacement under the same name is not distinguished.", map[string]any{"command": stringField("Command name"), "timeout_seconds": integerField(30)}, "command"), - tool("read_logs", "Read a finite recent console snapshot (default 200 lines, maximum 64 KiB). Application logs may contain secrets.", map[string]any{"console": stringField("Console ID from list_commands"), "tail_lines": integerField(1000)}, "console"), - tool("search_logs", "Case-insensitive literal search of recent logs; maximum 50 matches and 1 MiB scanned.", map[string]any{"console": stringField("Console ID"), "query": stringField("Literal search text"), "tail_lines": integerField(10000)}, "console", "query"), - tool("send_stdin", "Write exact text to an existing running console. Include a newline to submit a line. Does not start a shell.", map[string]any{"console": stringField("Console ID"), "text": stringField("Exact UTF-8 input, at most 8 KiB")}, "console", "text"), - tool("publish_ui", "Publish an already built Druid UI WASM/OpenUI pair for this deployment. First show focused previews, explain changes and scope, ask for publishing approval, end your turn and wait for a subsequent explicit customer reply. The original UI-change request is not approval. Revisions require renewed approval; failed previews must not be published automatically. For behavior-only changes, explain the preview limitation and still obtain approval. Do not bypass approval through HTTP, terminal commands, Developer Mode or live watchers. This is an agent instruction, not a server-enforced approval check. Does not build or publish npm/Scroll artifacts. Choose private or public explicitly; path defaults to /dist/app.wasm with an adjacent .openui document. Returns the published package only after upload succeeds.", map[string]any{ - "scope": map[string]any{"type": "string", "enum": []string{"private", "public"}, "description": "UI scope; use private unless the customer requests public UI"}, - "path": stringField("Optional scope-relative .wasm path; the matching .openui file must be adjacent"), - }, "scope"), - } -} diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go index 28c6be66..0ff2cfc8 100644 --- a/apps/druid/adapters/http/handlers/command_tools_test.go +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -22,34 +22,22 @@ import ( ) func TestPublishUIDescriptionRequiresSeparateCustomerApproval(t *testing.T) { - for _, definition := range commandToolDefinitions() { - if definition.Name != "publish_ui" { - continue + definition := publishUITool() + for _, instruction := range []string{"focused previews", "end your turn and wait", "subsequent explicit customer reply", "original UI-change request is not approval", "Revisions require renewed approval", "failed previews", "behavior-only changes", "Do not bypass approval", "not a server-enforced approval check"} { + if !strings.Contains(definition.Description, instruction) { + t.Errorf("publish_ui description missing %q", instruction) } - for _, instruction := range []string{"focused previews", "end your turn and wait", "subsequent explicit customer reply", "original UI-change request is not approval", "Revisions require renewed approval", "failed previews", "behavior-only changes", "Do not bypass approval", "not a server-enforced approval check"} { - if !strings.Contains(definition.Description, instruction) { - t.Errorf("publish_ui description missing %q", instruction) - } - } - return } - t.Fatal("publish_ui tool is missing") } func TestCommandWaitSchemaUsesOnlyCommandNameAndTimeout(t *testing.T) { - for _, tool := range commandToolDefinitions() { - if tool.Name != "wait_for_command" { - continue - } - schema := tool.InputSchema.(map[string]any) - properties := schema["properties"].(map[string]any) - required := schema["required"].([]string) - if len(properties) != 2 || properties["command"] == nil || properties["timeout_seconds"] == nil || len(required) != 1 || required[0] != "command" { - t.Fatal("command waiting requires more than a name and optional timeout") - } - return + tool := waitForCommandTool() + schema := tool.InputSchema.(map[string]any) + properties := schema["properties"].(map[string]any) + required := schema["required"].([]string) + if len(properties) != 2 || properties["command"] == nil || properties["timeout_seconds"] == nil || len(required) != 1 || required[0] != "command" { + t.Fatal("command waiting requires more than a name and optional timeout") } - t.Fatal("wait tool missing") } func TestConcreteToolSchemaNormalizesMapValuesWithoutChangingRequiredFields(t *testing.T) { @@ -67,7 +55,7 @@ func TestConcreteToolSchemaNormalizesMapValuesWithoutChangingRequiredFields(t *t } func TestCreateCommandSchemaPreservesNestedTypesForOpenHands(t *testing.T) { - definition := commandToolDefinitions()[0] + definition := createCommandTool() if definition.Name != "create_command" { t.Fatal("creation tool missing") } @@ -176,7 +164,7 @@ func TestCommandToolsRealMCPClient(t *testing.T) { } backend := &toolRouteBackend{} backend.credentials.Store(token, workloadidentity.Identity{DeploymentID: "tools", Permissions: []string{"commands:list", "commands:create", "commands:remove", "queue:read", "ui:publish", "logs:read"}}) - supervisor, err := appservices.NewRuntimeSupervisor(store, coreservices.NewRuntimeScrollManager(store), ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) + supervisor, err := appservices.NewRuntimeSupervisor(store, coreservices.NewRuntimeScrollManager(store), ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil }), "") if err != nil { t.Fatal(err) } @@ -317,7 +305,7 @@ func TestCommandToolsRealMCPClient(t *testing.T) { t.Fatal("MCP metadata projection failed") } var arguments map[string]any - _, example, found := strings.Cut(commandToolDefinitions()[0].Description, "Example: ") + _, example, found := strings.Cut(createCommandTool().Description, "Example: ") if !found || json.Unmarshal([]byte(example), &arguments) != nil { t.Fatal("advertised example is not valid JSON") } diff --git a/apps/druid/adapters/http/handlers/console_handler.go b/apps/druid/adapters/http/handlers/console_handler.go new file mode 100644 index 00000000..d1c4ff6f --- /dev/null +++ b/apps/druid/adapters/http/handlers/console_handler.go @@ -0,0 +1,17 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" +) + +func (h *ScrollHandler) ReadDaemonLogs(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "read_logs") +} + +func (h *ScrollHandler) SearchDaemonLogs(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "search_logs") +} + +func (h *ScrollHandler) WriteDaemonStdin(c *fiber.Ctx) error { + return h.callCommandToolHTTP(c, "send_stdin") +} diff --git a/apps/druid/adapters/http/handlers/docker_command_tools_test.go b/apps/druid/adapters/http/handlers/docker_command_tools_test.go index 9416476a..8091e6df 100644 --- a/apps/druid/adapters/http/handlers/docker_command_tools_test.go +++ b/apps/druid/adapters/http/handlers/docker_command_tools_test.go @@ -31,7 +31,7 @@ func TestDockerToolsAreAnonymousWhileOwnerRoutesRemainProtected(t *testing.T) { } } backend := &dockerToolRouteBackend{&toolRouteBackend{}} - supervisor, err := appservices.NewRuntimeSupervisor(store, coreservices.NewRuntimeScrollManager(store), ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) + supervisor, err := appservices.NewRuntimeSupervisor(store, coreservices.NewRuntimeScrollManager(store), ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil }), "") if err != nil { t.Fatal(err) } @@ -73,9 +73,14 @@ func TestDockerToolsAreAnonymousWhileOwnerRoutesRemainProtected(t *testing.T) { } defer session.Close() tools, err := session.ListTools(ctx, nil) - if err != nil || len(tools.Tools) != len(commandToolDefinitions()) { + if err != nil || len(tools.Tools) != len(appservices.ToolPermissions) { t.Fatalf("anonymous Docker discovery: %v, %v", tools, err) } + for _, tool := range tools.Tools { + if _, known := appservices.ToolPermissions[tool.Name]; !known { + t.Fatalf("unexpected MCP tool %q", tool.Name) + } + } result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "create_command", Arguments: map[string]any{"command": "anonymous-created", "instructions": map[string]any{"procedures": []any{map[string]any{"image": "busybox", "command": []string{"true"}}}}}}) if err != nil || result.IsError { t.Fatalf("anonymous Docker creation: %v, %v", result, err) diff --git a/apps/druid/adapters/http/handlers/mcp.go b/apps/druid/adapters/http/handlers/mcp.go new file mode 100644 index 00000000..d552837d --- /dev/null +++ b/apps/druid/adapters/http/handlers/mcp.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" + services "github.com/highcard-dev/daemon/apps/druid/core/services" + "github.com/highcard-dev/daemon/internal/workloadidentity" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type commandMCP struct { + server *mcp.Server + supervisor *services.RuntimeSupervisor + identity workloadidentity.Identity + deployment, token string +} + +func (h *ScrollHandler) MCP(c *fiber.Ctx) error { + deployment, token := c.Params("id"), bearer(c.Get("Authorization")) + record, err := h.supervisor.AuthenticateCommand(c.UserContext(), token, deployment) + if err != nil { + return fiber.NewError(commandToolStatus(err), commandToolMessage(err)) + } + handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { + tools := commandMCP{ + server: mcp.NewServer(&mcp.Implementation{Name: "druid", Version: "1.0.0"}, nil), + supervisor: h.supervisor, identity: record, deployment: deployment, token: token, + } + tools.addCommands() + tools.addConsole() + tools.addStatus() + tools.addUI() + return tools.server + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + return adaptor.HTTPHandler(handler)(c) +} + +// add keeps discovery permission-filtered; every call is authorized by the shared module. +func (m *commandMCP) add(definition *mcp.Tool) { + name := definition.Name + if !m.identity.Allows(services.ToolPermissions[name]) { + return + } + m.server.AddTool(definition, func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + input, err := commandToolInput(request.Params.Arguments) + if err != nil { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "invalid tool request"}}}, nil + } + result, err := m.supervisor.CallCommandTool(ctx, m.token, m.deployment, name, input) + if err != nil { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: commandToolMessage(err)}}}, nil + } + data, err := json.Marshal(result) + if err != nil { + return nil, err + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}}, nil + }) +} diff --git a/apps/druid/adapters/http/handlers/mcp_commands.go b/apps/druid/adapters/http/handlers/mcp_commands.go new file mode 100644 index 00000000..57b4de01 --- /dev/null +++ b/apps/druid/adapters/http/handlers/mcp_commands.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "github.com/google/jsonschema-go/jsonschema" + "github.com/highcard-dev/daemon/internal/core/domain" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func createCommandTool() *mcp.Tool { + // Derive the vocabulary from the existing command type, not a second schema. + instructions, err := jsonschema.For[domain.CommandInstructionSet](nil) + if err != nil { + panic(err) // A programming error in the fixed command type. + } + delete(instructions.Properties, "identity") + concreteToolSchema(instructions) + procedures := instructions.Properties["procedures"] + minimumProcedures := 1 + procedures.MinItems = &minimumProcedures + // The runtime accepts omitted ignore_failure as false. + procedure := procedures.Items + procedure.Required = nil + procedure.Properties["type"].Enum = []any{string(domain.ProcedureTypeContainer), string(domain.ProcedureTypeSignal)} + run := instructions.Properties["run"] + run.Enum = []any{string(domain.RunModeAlways), string(domain.RunModeOnce), string(domain.RunModeRestart), string(domain.RunModePersistent)} + return commandMCPTool("create_command", `Register a new persistent deployment command without executing it. Instructions define container images, arguments, environments and deployment-data mounts; this creates container workloads, not host-shell execution. Existing names and workload identity grants are rejected. Use run_command afterward. Example: {"command":"example-echo","instructions":{"procedures":[{"image":"busybox:1.37","command":["echo","hello"]}]}}`, map[string]any{"command": stringField("New command name"), "instructions": instructions}, "command", "instructions") +} + +func waitForCommandTool() *mcp.Tool { + return commandMCPTool("wait_for_command", "Wait up to 30 seconds for the named command's current status. A restart or replacement under the same name is not distinguished.", map[string]any{"command": stringField("Command name"), "timeout_seconds": integerField(30)}, "command") +} + +func (m *commandMCP) addCommands() { + m.add(createCommandTool()) + m.add(commandMCPTool("list_commands", "List existing Scroll commands and console IDs, without secrets.", nil)) + m.add(commandMCPTool("remove_command", "Stop a named command and remove its stored definition, queue/status entry and runtime containers. This does not delete the deployment or its data. Removing a command also removes its console/log access.", map[string]any{"command": stringField("Command name to stop and remove")}, "command")) + m.add(commandMCPTool("get_queue", "Read current command/procedure status.", nil)) + m.add(commandMCPTool("run_command", "Enqueue an existing named command; inspect its current status and logs for completion.", map[string]any{"command": stringField("Existing command name")}, "command")) + m.add(waitForCommandTool()) +} diff --git a/apps/druid/adapters/http/handlers/mcp_console.go b/apps/druid/adapters/http/handlers/mcp_console.go new file mode 100644 index 00000000..72caae7b --- /dev/null +++ b/apps/druid/adapters/http/handlers/mcp_console.go @@ -0,0 +1,7 @@ +package handlers + +func (m *commandMCP) addConsole() { + m.add(commandMCPTool("read_logs", "Read a finite recent console snapshot (default 200 lines, maximum 64 KiB). Application logs may contain secrets.", map[string]any{"console": stringField("Console ID from list_commands"), "tail_lines": integerField(1000)}, "console")) + m.add(commandMCPTool("search_logs", "Case-insensitive literal search of recent logs; maximum 50 matches and 1 MiB scanned.", map[string]any{"console": stringField("Console ID"), "query": stringField("Literal search text"), "tail_lines": integerField(10000)}, "console", "query")) + m.add(commandMCPTool("send_stdin", "Write exact text to an existing running console. Include a newline to submit a line. Does not start a shell.", map[string]any{"console": stringField("Console ID"), "text": stringField("Exact UTF-8 input, at most 8 KiB")}, "console", "text")) +} diff --git a/apps/druid/adapters/http/handlers/mcp_schema.go b/apps/druid/adapters/http/handlers/mcp_schema.go new file mode 100644 index 00000000..f3e13480 --- /dev/null +++ b/apps/druid/adapters/http/handlers/mcp_schema.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// OpenHands drops items/properties for nullable type arrays. Optional fields can +// be omitted instead; advertise concrete types without duplicating domain types. +func concreteToolSchema(schema *jsonschema.Schema) { + if schema == nil { + return + } + if len(schema.Types) == 2 { + for index, kind := range schema.Types { + if kind == "null" { + schema.Type = schema.Types[1-index] + schema.Types = nil + break + } + } + } + for _, property := range schema.Properties { + concreteToolSchema(property) + } + concreteToolSchema(schema.Items) + concreteToolSchema(schema.AdditionalProperties) +} + +func stringField(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} +func integerField(max int) map[string]any { + return map[string]any{"type": "integer", "minimum": 1, "maximum": max} +} +func commandMCPTool(name, description string, properties map[string]any, required ...string) *mcp.Tool { + if properties == nil { + properties = map[string]any{} + } + schema := map[string]any{"type": "object", "properties": properties, "additionalProperties": false} + if len(required) > 0 { + schema["required"] = required + } + return &mcp.Tool{Name: name, Description: description, InputSchema: schema} +} diff --git a/apps/druid/adapters/http/handlers/mcp_status.go b/apps/druid/adapters/http/handlers/mcp_status.go new file mode 100644 index 00000000..949e3861 --- /dev/null +++ b/apps/druid/adapters/http/handlers/mcp_status.go @@ -0,0 +1,5 @@ +package handlers + +func (m *commandMCP) addStatus() { + m.add(commandMCPTool("get_deployment_status", "Read deployment state, installed release and ports/endpoints.", nil)) +} diff --git a/apps/druid/adapters/http/handlers/mcp_ui.go b/apps/druid/adapters/http/handlers/mcp_ui.go new file mode 100644 index 00000000..c6652d87 --- /dev/null +++ b/apps/druid/adapters/http/handlers/mcp_ui.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func publishUITool() *mcp.Tool { + return commandMCPTool("publish_ui", "Publish an already built Druid UI WASM/OpenUI pair for this deployment. First show focused previews, explain changes and scope, ask for publishing approval, end your turn and wait for a subsequent explicit customer reply. The original UI-change request is not approval. Revisions require renewed approval; failed previews must not be published automatically. For behavior-only changes, explain the preview limitation and still obtain approval. Do not bypass approval through HTTP, terminal commands, Developer Mode or live watchers. This is an agent instruction, not a server-enforced approval check. Does not build or publish npm/Scroll artifacts. Choose private or public explicitly; path defaults to /dist/app.wasm with an adjacent .openui document. Returns the published package only after upload succeeds.", map[string]any{ + "scope": map[string]any{"type": "string", "enum": []string{"private", "public"}, "description": "UI scope; use private unless the customer requests public UI"}, + "path": stringField("Optional scope-relative .wasm path; the matching .openui file must be adjacent"), + }, "scope") +} + +func (m *commandMCP) addUI() { + m.add(publishUITool()) +} diff --git a/apps/druid/adapters/http/handlers/routes.go b/apps/druid/adapters/http/handlers/routes.go index 0b5bd603..ff9eb1de 100644 --- a/apps/druid/adapters/http/handlers/routes.go +++ b/apps/druid/adapters/http/handlers/routes.go @@ -64,7 +64,19 @@ func RegisterPublicRoutes(app *fiber.App, handlers RouteHandlers) { app.Get("/.well-known/jwks.json", RuntimeJWKS(authorizer)) app.Get("/:id/ws/v1/serve/:console", websocket.New(handlers.Websocket.AttachScrollConsole)) if handlers.Server != nil && handlers.Server.ScrollHandler != nil { - handlers.Server.ScrollHandler.registerCommandTools(app) + h := handlers.Server.ScrollHandler + app.All("/:id/mcp", h.MCP) + app.Get("/:id/api/v1/commands", h.GetDaemonCommands) + app.Post("/:id/api/v1/commands", h.CreateDaemonCommand) + app.Get("/:id/api/v1/commands/:command/wait", h.WaitDaemonCommand) + app.Get("/:id/api/v1/consoles/:console/logs", h.ReadDaemonLogs) + app.Post("/:id/api/v1/consoles/:console/logs/search", h.SearchDaemonLogs) + app.Post("/:id/api/v1/consoles/:console/stdin", h.WriteDaemonStdin) + app.Get("/:id/api/v1/status", h.GetDaemonStatus) + app.Get("/:id/api/v1/queue", h.ownerOrWorkload(h.GetWorkloadQueue), h.PublicAuth, h.GetDaemonQueue) + app.Post("/:id/api/v1/command", h.ownerOrWorkload(h.RunWorkloadCommand), h.PublicAuth, h.RunDaemonCommand) + app.Delete("/:id/api/v1/scroll/commands/:command", h.ownerOrWorkload(h.RemoveWorkloadCommand), h.PublicAuth, h.RemoveDaemonCommand) + app.Post("/:id/api/v1/ui/packages/:scope/publish", h.ownerOrWorkload(h.PublishWorkloadUIPackage), h.PublicAuth, h.PublishDaemonUIPackage) app.Use("/:id", handlers.Server.PublicAuth) } app.Get("/:id/api/v1/health", handlers.Server.GetHealthAuth) diff --git a/apps/druid/adapters/http/handlers/routes_test.go b/apps/druid/adapters/http/handlers/routes_test.go index bc9ae37d..f5cea0d1 100644 --- a/apps/druid/adapters/http/handlers/routes_test.go +++ b/apps/druid/adapters/http/handlers/routes_test.go @@ -127,7 +127,7 @@ func (missingOwnerAuth) CheckHeader(*fiber.Ctx) (*ports.AuthContext, error) { re func TestPublicReleaseRoutesEnforceOwnerBeforeReadingOrUpdating(t *testing.T) { supervisor, err := appservices.NewRuntimeSupervisor(ownerRouteStore{}, nil, ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return ownerRouteBackend{}, nil - })) + }), "") if err != nil { t.Fatal(err) } diff --git a/apps/druid/core/services/runtime_identity.go b/apps/druid/core/services/runtime_identity.go index 46a7e64a..0a3ed2c5 100644 --- a/apps/druid/core/services/runtime_identity.go +++ b/apps/druid/core/services/runtime_identity.go @@ -2,25 +2,11 @@ package services import ( "context" - "fmt" - "net/url" - "strings" "github.com/highcard-dev/daemon/internal/core/ports" "github.com/highcard-dev/daemon/internal/workloadidentity" ) -func (s *RuntimeSupervisor) SetWorkloadAPIURL(base string) error { - if base != "" { - parsed, err := url.Parse(base) - if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { - return fmt.Errorf("workload API URL must be an explicit HTTP(S) base URL") - } - } - s.workloadAPIURL = strings.TrimRight(base, "/") - return nil -} - func (s *RuntimeSupervisor) AuthenticateCommand(ctx context.Context, token string, deployment string) (workloadidentity.Identity, error) { // Docker MCP tools are deliberately unauthenticated: trusted local use only. if s.runtimeBackend.Name() == "docker" { diff --git a/apps/druid/core/services/runtime_identity_test.go b/apps/druid/core/services/runtime_identity_test.go index 00266c4a..9d3e2d16 100644 --- a/apps/druid/core/services/runtime_identity_test.go +++ b/apps/druid/core/services/runtime_identity_test.go @@ -3,6 +3,7 @@ package services import ( "context" "errors" + "strings" "testing" "github.com/highcard-dev/daemon/internal/core/domain" @@ -45,9 +46,35 @@ func (b podIdentityBackend) Name() string { return "fixture" } +func TestSupervisorConfiguresWorkloadURLDuringConstruction(t *testing.T) { + for _, base := range []string{"", "http://runtime.local/", "https://runtime.local/prefix///"} { + supervisor, err := NewRuntimeSupervisor(nil, nil, ports.RuntimeBackendFactoryFunc(func(observer ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { + // The backend receives an already configured supervisor. + if observer.(*RuntimeSupervisor).workloadAPIURL != strings.TrimRight(base, "/") { + t.Fatal("workload URL was not configured before backend creation") + } + return podIdentityBackend{}, nil + }), base) + if err != nil || supervisor.workloadAPIURL != strings.TrimRight(base, "/") { + t.Fatalf("base=%q supervisor=%+v err=%v", base, supervisor, err) + } + } + for _, base := range []string{"runtime.local", "ftp://runtime.local", "https://user:password@runtime.local", "https://runtime.local/?token=value", "https://runtime.local/#fragment", "http://%"} { + t.Run(base, func(t *testing.T) { + factory := ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { + t.Fatal("invalid configuration reached backend creation") + return nil, nil + }) + if _, err := NewRuntimeSupervisor(nil, nil, factory, base); err == nil { + t.Fatal("invalid workload URL accepted") + } + }) + } +} + func TestCommandAuthenticationNeedsNoDaemonState(t *testing.T) { backend := podIdentityBackend{record: workloadidentity.Identity{DeploymentID: "deployment", Permissions: []string{"commands:list"}}} - supervisor, err := NewRuntimeSupervisor(nil, nil, ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) + supervisor, err := NewRuntimeSupervisor(nil, nil, ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil }), "") if err != nil { t.Fatal(err) } @@ -66,7 +93,7 @@ func TestCommandAuthenticationNeedsNoDaemonState(t *testing.T) { func TestDockerToolsIgnoreCredentialsPermissionsAndDeploymentBinding(t *testing.T) { backend := podIdentityBackend{name: "docker"} - supervisor, err := NewRuntimeSupervisor(nil, nil, ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil })) + supervisor, err := NewRuntimeSupervisor(nil, nil, ports.RuntimeBackendFactoryFunc(func(ports.ProcedureStatusObserver) (ports.RuntimeBackendInterface, error) { return backend, nil }), "") if err != nil { t.Fatal(err) } diff --git a/apps/druid/core/services/runtime_supervisor.go b/apps/druid/core/services/runtime_supervisor.go index 17b14cae..d46036ff 100644 --- a/apps/druid/core/services/runtime_supervisor.go +++ b/apps/druid/core/services/runtime_supervisor.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/url" "strings" "sync" "time" @@ -60,16 +61,24 @@ func NewRuntimeSupervisor( store ports.RuntimeScrollStore, manager *coreservices.RuntimeScrollManager, backendFactory ports.RuntimeBackendFactory, + workloadAPIURL string, ) (*RuntimeSupervisor, error) { + if workloadAPIURL != "" { + parsed, err := url.Parse(workloadAPIURL) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("workload API URL must be an explicit HTTP(S) base URL") + } + } if backendFactory == nil { return nil, fmt.Errorf("runtime backend factory is required") } supervisor := &RuntimeSupervisor{ - store: store, - manager: manager, - workerTimeout: 20 * time.Minute, - sessions: map[string]*RuntimeSession{}, - operations: map[string]*sync.Mutex{}, + workloadAPIURL: strings.TrimRight(workloadAPIURL, "/"), + store: store, + manager: manager, + workerTimeout: 20 * time.Minute, + sessions: map[string]*RuntimeSession{}, + operations: map[string]*sync.Mutex{}, } runtimeBackend, err := backendFactory.Create(supervisor) if err != nil { diff --git a/apps/druid/core/services/runtime_supervisor_test.go b/apps/druid/core/services/runtime_supervisor_test.go index 9d62a566..ab2ba19f 100644 --- a/apps/druid/core/services/runtime_supervisor_test.go +++ b/apps/druid/core/services/runtime_supervisor_test.go @@ -1637,7 +1637,7 @@ type fakeWorkerBackend struct { func newRuntimeSupervisorForTest(t *testing.T, store ports.RuntimeScrollStore, manager *coreservices.RuntimeScrollManager, factory ports.RuntimeBackendFactory) *RuntimeSupervisor { t.Helper() - supervisor, err := NewRuntimeSupervisor(store, manager, factory) + supervisor, err := NewRuntimeSupervisor(store, manager, factory, "") if err != nil { t.Fatal(err) } diff --git a/test/integration/agent_ui_test.go b/test/integration/agent_ui_test.go index 97a99ca6..0b13c592 100644 --- a/test/integration/agent_ui_test.go +++ b/test/integration/agent_ui_test.go @@ -92,7 +92,7 @@ func TestAgentUIBuildAndPublish(t *testing.T) { if err != nil { t.Fatal(err) } - supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, coreservices.NewRuntimeScrollManager(runtime.Store), runtime) + supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, coreservices.NewRuntimeScrollManager(runtime.Store), runtime, "http://host.docker.internal:8082") if err != nil { t.Fatal(err) } @@ -156,9 +156,6 @@ commands: t.Errorf("cleanup: %v", err) } }() - if err := supervisor.SetWorkloadAPIURL("http://host.docker.internal:8082"); err != nil { - t.Fatal(err) - } if _, err := supervisor.Run(id, "agent"); err != nil { t.Fatal(err) } diff --git a/test/integration/workload_identity_test.go b/test/integration/workload_identity_test.go index a619802c..eb563955 100644 --- a/test/integration/workload_identity_test.go +++ b/test/integration/workload_identity_test.go @@ -106,7 +106,14 @@ func TestWorkloadIdentityRealRuntime(t *testing.T) { if err != nil { t.Fatal(err) } - supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, coreservices.NewRuntimeScrollManager(runtime.Store), runtime) + listener, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + workloadAPIURL := fmt.Sprintf("http://host.docker.internal:%d", port) + supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, coreservices.NewRuntimeScrollManager(runtime.Store), runtime, workloadAPIURL) if err != nil { t.Fatal(err) } @@ -157,17 +164,9 @@ commands: w.WriteHeader(response.StatusCode) _, _ = io.Copy(w, response.Body) })) - listener, err := net.Listen("tcp", "0.0.0.0:0") - if err != nil { - t.Fatal(err) - } server.Listener = listener server.Start() defer server.Close() - port := listener.Addr().(*net.TCPAddr).Port - if err := supervisor.SetWorkloadAPIURL(fmt.Sprintf("http://host.docker.internal:%d", port)); err != nil { - t.Fatal(err) - } if _, err := supervisor.Run(id, "agent"); err != nil { t.Fatal(err) } @@ -365,13 +364,10 @@ commands: if err != nil { t.Fatal(err) } - second, err := appservices.NewRuntimeSupervisor(reattached.Store, coreservices.NewRuntimeScrollManager(reattached.Store), reattached) + second, err := appservices.NewRuntimeSupervisor(reattached.Store, coreservices.NewRuntimeScrollManager(reattached.Store), reattached, workloadAPIURL) if err != nil { t.Fatal(err) } - if err := second.SetWorkloadAPIURL(fmt.Sprintf("http://host.docker.internal:%d", port)); err != nil { - t.Fatal(err) - } if _, err := second.AuthenticateCommand(ctx, token, id); err != nil { t.Fatal("daemon reattachment rotated or lost identity") } From f5defc503505a0d3dda4fb935b66ff93faf30270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Fri, 30 Oct 2026 15:35:04 +0100 Subject: [PATCH 13/14] refactor(mcp): move tools into own adapter --- apps/druid/adapters/http/handlers/auth.go | 3 +- .../adapters/http/handlers/command_handler.go | 7 +- .../http/handlers/command_tools_test.go | 94 ++----------------- apps/druid/adapters/http/handlers/routes.go | 3 +- .../mcp_commands.go => mcp/commands.go} | 2 +- .../mcp_console.go => mcp/console.go} | 2 +- .../{http/handlers/mcp.go => mcp/handler.go} | 43 +++++---- .../handlers/mcp_schema.go => mcp/schema.go} | 2 +- apps/druid/adapters/mcp/schema_test.go | 91 ++++++++++++++++++ .../handlers/mcp_status.go => mcp/status.go} | 2 +- .../{http/handlers/mcp_ui.go => mcp/ui.go} | 2 +- .../request.go} | 12 +-- 12 files changed, 140 insertions(+), 123 deletions(-) rename apps/druid/adapters/{http/handlers/mcp_commands.go => mcp/commands.go} (99%) rename apps/druid/adapters/{http/handlers/mcp_console.go => mcp/console.go} (97%) rename apps/druid/adapters/{http/handlers/mcp.go => mcp/handler.go} (55%) rename apps/druid/adapters/{http/handlers/mcp_schema.go => mcp/schema.go} (98%) create mode 100644 apps/druid/adapters/mcp/schema_test.go rename apps/druid/adapters/{http/handlers/mcp_status.go => mcp/status.go} (89%) rename apps/druid/adapters/{http/handlers/mcp_ui.go => mcp/ui.go} (98%) rename apps/druid/adapters/{http/handlers/command_tool_request.go => toolrequest/request.go} (77%) diff --git a/apps/druid/adapters/http/handlers/auth.go b/apps/druid/adapters/http/handlers/auth.go index 777e58a1..529c807b 100644 --- a/apps/druid/adapters/http/handlers/auth.go +++ b/apps/druid/adapters/http/handlers/auth.go @@ -3,6 +3,7 @@ package handlers import ( "github.com/gofiber/contrib/websocket" "github.com/gofiber/fiber/v2" + "github.com/highcard-dev/daemon/apps/druid/adapters/toolrequest" "github.com/highcard-dev/daemon/internal/core/ports" ) @@ -68,7 +69,7 @@ func (h *ScrollHandler) authorizeRuntimeOwner(id string, subject string) error { func (h *WebsocketHandler) PublicQueryAuth(c *websocket.Conn) bool { token := c.Query("token") if token == "" { - token = bearer(c.Headers("Authorization")) + token = toolrequest.Bearer(c.Headers("Authorization")) } if h.authorizer == nil { return h.allowUnauthenticatedPublic && token == "" && c.Headers("Authorization") == "" diff --git a/apps/druid/adapters/http/handlers/command_handler.go b/apps/druid/adapters/http/handlers/command_handler.go index e1262267..604d8419 100644 --- a/apps/druid/adapters/http/handlers/command_handler.go +++ b/apps/druid/adapters/http/handlers/command_handler.go @@ -2,6 +2,7 @@ package handlers import ( "github.com/gofiber/fiber/v2" + "github.com/highcard-dev/daemon/apps/druid/adapters/toolrequest" services "github.com/highcard-dev/daemon/apps/druid/core/services" ) @@ -9,7 +10,7 @@ func (h *ScrollHandler) callCommandToolHTTP(c *fiber.Ctx, name string) error { var input services.ToolInput if len(c.Body()) > 0 { var err error - input, err = commandToolInput(c.Body()) + input, err = toolrequest.Decode(c.Body()) if err != nil { return fiber.NewError(400, "invalid tool request") } @@ -21,9 +22,9 @@ func (h *ScrollHandler) callCommandToolHTTP(c *fiber.Ctx, name string) error { input.TailLines = c.QueryInt("tail_lines", 0) input.TimeoutSeconds = c.QueryInt("timeout_seconds", 0) } - result, err := h.supervisor.CallCommandTool(c.UserContext(), bearer(c.Get("Authorization")), c.Params("id"), name, input) + result, err := h.supervisor.CallCommandTool(c.UserContext(), toolrequest.Bearer(c.Get("Authorization")), c.Params("id"), name, input) if err != nil { - return fiber.NewError(commandToolStatus(err), commandToolMessage(err)) + return fiber.NewError(toolrequest.Status(err), toolrequest.Message(err)) } return c.JSON(result) } diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go index 0ff2cfc8..397a500c 100644 --- a/apps/druid/adapters/http/handlers/command_tools_test.go +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -11,7 +11,6 @@ import ( "testing" "github.com/gofiber/fiber/v2" - "github.com/google/jsonschema-go/jsonschema" appservices "github.com/highcard-dev/daemon/apps/druid/core/services" "github.com/highcard-dev/daemon/internal/core/domain" "github.com/highcard-dev/daemon/internal/core/ports" @@ -21,90 +20,6 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -func TestPublishUIDescriptionRequiresSeparateCustomerApproval(t *testing.T) { - definition := publishUITool() - for _, instruction := range []string{"focused previews", "end your turn and wait", "subsequent explicit customer reply", "original UI-change request is not approval", "Revisions require renewed approval", "failed previews", "behavior-only changes", "Do not bypass approval", "not a server-enforced approval check"} { - if !strings.Contains(definition.Description, instruction) { - t.Errorf("publish_ui description missing %q", instruction) - } - } -} - -func TestCommandWaitSchemaUsesOnlyCommandNameAndTimeout(t *testing.T) { - tool := waitForCommandTool() - schema := tool.InputSchema.(map[string]any) - properties := schema["properties"].(map[string]any) - required := schema["required"].([]string) - if len(properties) != 2 || properties["command"] == nil || properties["timeout_seconds"] == nil || len(required) != 1 || required[0] != "command" { - t.Fatal("command waiting requires more than a name and optional timeout") - } -} - -func TestConcreteToolSchemaNormalizesMapValuesWithoutChangingRequiredFields(t *testing.T) { - schema := &jsonschema.Schema{Type: "object", Required: []string{"required"}, Properties: map[string]*jsonschema.Schema{ - "required": {Types: []string{"string", "null"}}, - "optional": {Type: "object", AdditionalProperties: &jsonschema.Schema{Types: []string{"null", "string"}}}, - }} - concreteToolSchema(schema) - if schema.Properties["required"].Type != "string" || schema.Properties["optional"].AdditionalProperties.Type != "string" { - t.Fatal("nullable scalar or map value lost its concrete type") - } - if len(schema.Required) != 1 || schema.Required[0] != "required" { - t.Fatal("schema normalization changed field optionality") - } -} - -func TestCreateCommandSchemaPreservesNestedTypesForOpenHands(t *testing.T) { - definition := createCommandTool() - if definition.Name != "create_command" { - t.Fatal("creation tool missing") - } - instructions := definition.InputSchema.(map[string]any)["properties"].(map[string]any)["instructions"].(*jsonschema.Schema) - var visit func(*jsonschema.Schema) - visit = func(schema *jsonschema.Schema) { - if schema == nil { - return - } - if len(schema.Types) > 0 { - t.Fatalf("OpenHands loses nested fields for nullable type %v", schema.Types) - } - for _, property := range schema.Properties { - visit(property) - } - visit(schema.Items) - visit(schema.AdditionalProperties) - } - visit(instructions) - procedures := instructions.Properties["procedures"] - if procedures.Type != "array" || procedures.MinItems == nil || *procedures.MinItems != 1 || procedures.Items.Type != "object" { - t.Fatal("procedures must advertise a nonempty object array") - } - procedure := procedures.Items - for _, field := range []string{"image", "id", "command", "env", "mounts", "expectedPorts", "target", "signal"} { - if procedure.Properties[field] == nil { - t.Fatalf("procedure field %s missing", field) - } - if procedure.Properties[field].Description == "" { - t.Fatalf("Scroll field %s lost its description", field) - } - } - if procedure.Properties["command"].Type != "array" || procedure.Properties["command"].Items.Type != "string" || procedure.Properties["env"].AdditionalProperties.Type != "string" { - t.Fatal("argument array or environment map schema lost") - } - if procedure.Properties["mounts"].Items.Properties["read_only"].Type != "boolean" || procedure.Properties["expectedPorts"].Items.Properties["name"].Type != "string" { - t.Fatal("mount or port-reference schema lost") - } - if instructions.Properties["identity"] != nil || len(procedure.Required) != 0 || len(instructions.Properties["run"].Enum) != 4 { - t.Fatal("identity exclusion, optional ignore_failure, or run modes changed") - } - if procedures.Description == "" || !strings.Contains(instructions.Properties["run"].Description, "NOT a shell command") { - t.Fatal("Scroll procedure or scheduling-mode documentation lost") - } - if !strings.Contains(definition.Description, "Example: ") { - t.Fatal("model-visible example missing") - } -} - type toolRouteBackend struct { ports.RuntimeBackendInterface credentials sync.Map @@ -305,8 +220,13 @@ func TestCommandToolsRealMCPClient(t *testing.T) { t.Fatal("MCP metadata projection failed") } var arguments map[string]any - _, example, found := strings.Cut(createCommandTool().Description, "Example: ") - if !found || json.Unmarshal([]byte(example), &arguments) != nil { + var example string + for _, tool := range listed.Tools { + if tool.Name == "create_command" { + _, example, _ = strings.Cut(tool.Description, "Example: ") + } + } + if json.Unmarshal([]byte(example), &arguments) != nil { t.Fatal("advertised example is not valid JSON") } created, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "create_command", Arguments: arguments}) diff --git a/apps/druid/adapters/http/handlers/routes.go b/apps/druid/adapters/http/handlers/routes.go index ff9eb1de..cfacc27a 100644 --- a/apps/druid/adapters/http/handlers/routes.go +++ b/apps/druid/adapters/http/handlers/routes.go @@ -4,6 +4,7 @@ import ( "github.com/gofiber/contrib/websocket" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" + commandmcp "github.com/highcard-dev/daemon/apps/druid/adapters/mcp" "github.com/highcard-dev/daemon/internal/api" "github.com/highcard-dev/daemon/internal/core/ports" ) @@ -65,7 +66,7 @@ func RegisterPublicRoutes(app *fiber.App, handlers RouteHandlers) { app.Get("/:id/ws/v1/serve/:console", websocket.New(handlers.Websocket.AttachScrollConsole)) if handlers.Server != nil && handlers.Server.ScrollHandler != nil { h := handlers.Server.ScrollHandler - app.All("/:id/mcp", h.MCP) + app.All("/:id/mcp", commandmcp.NewHandler(h.supervisor)) app.Get("/:id/api/v1/commands", h.GetDaemonCommands) app.Post("/:id/api/v1/commands", h.CreateDaemonCommand) app.Get("/:id/api/v1/commands/:command/wait", h.WaitDaemonCommand) diff --git a/apps/druid/adapters/http/handlers/mcp_commands.go b/apps/druid/adapters/mcp/commands.go similarity index 99% rename from apps/druid/adapters/http/handlers/mcp_commands.go rename to apps/druid/adapters/mcp/commands.go index 57b4de01..9bb1acf6 100644 --- a/apps/druid/adapters/http/handlers/mcp_commands.go +++ b/apps/druid/adapters/mcp/commands.go @@ -1,4 +1,4 @@ -package handlers +package commandmcp import ( "github.com/google/jsonschema-go/jsonschema" diff --git a/apps/druid/adapters/http/handlers/mcp_console.go b/apps/druid/adapters/mcp/console.go similarity index 97% rename from apps/druid/adapters/http/handlers/mcp_console.go rename to apps/druid/adapters/mcp/console.go index 72caae7b..0f4782af 100644 --- a/apps/druid/adapters/http/handlers/mcp_console.go +++ b/apps/druid/adapters/mcp/console.go @@ -1,4 +1,4 @@ -package handlers +package commandmcp func (m *commandMCP) addConsole() { m.add(commandMCPTool("read_logs", "Read a finite recent console snapshot (default 200 lines, maximum 64 KiB). Application logs may contain secrets.", map[string]any{"console": stringField("Console ID from list_commands"), "tail_lines": integerField(1000)}, "console")) diff --git a/apps/druid/adapters/http/handlers/mcp.go b/apps/druid/adapters/mcp/handler.go similarity index 55% rename from apps/druid/adapters/http/handlers/mcp.go rename to apps/druid/adapters/mcp/handler.go index d552837d..a36113a3 100644 --- a/apps/druid/adapters/http/handlers/mcp.go +++ b/apps/druid/adapters/mcp/handler.go @@ -1,4 +1,4 @@ -package handlers +package commandmcp import ( "context" @@ -7,6 +7,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/highcard-dev/daemon/apps/druid/adapters/toolrequest" services "github.com/highcard-dev/daemon/apps/druid/core/services" "github.com/highcard-dev/daemon/internal/workloadidentity" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -19,24 +20,26 @@ type commandMCP struct { deployment, token string } -func (h *ScrollHandler) MCP(c *fiber.Ctx) error { - deployment, token := c.Params("id"), bearer(c.Get("Authorization")) - record, err := h.supervisor.AuthenticateCommand(c.UserContext(), token, deployment) - if err != nil { - return fiber.NewError(commandToolStatus(err), commandToolMessage(err)) - } - handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { - tools := commandMCP{ - server: mcp.NewServer(&mcp.Implementation{Name: "druid", Version: "1.0.0"}, nil), - supervisor: h.supervisor, identity: record, deployment: deployment, token: token, +func NewHandler(supervisor *services.RuntimeSupervisor) fiber.Handler { + return func(c *fiber.Ctx) error { + deployment, token := c.Params("id"), toolrequest.Bearer(c.Get("Authorization")) + record, err := supervisor.AuthenticateCommand(c.UserContext(), token, deployment) + if err != nil { + return fiber.NewError(toolrequest.Status(err), toolrequest.Message(err)) } - tools.addCommands() - tools.addConsole() - tools.addStatus() - tools.addUI() - return tools.server - }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) - return adaptor.HTTPHandler(handler)(c) + handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { + tools := commandMCP{ + server: mcp.NewServer(&mcp.Implementation{Name: "druid", Version: "1.0.0"}, nil), + supervisor: supervisor, identity: record, deployment: deployment, token: token, + } + tools.addCommands() + tools.addConsole() + tools.addStatus() + tools.addUI() + return tools.server + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + return adaptor.HTTPHandler(handler)(c) + } } // add keeps discovery permission-filtered; every call is authorized by the shared module. @@ -46,13 +49,13 @@ func (m *commandMCP) add(definition *mcp.Tool) { return } m.server.AddTool(definition, func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - input, err := commandToolInput(request.Params.Arguments) + input, err := toolrequest.Decode(request.Params.Arguments) if err != nil { return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "invalid tool request"}}}, nil } result, err := m.supervisor.CallCommandTool(ctx, m.token, m.deployment, name, input) if err != nil { - return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: commandToolMessage(err)}}}, nil + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: toolrequest.Message(err)}}}, nil } data, err := json.Marshal(result) if err != nil { diff --git a/apps/druid/adapters/http/handlers/mcp_schema.go b/apps/druid/adapters/mcp/schema.go similarity index 98% rename from apps/druid/adapters/http/handlers/mcp_schema.go rename to apps/druid/adapters/mcp/schema.go index f3e13480..8a0e4904 100644 --- a/apps/druid/adapters/http/handlers/mcp_schema.go +++ b/apps/druid/adapters/mcp/schema.go @@ -1,4 +1,4 @@ -package handlers +package commandmcp import ( "github.com/google/jsonschema-go/jsonschema" diff --git a/apps/druid/adapters/mcp/schema_test.go b/apps/druid/adapters/mcp/schema_test.go new file mode 100644 index 00000000..d3c9914d --- /dev/null +++ b/apps/druid/adapters/mcp/schema_test.go @@ -0,0 +1,91 @@ +package commandmcp + +import ( + "github.com/google/jsonschema-go/jsonschema" + "strings" + "testing" +) + +func TestPublishUIDescriptionRequiresSeparateCustomerApproval(t *testing.T) { + definition := publishUITool() + for _, instruction := range []string{"focused previews", "end your turn and wait", "subsequent explicit customer reply", "original UI-change request is not approval", "Revisions require renewed approval", "failed previews", "behavior-only changes", "Do not bypass approval", "not a server-enforced approval check"} { + if !strings.Contains(definition.Description, instruction) { + t.Errorf("publish_ui description missing %q", instruction) + } + } +} + +func TestCommandWaitSchemaUsesOnlyCommandNameAndTimeout(t *testing.T) { + tool := waitForCommandTool() + schema := tool.InputSchema.(map[string]any) + properties := schema["properties"].(map[string]any) + required := schema["required"].([]string) + if len(properties) != 2 || properties["command"] == nil || properties["timeout_seconds"] == nil || len(required) != 1 || required[0] != "command" { + t.Fatal("command waiting requires more than a name and optional timeout") + } +} + +func TestConcreteToolSchemaNormalizesMapValuesWithoutChangingRequiredFields(t *testing.T) { + schema := &jsonschema.Schema{Type: "object", Required: []string{"required"}, Properties: map[string]*jsonschema.Schema{ + "required": {Types: []string{"string", "null"}}, + "optional": {Type: "object", AdditionalProperties: &jsonschema.Schema{Types: []string{"null", "string"}}}, + }} + concreteToolSchema(schema) + if schema.Properties["required"].Type != "string" || schema.Properties["optional"].AdditionalProperties.Type != "string" { + t.Fatal("nullable scalar or map value lost its concrete type") + } + if len(schema.Required) != 1 || schema.Required[0] != "required" { + t.Fatal("schema normalization changed field optionality") + } +} + +func TestCreateCommandSchemaPreservesNestedTypesForOpenHands(t *testing.T) { + definition := createCommandTool() + if definition.Name != "create_command" { + t.Fatal("creation tool missing") + } + instructions := definition.InputSchema.(map[string]any)["properties"].(map[string]any)["instructions"].(*jsonschema.Schema) + var visit func(*jsonschema.Schema) + visit = func(schema *jsonschema.Schema) { + if schema == nil { + return + } + if len(schema.Types) > 0 { + t.Fatalf("OpenHands loses nested fields for nullable type %v", schema.Types) + } + for _, property := range schema.Properties { + visit(property) + } + visit(schema.Items) + visit(schema.AdditionalProperties) + } + visit(instructions) + procedures := instructions.Properties["procedures"] + if procedures.Type != "array" || procedures.MinItems == nil || *procedures.MinItems != 1 || procedures.Items.Type != "object" { + t.Fatal("procedures must advertise a nonempty object array") + } + procedure := procedures.Items + for _, field := range []string{"image", "id", "command", "env", "mounts", "expectedPorts", "target", "signal"} { + if procedure.Properties[field] == nil { + t.Fatalf("procedure field %s missing", field) + } + if procedure.Properties[field].Description == "" { + t.Fatalf("Scroll field %s lost its description", field) + } + } + if procedure.Properties["command"].Type != "array" || procedure.Properties["command"].Items.Type != "string" || procedure.Properties["env"].AdditionalProperties.Type != "string" { + t.Fatal("argument array or environment map schema lost") + } + if procedure.Properties["mounts"].Items.Properties["read_only"].Type != "boolean" || procedure.Properties["expectedPorts"].Items.Properties["name"].Type != "string" { + t.Fatal("mount or port-reference schema lost") + } + if instructions.Properties["identity"] != nil || len(procedure.Required) != 0 || len(instructions.Properties["run"].Enum) != 4 { + t.Fatal("identity exclusion, optional ignore_failure, or run modes changed") + } + if procedures.Description == "" || !strings.Contains(instructions.Properties["run"].Description, "NOT a shell command") { + t.Fatal("Scroll procedure or scheduling-mode documentation lost") + } + if !strings.Contains(definition.Description, "Example: ") { + t.Fatal("model-visible example missing") + } +} diff --git a/apps/druid/adapters/http/handlers/mcp_status.go b/apps/druid/adapters/mcp/status.go similarity index 89% rename from apps/druid/adapters/http/handlers/mcp_status.go rename to apps/druid/adapters/mcp/status.go index 949e3861..4ca369bd 100644 --- a/apps/druid/adapters/http/handlers/mcp_status.go +++ b/apps/druid/adapters/mcp/status.go @@ -1,4 +1,4 @@ -package handlers +package commandmcp func (m *commandMCP) addStatus() { m.add(commandMCPTool("get_deployment_status", "Read deployment state, installed release and ports/endpoints.", nil)) diff --git a/apps/druid/adapters/http/handlers/mcp_ui.go b/apps/druid/adapters/mcp/ui.go similarity index 98% rename from apps/druid/adapters/http/handlers/mcp_ui.go rename to apps/druid/adapters/mcp/ui.go index c6652d87..08bb80fe 100644 --- a/apps/druid/adapters/http/handlers/mcp_ui.go +++ b/apps/druid/adapters/mcp/ui.go @@ -1,4 +1,4 @@ -package handlers +package commandmcp import ( "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/apps/druid/adapters/http/handlers/command_tool_request.go b/apps/druid/adapters/toolrequest/request.go similarity index 77% rename from apps/druid/adapters/http/handlers/command_tool_request.go rename to apps/druid/adapters/toolrequest/request.go index 10ac57f7..8e945637 100644 --- a/apps/druid/adapters/http/handlers/command_tool_request.go +++ b/apps/druid/adapters/toolrequest/request.go @@ -1,4 +1,4 @@ -package handlers +package toolrequest import ( "bytes" @@ -10,7 +10,7 @@ import ( "github.com/highcard-dev/daemon/internal/workloadidentity" ) -func commandToolStatus(err error) int { +func Status(err error) int { var failure *services.ToolError if errors.As(err, &failure) { return failure.Status @@ -24,14 +24,14 @@ func commandToolStatus(err error) int { return 500 } -func commandToolMessage(err error) string { - if commandToolStatus(err) == 500 { +func Message(err error) string { + if Status(err) == 500 { return "runtime tool failed" } return workloadidentity.Redact(err.Error()) } -func bearer(header string) string { +func Bearer(header string) string { parts := strings.Fields(header) if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { return "" @@ -39,7 +39,7 @@ func bearer(header string) string { return parts[1] } -func commandToolInput(data []byte) (services.ToolInput, error) { +func Decode(data []byte) (services.ToolInput, error) { var input services.ToolInput decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() From 69381260ac8ae0c23a2a4b6d85c20bdf600db5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Schottst=C3=A4dt?= Date: Fri, 30 Oct 2026 20:18:38 +0100 Subject: [PATCH 14/14] feat(ui)!: declare builds and publish pairs Share upload-only and build-dependent publication across HTTP and MCP. Keep initialization upload-only and delegate live rebuilds to declared commands. BREAKING CHANGE: UI declarations require wasm/openui instead of path. Publication accepts with_build instead of a path override; no legacy fallback. --- Dockerfile.dev | 1 + api/openapi.yaml | 13 +- .../adapters/cli/build_launcher_test.go | 105 ++++++++++ apps/druid-dev/adapters/cli/root.go | 90 ++++---- .../adapters/cli/client/procedure_test.go | 2 +- apps/druid/adapters/cli/client/register.go | 2 +- .../cli/client/routing_publish_test.go | 2 +- .../adapters/daemonclient/openapi_client.go | 7 +- .../http/handlers/command_tools_test.go | 6 +- .../handlers/docker_command_tools_test.go | 2 +- .../adapters/http/handlers/scroll_handler.go | 11 +- apps/druid/adapters/mcp/schema_test.go | 7 + apps/druid/adapters/mcp/ui.go | 6 +- apps/druid/core/services/command_tools.go | 9 +- .../druid/core/services/command_tools_test.go | 12 +- .../druid/core/services/runtime_supervisor.go | 22 +- apps/druid/core/services/runtime_ui.go | 196 ++++++++++-------- apps/druid/core/services/runtime_ui_test.go | 171 ++++++++++++--- apps/druid/core/services/runtime_update.go | 2 +- internal/api/generated.go | 118 +++++------ internal/core/domain/runtime_scroll.go | 8 +- internal/core/domain/scroll.go | 38 ++-- internal/core/domain/scroll_test.go | 43 +++- internal/core/services/watch_service.go | 25 ++- scripts/build-ui.sh | 45 ++++ 25 files changed, 645 insertions(+), 298 deletions(-) create mode 100644 apps/druid-dev/adapters/cli/build_launcher_test.go create mode 100644 scripts/build-ui.sh diff --git a/Dockerfile.dev b/Dockerfile.dev index ace4c668..732fc864 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -12,4 +12,5 @@ ENV PATH=/usr/local/cargo/bin:$PATH RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable \ && rustup target add wasm32-unknown-unknown COPY --from=builder /out/druid-dev /usr/local/bin/druid-dev +COPY scripts/build-ui.sh /usr/local/bin/druid-build-ui.sh ENTRYPOINT ["druid-dev"] diff --git a/api/openapi.yaml b/api/openapi.yaml index 74e23abc..e37cbcd8 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -242,9 +242,10 @@ components: PublishUIPackageRequest: type: object properties: - path: - type: string - description: Optional scroll-root-relative .wasm path. Defaults to private/dist/app.wasm or public/dist/app.wasm. + with_build: + type: boolean + default: false + description: Run the scope's declared buildcommand as a dependency before uploading its declared WASM/OpenUI pair. RuntimeUIPackage: type: object @@ -252,11 +253,17 @@ components: - url - path - updated_at + - openui_url + - openui_path properties: url: type: string path: type: string + openui_url: + type: string + openui_path: + type: string updated_at: type: string format: date-time diff --git a/apps/druid-dev/adapters/cli/build_launcher_test.go b/apps/druid-dev/adapters/cli/build_launcher_test.go new file mode 100644 index 00000000..033bfdf4 --- /dev/null +++ b/apps/druid-dev/adapters/cli/build_launcher_test.go @@ -0,0 +1,105 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/highcard-dev/daemon/internal/devapi" +) + +func TestBuildLauncherWaitsAndRereadsRotatedCredential(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 is required by the dev image") + } + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "failure"}[fail], func(t *testing.T) { + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte("before"), 0600); err != nil { + t.Fatal(err) + } + var waits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/deployment/api/v1/command" { + if r.Header.Get("Authorization") != "Bearer before" { + t.Error("incorrect admission credential") + } + var input map[string]string + if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input["command"] != "build_ui" { + t.Error("wrong command payload") + } + if err := os.WriteFile(tokenFile, []byte("after"), 0600); err != nil { + t.Error(err) + } + w.Write([]byte(`{"accepted":true}`)) + return + } + if r.URL.Path != "/deployment/api/v1/commands/build_ui/wait" || r.Header.Get("Authorization") != "Bearer after" { + t.Error("wait did not use rotated credential or configured endpoint") + } + state := "done" + if waits.Add(1) == 1 { + state = "running" + } else if fail { + state = "error" + } + json.NewEncoder(w).Encode(map[string]any{"status": state}) + })) + defer server.Close() + command := exec.Command("sh", "../../../../scripts/build-ui.sh", "build_ui") + command.Env = append(os.Environ(), "DRUID_API_URL="+server.URL+"/deployment", "DRUID_TOKEN_FILE="+tokenFile) + output, err := command.CombinedOutput() + if (err != nil) != fail || waits.Load() != 2 { + t.Fatalf("err=%v waits=%d output=%s", err, waits.Load(), output) + } + if strings.Contains(string(output), "before") || strings.Contains(string(output), "after") { + t.Fatal("credential leaked") + } + }) + } +} + +func TestWatcherRerunsFiniteBuildWithoutWatchingItsOwnArtifacts(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "private"), 0755); err != nil { + t.Fatal(err) + } + server := newDevServer(root, devAuth{}, "private/custom.wasm", "private/layout.openui") + defer server.stopWatch() + err := server.startWatch(devapi.WatchModeRequest{WatchPaths: []string{"private"}, WorkingDirectory: ".", Command: []string{"sh", "-ec", "printf x >> count; printf wasm > private/custom.wasm; printf ui > private/layout.openui"}}) + if err != nil { + t.Fatal(err) + } + waitCount := func(want int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + data, _ := os.ReadFile(filepath.Join(root, "count")) + if len(data) == want { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("did not reach %d builds", want) + } + waitCount(1) + time.Sleep(700 * time.Millisecond) + data, _ := os.ReadFile(filepath.Join(root, "count")) + if len(data) != 1 { + t.Fatal("artifacts triggered another build") + } + if err := os.WriteFile(filepath.Join(root, "private", "source.rs"), []byte("source"), 0644); err != nil { + t.Fatal(err) + } + waitCount(2) + if string(server.watchResponse().Status) != statusReady { + t.Fatal("finite build disabled watcher") + } +} diff --git a/apps/druid-dev/adapters/cli/root.go b/apps/druid-dev/adapters/cli/root.go index 33db5836..fbd293ae 100644 --- a/apps/druid-dev/adapters/cli/root.go +++ b/apps/druid-dev/adapters/cli/root.go @@ -43,6 +43,7 @@ type options struct { watchPaths []string watchWorkdir string watchCommand []string + watchOutputs []string } func NewRootCommand() *cobra.Command { @@ -63,6 +64,7 @@ func NewRootCommand() *cobra.Command { cmd.Flags().StringArrayVar(&opt.watchPaths, "watch-path", nil, "Runtime-relative path to watch (repeatable)") cmd.Flags().StringVar(&opt.watchWorkdir, "watch-working-directory", ".", "Runtime-relative working directory for the watcher command") cmd.Flags().StringArrayVar(&opt.watchCommand, "watch-command", nil, "Watcher command argument (repeatable, preserves argument boundaries)") + cmd.Flags().StringArrayVar(&opt.watchOutputs, "watch-output", nil, "Built artifact path to notify without triggering another build") _ = cmd.MarkFlagRequired("root") return cmd } @@ -85,7 +87,7 @@ func run(ctx context.Context, opt options) error { return err } } - server := newDevServer(root, auth) + server := newDevServer(root, auth, opt.watchOutputs...) watchRequest, err := startupWatchRequest(opt) if err != nil { return err @@ -145,16 +147,16 @@ type devAuth struct { ownerID string } -type noopQueue struct{} +type buildQueue struct{ run func() error } -func (noopQueue) AddTempItem(string) error { return nil } -func (noopQueue) AddTempItemWithWait(string) error { return nil } -func (noopQueue) GetQueue() map[string]domain.ScrollLockStatus { return nil } +func (q buildQueue) AddTempItem(string) error { return nil } +func (q buildQueue) AddTempItemWithWait(string) error { return q.run() } +func (q buildQueue) GetQueue() map[string]domain.ScrollLockStatus { return nil } type noopScroll struct{} func (noopScroll) GetCommand(string) (*domain.CommandInstructionSet, error) { - return nil, fmt.Errorf("watcher does not run daemon commands") + return &domain.CommandInstructionSet{}, nil } func (noopScroll) GetCurrent() *domain.Scroll { return nil } func (noopScroll) GetFile() *domain.File { return &domain.File{} } @@ -178,10 +180,18 @@ type devServer struct { config watchConfig cancel context.CancelFunc watchID uint64 + build func() error } -func newDevServer(root string, auth devAuth) *devServer { - return &devServer{root: root, auth: auth, watch: coreservices.NewDevService(noopQueue{}, noopScroll{}), status: statusDisabled} +func newDevServer(root string, auth devAuth, outputs ...string) *devServer { + server := &devServer{root: root, auth: auth, status: statusDisabled} + server.watch = coreservices.NewDevService(buildQueue{run: func() error { + server.mu.RLock() + build := server.build + server.mu.RUnlock() + return build() + }}, noopScroll{}, outputs...) + return server } func newApp(server *devServer) *fiber.App { @@ -317,54 +327,36 @@ func (s *devServer) startWatch(request devapi.WatchModeRequest) error { s.watchID++ watchID := s.watchID s.mu.Unlock() - if err := s.watch.StartWatching(s.root, paths...); err != nil { - s.setWatchError(err) - logger.Log().Error("Failed to start Druid development watcher", zap.Error(err)) + ctx, cancel := context.WithCancel(context.Background()) + s.mu.Lock() + s.build = func() error { + child := exec.CommandContext(ctx, request.Command[0], request.Command[1:]...) + child.Dir = workdir + child.Stdout, child.Stderr = os.Stdout, os.Stderr + err := child.Run() + s.mu.Lock() + if s.watchID == watchID && ctx.Err() == nil { + s.status, s.lastErr = statusReady, "" + if err != nil { + s.status, s.lastErr = statusError, err.Error() + } + } + s.mu.Unlock() return err } - ctx, cancel := context.WithCancel(context.Background()) - child := exec.CommandContext(ctx, request.Command[0], request.Command[1:]...) - child.Dir = workdir - child.Stdout, child.Stderr = os.Stdout, os.Stderr - if err := child.Start(); err != nil { + s.mu.Unlock() + if err := s.watch.SetHotReloadCommands([]string{"build"}); err != nil { cancel() - _ = s.watch.StopWatching() - s.setWatchError(err) - logger.Log().Error("Failed to start Druid development build process", zap.Error(err)) return err } - logger.Log().Info("Druid development build process started", - zap.Int("pid", child.Process.Pid), - zap.String("working_directory", workdir), - zap.Strings("command", request.Command), - ) s.mu.Lock() - s.cancel = cancel - s.status = statusReady + s.cancel, s.status = cancel, statusReady s.mu.Unlock() - go func() { - err := child.Wait() - s.mu.Lock() - if s.watchID == watchID { - s.cancel = nil - if err != nil && ctx.Err() == nil { - s.status = statusError - s.lastErr = err.Error() - } else { - s.status = statusDisabled - s.lastErr = "" - } - } - s.mu.Unlock() - if err != nil && ctx.Err() == nil { - logger.Log().Error("Druid development build process exited with an error", zap.Error(err)) - } else { - logger.Log().Info("Druid development build process stopped") - } - if ctx.Err() == nil { - _ = s.watch.StopWatching() - } - }() + if err := s.watch.StartWatching(s.root, paths...); err != nil { + cancel() + s.setWatchError(err) + return err + } return nil } diff --git a/apps/druid/adapters/cli/client/procedure_test.go b/apps/druid/adapters/cli/client/procedure_test.go index 3edcc63f..68f1abb6 100644 --- a/apps/druid/adapters/cli/client/procedure_test.go +++ b/apps/druid/adapters/cli/client/procedure_test.go @@ -156,6 +156,6 @@ func (f *fakeProcedureDaemon) GetScrollUIPackages(ctx context.Context, id string return map[string]api.RuntimeUIPackage{}, nil } -func (f *fakeProcedureDaemon) PublishScrollUIPackage(ctx context.Context, id string, scope string, path string) (*api.RuntimeScroll, error) { +func (f *fakeProcedureDaemon) PublishScrollUIPackage(ctx context.Context, id string, scope string, withBuild bool) (*api.RuntimeScroll, error) { return &api.RuntimeScroll{Id: id}, nil } diff --git a/apps/druid/adapters/cli/client/register.go b/apps/druid/adapters/cli/client/register.go index b0cf405f..29cb70cf 100644 --- a/apps/druid/adapters/cli/client/register.go +++ b/apps/druid/adapters/cli/client/register.go @@ -24,7 +24,7 @@ type RuntimeDaemon interface { GetScrollRoutingTargets(ctx context.Context, id string) ([]api.RuntimeRoutingTarget, error) ApplyScrollRouting(ctx context.Context, id string, assignments []api.RuntimeRouteAssignment) (*api.RuntimeScroll, error) GetScrollUIPackages(ctx context.Context, id string) (map[string]api.RuntimeUIPackage, error) - PublishScrollUIPackage(ctx context.Context, id string, scope string, path string) (*api.RuntimeScroll, error) + PublishScrollUIPackage(ctx context.Context, id string, scope string, withBuild bool) (*api.RuntimeScroll, error) } type Config struct { diff --git a/apps/druid/adapters/cli/client/routing_publish_test.go b/apps/druid/adapters/cli/client/routing_publish_test.go index 004bb0db..80796da2 100644 --- a/apps/druid/adapters/cli/client/routing_publish_test.go +++ b/apps/druid/adapters/cli/client/routing_publish_test.go @@ -201,6 +201,6 @@ func (f *fakeRoutingDaemon) GetScrollUIPackages(ctx context.Context, id string) return map[string]api.RuntimeUIPackage{}, nil } -func (f *fakeRoutingDaemon) PublishScrollUIPackage(ctx context.Context, id string, scope string, path string) (*api.RuntimeScroll, error) { +func (f *fakeRoutingDaemon) PublishScrollUIPackage(ctx context.Context, id string, scope string, withBuild bool) (*api.RuntimeScroll, error) { return &api.RuntimeScroll{Id: id, Status: api.RuntimeScrollStatusCreated}, nil } diff --git a/apps/druid/adapters/daemonclient/openapi_client.go b/apps/druid/adapters/daemonclient/openapi_client.go index 88d1de2d..ee7288db 100644 --- a/apps/druid/adapters/daemonclient/openapi_client.go +++ b/apps/druid/adapters/daemonclient/openapi_client.go @@ -273,11 +273,8 @@ func (c *OpenAPIClient) GetScrollUIPackages(ctx context.Context, id string) (map return *res.JSON200, nil } -func (c *OpenAPIClient) PublishScrollUIPackage(ctx context.Context, id string, scope string, path string) (*api.RuntimeScroll, error) { - request := api.PublishUIPackageRequest{} - if path != "" { - request.Path = &path - } +func (c *OpenAPIClient) PublishScrollUIPackage(ctx context.Context, id string, scope string, withBuild bool) (*api.RuntimeScroll, error) { + request := api.PublishUIPackageRequest{WithBuild: &withBuild} res, err := c.client.PublishScrollUIPackageWithResponse(ctx, id, api.PublishScrollUIPackageParamsScope(scope), request) if err != nil { return nil, err diff --git a/apps/druid/adapters/http/handlers/command_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go index 397a500c..c3d0fb6c 100644 --- a/apps/druid/adapters/http/handlers/command_tools_test.go +++ b/apps/druid/adapters/http/handlers/command_tools_test.go @@ -72,7 +72,7 @@ func TestCommandToolsRealMCPClient(t *testing.T) { if err != nil { t.Fatal(err) } - runtime := &domain.RuntimeScroll{ID: "tools", Root: t.TempDir(), ScrollName: "tools", ScrollYAML: "name: tools\ndesc: Tools fixture\nversion: 0.1.0\napp_version: 1.0\ncommands:\n agent:\n run: persistent\n identity:\n permissions: [commands:list, commands:create, queue:read, ui:publish]\n procedures:\n - image: busybox\n env: {SECRET: must-not-leak}\n"} + runtime := &domain.RuntimeScroll{ID: "tools", Root: t.TempDir(), ScrollName: "tools", ScrollYAML: "name: tools\ndesc: Tools fixture\nversion: 0.1.0\napp_version: 1.0\nui:\n private: {buildcommand: ui_build, wasm: private/dist/app.wasm, openui: private/view.openui}\n public: {buildcommand: ui_build, wasm: public/dist/app.wasm, openui: public/view.openui}\ncommands:\n ui_build:\n procedures:\n - image: busybox\n command: [true]\n agent:\n run: persistent\n identity:\n permissions: [commands:list, commands:create, queue:read, ui:publish]\n procedures:\n - image: busybox\n env: {SECRET: must-not-leak}\n"} runtime.Procedures = domain.ProcedureStatusMap{"agent": {"agent.0": {Status: domain.ScrollLockStatusRunning}}} if err := store.CreateScroll(runtime); err != nil { t.Fatal(err) @@ -203,7 +203,7 @@ func TestCommandToolsRealMCPClient(t *testing.T) { backend.uploadError = nil } }) - publication, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "publish_ui", Arguments: map[string]any{"scope": "private"}}) + publication, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "publish_ui", Arguments: map[string]any{"scope": "private", "with_build": true}}) if err != nil || publication.IsError { t.Fatalf("MCP publish failed: %v, %v", err, publication) } @@ -352,7 +352,7 @@ func TestCommandToolsRealMCPClient(t *testing.T) { status int }{ {"GET", "/tools/api/v1/queue", `{}`, 200}, - {"POST", "/tools/api/v1/ui/packages/private/publish", `{}`, 200}, + {"POST", "/tools/api/v1/ui/packages/private/publish", `{"with_build":true}`, 200}, {"POST", "/tools/api/v1/ui/packages/public/publish", `{}`, 200}, {"POST", "/tools/api/v1/command", `{"command":"owner-cleanup"}`, 200}, {"DELETE", "/tools/api/v1/scroll/commands/owner-cleanup", ``, 204}, diff --git a/apps/druid/adapters/http/handlers/docker_command_tools_test.go b/apps/druid/adapters/http/handlers/docker_command_tools_test.go index 8091e6df..00be0023 100644 --- a/apps/druid/adapters/http/handlers/docker_command_tools_test.go +++ b/apps/druid/adapters/http/handlers/docker_command_tools_test.go @@ -26,7 +26,7 @@ func TestDockerToolsAreAnonymousWhileOwnerRoutesRemainProtected(t *testing.T) { t.Fatal(err) } for _, id := range []string{"one", "other"} { - if err := store.CreateScroll(&domain.RuntimeScroll{ID: id, Root: t.TempDir(), ScrollName: id, ScrollYAML: "name: fixture\ndesc: fixture\nversion: 0.1.0\napp_version: 1.0\ncommands:\n echo:\n procedures:\n - image: busybox\n"}); err != nil { + if err := store.CreateScroll(&domain.RuntimeScroll{ID: id, Root: t.TempDir(), ScrollName: id, ScrollYAML: "name: fixture\ndesc: fixture\nversion: 0.1.0\napp_version: 1.0\nui:\n private: {wasm: private/dist/app.wasm, openui: private/view.openui}\n public: {wasm: public/dist/app.wasm, openui: public/view.openui}\ncommands:\n echo:\n procedures:\n - image: busybox\n"}); err != nil { t.Fatal(err) } } diff --git a/apps/druid/adapters/http/handlers/scroll_handler.go b/apps/druid/adapters/http/handlers/scroll_handler.go index 3b5e9063..f4c86580 100644 --- a/apps/druid/adapters/http/handlers/scroll_handler.go +++ b/apps/druid/adapters/http/handlers/scroll_handler.go @@ -343,19 +343,14 @@ func (h *ScrollHandler) PublishScrollUIPackage(c *fiber.Ctx, id string, scope ap if _, err := h.getScroll(id); err != nil { return err } - var request struct { - Path *string `json:"path,omitempty"` - } + var request api.PublishUIPackageRequest if len(c.Body()) > 0 { if err := c.BodyParser(&request); err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } } - path := "" - if request.Path != nil { - path = *request.Path - } - runtimeScroll, err := h.supervisor.PublishUIPackage(id, string(scope), path) + withBuild := request.WithBuild != nil && *request.WithBuild + runtimeScroll, err := h.supervisor.PublishUIPackage(id, string(scope), withBuild) if err != nil { return err } diff --git a/apps/druid/adapters/mcp/schema_test.go b/apps/druid/adapters/mcp/schema_test.go index d3c9914d..fa57c53a 100644 --- a/apps/druid/adapters/mcp/schema_test.go +++ b/apps/druid/adapters/mcp/schema_test.go @@ -15,6 +15,13 @@ func TestPublishUIDescriptionRequiresSeparateCustomerApproval(t *testing.T) { } } +func TestPublishUISchemaUsesDeclaredArtifactsAndOptionalBuild(t *testing.T) { + properties := publishUITool().InputSchema.(map[string]any)["properties"].(map[string]any) + if len(properties) != 2 || properties["scope"] == nil || properties["with_build"] == nil || properties["path"] != nil { + t.Fatalf("unexpected publication schema: %#v", properties) + } +} + func TestCommandWaitSchemaUsesOnlyCommandNameAndTimeout(t *testing.T) { tool := waitForCommandTool() schema := tool.InputSchema.(map[string]any) diff --git a/apps/druid/adapters/mcp/ui.go b/apps/druid/adapters/mcp/ui.go index 08bb80fe..ab04e93b 100644 --- a/apps/druid/adapters/mcp/ui.go +++ b/apps/druid/adapters/mcp/ui.go @@ -5,9 +5,9 @@ import ( ) func publishUITool() *mcp.Tool { - return commandMCPTool("publish_ui", "Publish an already built Druid UI WASM/OpenUI pair for this deployment. First show focused previews, explain changes and scope, ask for publishing approval, end your turn and wait for a subsequent explicit customer reply. The original UI-change request is not approval. Revisions require renewed approval; failed previews must not be published automatically. For behavior-only changes, explain the preview limitation and still obtain approval. Do not bypass approval through HTTP, terminal commands, Developer Mode or live watchers. This is an agent instruction, not a server-enforced approval check. Does not build or publish npm/Scroll artifacts. Choose private or public explicitly; path defaults to /dist/app.wasm with an adjacent .openui document. Returns the published package only after upload succeeds.", map[string]any{ - "scope": map[string]any{"type": "string", "enum": []string{"private", "public"}, "description": "UI scope; use private unless the customer requests public UI"}, - "path": stringField("Optional scope-relative .wasm path; the matching .openui file must be adjacent"), + return commandMCPTool("publish_ui", "Publish the declared Druid UI WASM/OpenUI pair for this deployment. Set with_build=true to first run the scope's configured buildcommand as a dependency. First show focused previews, explain changes and scope, ask for publishing approval, end your turn and wait for a subsequent explicit customer reply. The original UI-change request is not approval. Revisions require renewed approval; failed previews must not be published automatically. For behavior-only changes, explain the preview limitation and still obtain approval. Do not bypass approval through HTTP, terminal commands, Developer Mode or live watchers. This is an agent instruction, not a server-enforced approval check. Does not publish npm/Scroll artifacts. Choose private or public explicitly. The artifact paths come from ui..wasm and ui..openui; with_build defaults to false. Returns the published package only after upload succeeds.", map[string]any{ + "scope": map[string]any{"type": "string", "enum": []string{"private", "public"}, "description": "UI scope; use private unless the customer requests public UI"}, + "with_build": map[string]any{"type": "boolean", "default": false, "description": "Run the scope's declared buildcommand as a needs dependency before uploading; false uploads prebuilt artifacts only"}, }, "scope") } diff --git a/apps/druid/core/services/command_tools.go b/apps/druid/core/services/command_tools.go index 04e6e9d6..f0a470bf 100644 --- a/apps/druid/core/services/command_tools.go +++ b/apps/druid/core/services/command_tools.go @@ -17,7 +17,7 @@ import ( type ToolInput struct { Scope string `json:"scope,omitempty"` - Path string `json:"path,omitempty"` + WithBuild bool `json:"with_build,omitempty"` Instructions *domain.CommandInstructionSet `json:"instructions,omitempty"` Command string `json:"command,omitempty"` Console string `json:"console,omitempty"` @@ -58,11 +58,8 @@ func (s *RuntimeSupervisor) CallCommandTool(ctx context.Context, token, deployme } switch name { case "publish_ui": - scope, path, err := normalizeUIPackageRequest(input.Scope, input.Path) - if err != nil { - return nil, toolError(400, err.Error()) - } - updated, err := s.PublishUIPackage(deployment, string(scope), path) + scope := domain.RuntimeUIPackageScope(input.Scope) + updated, err := s.PublishUIPackage(deployment, input.Scope, input.WithBuild) if err != nil { return nil, err } diff --git a/apps/druid/core/services/command_tools_test.go b/apps/druid/core/services/command_tools_test.go index 0396c584..9620fea0 100644 --- a/apps/druid/core/services/command_tools_test.go +++ b/apps/druid/core/services/command_tools_test.go @@ -128,7 +128,7 @@ func TestPublishUIToolPermissionsProjectionAndPaths(t *testing.T) { commands = append(commands, command.Name) return nil, nil } - for _, input := range []ToolInput{{Scope: "private"}, {Scope: "public"}, {Scope: "private", Path: "private/custom/widget.wasm"}} { + for _, input := range []ToolInput{{Scope: "private"}, {Scope: "public"}, {Scope: "private"}} { result, err := s.CallCommandTool(ctx, token, "tools", "publish_ui", input) if err != nil { t.Fatal(err) @@ -137,10 +137,7 @@ func TestPublishUIToolPermissionsProjectionAndPaths(t *testing.T) { if !strings.Contains(data, `"published":true`) || !strings.Contains(data, `"updated_at"`) || strings.Contains(data, "uploads/") || strings.Contains(data, "ScrollYAML") || strings.Contains(data, token) { t.Fatalf("unsafe or incomplete publication response: %s", data) } - wantPath := input.Path - if wantPath == "" { - wantPath = input.Scope + "/dist/app.wasm" - } + wantPath := input.Scope + "/dist/app.wasm" if !strings.Contains(data, wantPath) { t.Fatal("wrong published path") } @@ -150,7 +147,7 @@ func TestPublishUIToolPermissionsProjectionAndPaths(t *testing.T) { t.Fatalf("publication unexpectedly built: %s", command) } } - for _, input := range []ToolInput{{}, {Scope: "other"}, {Scope: "private", Path: "../outside.wasm"}, {Scope: "private", Path: "public/app.wasm"}, {Scope: "private", Path: "data/private/app.wasm"}, {Scope: "private", Path: "private/app.js"}} { + for _, input := range []ToolInput{{}, {Scope: "other"}} { _, err := s.CallCommandTool(ctx, token, "tools", "publish_ui", input) var failure *ToolError if !errors.As(err, &failure) || failure.Status != 400 { @@ -259,6 +256,9 @@ func commandToolsFixture(t *testing.T, permissions ...string) (*RuntimeSuperviso desc: Tools fixture version: 0.1.0 app_version: "1.0" +ui: + private: {wasm: private/dist/app.wasm, openui: private/view.openui} + public: {wasm: public/dist/app.wasm, openui: public/view.openui} commands: agent: run: persistent diff --git a/apps/druid/core/services/runtime_supervisor.go b/apps/druid/core/services/runtime_supervisor.go index d46036ff..0b2172d7 100644 --- a/apps/druid/core/services/runtime_supervisor.go +++ b/apps/druid/core/services/runtime_supervisor.go @@ -303,22 +303,32 @@ func (s *RuntimeSupervisor) applyMaterializedScroll(runtimeScroll *domain.Runtim if err := s.store.UpdateScroll(runtimeScroll); err != nil { return nil, err } - if err := s.publishDeclaredPrivateUI(runtimeScroll, scroll); err != nil { + if err := s.publishDeclaredUI(runtimeScroll, scroll); err != nil { return nil, err } return s.store.GetScroll(runtimeScroll.ID) } -func (s *RuntimeSupervisor) publishDeclaredPrivateUI(runtimeScroll *domain.RuntimeScroll, scroll *domain.Scroll) error { - if scroll.UI == nil || scroll.UI.Private == nil { +func (s *RuntimeSupervisor) publishDeclaredUI(runtimeScroll *domain.RuntimeScroll, scroll *domain.Scroll) error { + if scroll.UI == nil { return nil } - _, err := s.PublishUIPackage(runtimeScroll.ID, string(domain.RuntimeUIPackageScopePrivate), scroll.UI.Private.Path) + paths := map[domain.RuntimeUIPackageScope]*domain.UIPackageDeclaration{} + if scroll.UI.Private != nil { + paths[domain.RuntimeUIPackageScopePrivate] = scroll.UI.Private + } + if scroll.UI.Public != nil { + paths[domain.RuntimeUIPackageScopePublic] = scroll.UI.Public + } + if len(paths) == 0 { + return nil + } + _, err := s.publishUIPackages(runtimeScroll.ID, paths, false) if err != nil { runtimeScroll.Status = domain.RuntimeScrollStatusError - runtimeScroll.LastError = fmt.Sprintf("publish declared private UI: %v", err) + runtimeScroll.LastError = fmt.Sprintf("publish declared UI: %v", err) _ = s.store.UpdateScroll(runtimeScroll) - return fmt.Errorf("publish declared private UI: %w", err) + return fmt.Errorf("publish declared UI: %w", err) } return nil } diff --git a/apps/druid/core/services/runtime_ui.go b/apps/druid/core/services/runtime_ui.go index 6c334b35..e86acca8 100644 --- a/apps/druid/core/services/runtime_ui.go +++ b/apps/druid/core/services/runtime_ui.go @@ -3,7 +3,7 @@ package services import ( "context" "fmt" - "path/filepath" + "slices" "strings" "time" @@ -13,102 +13,143 @@ import ( ) const ( - defaultPrivateUIPackagePath = "private/dist/app.wasm" - defaultPublicUIPackagePath = "public/dist/app.wasm" - uiPublishImage = "curlimages/curl:8.12.1" + uiPublishImage = "curlimages/curl:8.12.1" ) -// PublishUIPackage runs a one-shot command with a short-lived upload URL for a -// unique, immutable object. The URL is supplied only to that command. -func (s *RuntimeSupervisor) PublishUIPackage(id string, scope string, sourcePath string) (*domain.RuntimeScroll, error) { - runtimeScroll, err := s.store.GetScroll(id) - if err != nil { - return nil, err +// PublishUIPackage publishes a single scope through the same one-command upload path. +func (s *RuntimeSupervisor) PublishUIPackage(id string, scope string, withBuild bool) (*domain.RuntimeScroll, error) { + if scope != "private" && scope != "public" { + return nil, toolError(400, "ui package scope must be private or public") } - uiScope, sourcePath, err := normalizeUIPackageRequest(scope, sourcePath) + scroll, err := s.ScrollFile(id) if err != nil { return nil, err } - action := ports.RuntimeUIPackageUploadAction{ - RuntimeID: runtimeScroll.ID, - RootRef: runtimeScroll.Root, - Scope: uiScope, - RequestID: uuid.NewString(), + var declaration *domain.UIPackageDeclaration + if scroll.UI != nil { + if scope == "private" { + declaration = scroll.UI.Private + } else { + declaration = scroll.UI.Public + } } - upload, err := s.runtimeBackend.CreateUIPackageUpload(context.Background(), action) - if err != nil { - return nil, fmt.Errorf("create UI package upload: %w", err) + if declaration == nil { + return nil, toolError(400, "UI scope is not declared") } - if upload.UploadURL == "" || upload.DocumentUploadURL == "" || upload.URL == "" { - return nil, fmt.Errorf("UI package upload is incomplete") + if withBuild && declaration.BuildCommand == "" { + return nil, toolError(400, "UI scope has no buildcommand") } + return s.publishUIPackages(id, map[domain.RuntimeUIPackageScope]*domain.UIPackageDeclaration{domain.RuntimeUIPackageScope(scope): declaration}, withBuild) +} - commandName := "ui_publish_" + string(uiScope) + "_" + strings.ReplaceAll(action.RequestID, "-", "")[:12] - command := uiPublishCommand() - session, err := s.sessionFor(runtimeScroll.ID) +// All declared pairs share one container; references change only after all uploads succeed. +func (s *RuntimeSupervisor) publishUIPackages(id string, declarations map[domain.RuntimeUIPackageScope]*domain.UIPackageDeclaration, withBuild bool) (*domain.RuntimeScroll, error) { + runtimeScroll, err := s.store.GetScroll(id) if err != nil { return nil, err } + requestID := uuid.NewString() + scopes := []domain.RuntimeUIPackageScope{} + for scope := range declarations { + scopes = append(scopes, scope) + } + slices.Sort(scopes) + env := map[string]string{} + packages := domain.RuntimeUIPackages{} + for _, scope := range scopes { + declaration := declarations[scope] + upload, err := s.runtimeBackend.CreateUIPackageUpload(context.Background(), ports.RuntimeUIPackageUploadAction{ + RuntimeID: runtimeScroll.ID, RootRef: runtimeScroll.Root, Scope: scope, RequestID: requestID, + }) + if err != nil { + return nil, fmt.Errorf("create UI package upload: %w", err) + } + if upload.UploadURL == "" || upload.DocumentUploadURL == "" || upload.URL == "" { + return nil, fmt.Errorf("UI package upload is incomplete") + } + prefix := "DRUID_UI_" + strings.ToUpper(string(scope)) + "_" + env[prefix+"SOURCE"] = declaration.WASM + env[prefix+"DOCUMENT_SOURCE"] = declaration.OpenUI + env[prefix+"UPLOAD_URL"] = upload.UploadURL + env[prefix+"DOCUMENT_UPLOAD_URL"] = upload.DocumentUploadURL + packages[scope] = domain.RuntimeUIPackage{URL: upload.URL, Path: declaration.WASM, OpenUIURL: strings.TrimSuffix(upload.URL, ".wasm") + ".openui", OpenUIPath: declaration.OpenUI, UpdatedAt: time.Now().UTC()} + } + commandName := "ui_publish_" + strings.ReplaceAll(requestID, "-", "")[:12] + command := uiPublishCommand(scopes) + if withBuild { + commandName = "publish_with_build_" + strings.ReplaceAll(requestID, "-", "")[:12] + for _, scope := range scopes { + command.Needs = append(command.Needs, declarations[scope].BuildCommand) + } + } + session, err := s.sessionFor(id) + if err != nil { + return nil, err + } + // Explicitly request a fresh build; needs alone accepts previously completed dependencies. + for _, dependency := range command.Needs { + if err := session.AddForcedItem(dependency); err != nil { + return nil, err + } + } if err := session.AddCommand(commandName, command); err != nil { return nil, err } defer func() { _ = session.RemoveCommand(commandName) }() procedureName := domain.ProcedureName(commandName, 0, command.Procedures[0]) - if err := session.AddTempItemWithWaitEnv(commandName, map[string]map[string]string{ - procedureName: { - "DRUID_UI_SOURCE": sourcePath, - "DRUID_UI_DOCUMENT_SOURCE": strings.TrimSuffix(sourcePath, ".wasm") + ".openui", - "DRUID_UI_UPLOAD_URL": upload.UploadURL, - "DRUID_UI_DOCUMENT_UPLOAD_URL": upload.DocumentUploadURL, - }, - }); err != nil { + if err := session.AddTempItemWithWaitEnv(commandName, map[string]map[string]string{procedureName: env}); err != nil { return nil, err } - runtimeScroll, err = s.store.GetScroll(id) - if err != nil { - return nil, err + // Persist through the live session so subsequent status/command writes retain the pair. + session.mu.Lock() + previous := session.runtimeScroll.UIPackages + updated := domain.RuntimeUIPackages{} + for scope, pkg := range previous { + updated[scope] = pkg } - if runtimeScroll.UIPackages == nil { - runtimeScroll.UIPackages = domain.RuntimeUIPackages{} + for scope, pkg := range packages { + updated[scope] = pkg } - runtimeScroll.UIPackages[uiScope] = domain.RuntimeUIPackage{ - URL: upload.URL, Path: sourcePath, UpdatedAt: time.Now().UTC(), + session.runtimeScroll.UIPackages = updated + err = s.store.UpdateScroll(session.runtimeScroll) + if err != nil { + session.runtimeScroll.UIPackages = previous } - if err := s.store.UpdateScroll(runtimeScroll); err != nil { + session.mu.Unlock() + if err != nil { return nil, err } return s.store.GetScroll(id) } -func uiPublishCommand() *domain.CommandInstructionSet { - return &domain.CommandInstructionSet{ - Run: domain.RunModeOnce, - Procedures: []*domain.Procedure{{ - Image: uiPublishImage, - WorkingDir: "/app/resources/deployment", - Mounts: []domain.Mount{{Path: "/app/resources/deployment", SubPath: ".", ReadOnly: true}}, - Command: []string{"sh", "-ec", ` -test -n "$(printenv DRUID_UI_SOURCE)" -test -n "$(printenv DRUID_UI_DOCUMENT_SOURCE)" -test -n "$(printenv DRUID_UI_UPLOAD_URL)" -test -n "$(printenv DRUID_UI_DOCUMENT_UPLOAD_URL)" -case "$(printenv DRUID_UI_SOURCE)" in private/*.wasm|public/*.wasm) ;; *) echo "invalid UI package path" >&2; exit 1;; esac -test -f "$(printenv DRUID_UI_SOURCE)" -test -s "$(printenv DRUID_UI_SOURCE)" -test -f "$(printenv DRUID_UI_DOCUMENT_SOURCE)" -test -s "$(printenv DRUID_UI_DOCUMENT_SOURCE)" -echo "Uploading Druid UI package" -curl --fail-with-body --silent --show-error --request PUT --upload-file "$(printenv DRUID_UI_DOCUMENT_SOURCE)" \ +func uiPublishCommand(scopes []domain.RuntimeUIPackageScope) *domain.CommandInstructionSet { + var checks, uploads strings.Builder + for _, scope := range scopes { + prefix := "DRUID_UI_" + strings.ToUpper(string(scope)) + "_" + // Literal printenv references survive the Scroll environment expansion. + fmt.Fprintf(&checks, ` +case "$(printenv %[1]sSOURCE)" in %[2]s/*.wasm) ;; *) echo "invalid UI package path" >&2; exit 1;; esac +test -s "$(printenv %[1]sSOURCE)" +test -s "$(printenv %[1]sDOCUMENT_SOURCE)" +test -n "$(printenv %[1]sUPLOAD_URL)" +test -n "$(printenv %[1]sDOCUMENT_UPLOAD_URL)" +`, prefix, scope) + fmt.Fprintf(&uploads, ` +curl --fail-with-body --silent --show-error --request PUT --upload-file "$(printenv %[1]sDOCUMENT_SOURCE)" \ --header "Content-Type: text/plain; charset=utf-8" \ --header "Cache-Control: public, max-age=31536000, immutable" \ - "$(printenv DRUID_UI_DOCUMENT_UPLOAD_URL)" -curl --fail-with-body --silent --show-error --request PUT --upload-file "$(printenv DRUID_UI_SOURCE)" \ + "$(printenv %[1]sDOCUMENT_UPLOAD_URL)" +curl --fail-with-body --silent --show-error --request PUT --upload-file "$(printenv %[1]sSOURCE)" \ --header "Content-Type: application/wasm" \ --header "Cache-Control: public, max-age=31536000, immutable" \ - "$(printenv DRUID_UI_UPLOAD_URL)" -`}, - }}, - } + "$(printenv %[1]sUPLOAD_URL)" +`, prefix) + } + return &domain.CommandInstructionSet{Run: domain.RunModeOnce, Procedures: []*domain.Procedure{{ + Image: uiPublishImage, WorkingDir: "/app/resources/deployment", + Mounts: []domain.Mount{{Path: "/app/resources/deployment", SubPath: ".", ReadOnly: true}}, + Command: []string{"sh", "-ec", checks.String() + uploads.String()}, + }}} } func (s *RuntimeSupervisor) UIPackages(id string) (domain.RuntimeUIPackages, error) { @@ -121,28 +162,3 @@ func (s *RuntimeSupervisor) UIPackages(id string) (domain.RuntimeUIPackages, err } return runtimeScroll.UIPackages, nil } - -func normalizeUIPackageRequest(scope string, sourcePath string) (domain.RuntimeUIPackageScope, string, error) { - uiScope := domain.RuntimeUIPackageScope(strings.ToLower(strings.TrimSpace(scope))) - if uiScope != domain.RuntimeUIPackageScopePrivate && uiScope != domain.RuntimeUIPackageScopePublic { - return "", "", fmt.Errorf("ui package scope must be private or public") - } - if sourcePath == "" { - if uiScope == domain.RuntimeUIPackageScopePublic { - sourcePath = defaultPublicUIPackagePath - } else { - sourcePath = defaultPrivateUIPackagePath - } - } - cleaned := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(sourcePath, "/"))) - if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || filepath.IsAbs(sourcePath) { - return "", "", fmt.Errorf("ui package path must stay inside the scroll root") - } - if strings.HasPrefix(cleaned, "data/private/") || strings.HasPrefix(cleaned, "data/public/") { - return "", "", fmt.Errorf("ui package path must be rooted at private/ or public/, not data/") - } - if filepath.Ext(cleaned) != ".wasm" || !strings.HasPrefix(cleaned, string(uiScope)+"/") { - return "", "", fmt.Errorf("ui package path must point to a %s .wasm file", uiScope) - } - return uiScope, cleaned, nil -} diff --git a/apps/druid/core/services/runtime_ui_test.go b/apps/druid/core/services/runtime_ui_test.go index eccae72b..d06c79cd 100644 --- a/apps/druid/core/services/runtime_ui_test.go +++ b/apps/druid/core/services/runtime_ui_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" @@ -13,41 +14,58 @@ import ( coreservices "github.com/highcard-dev/daemon/internal/core/services" ) -func TestNormalizeUIPackageRequestDefaults(t *testing.T) { - tests := []struct { - scope string - want string - }{ - {"private", "private/dist/app.wasm"}, - {"public", "public/dist/app.wasm"}, +func uiScrollYAML() string { + return cachedScrollYAML("") + "ui:\n private:\n wasm: private/dist/app.wasm\n openui: private/templates/view.openui\n public:\n wasm: public/dist/app.wasm\n openui: public/templates/view.openui\n" +} + +func TestPublishWithBuildUsesFreshDependency(t *testing.T) { + store := newTestStateStore(t) + yaml := strings.Replace(uiScrollYAML(), "wasm: private/dist/app.wasm", "buildcommand: start\n wasm: private/dist/app.wasm", 1) + if err := store.CreateScroll(&domain.RuntimeScroll{ID: "build-ui", Root: "runtime://build-ui", ScrollName: "cached", ScrollYAML: yaml}); err != nil { + t.Fatal(err) } - for _, tt := range tests { - t.Run(tt.scope, func(t *testing.T) { - _, got, err := normalizeUIPackageRequest(tt.scope, "") - if err != nil { - t.Fatal(err) - } - if got != tt.want { - t.Fatalf("path = %q, want %q", got, tt.want) + var runs []string + backend := &fakeWorkerBackend{runCommand: func(cmd ports.RuntimeCommand) (*int, error) { + if cmd.Name == "start" { + runs = append(runs, "build") + } else { + runs = append(runs, "upload") + if !strings.HasPrefix(cmd.Name, "publish_with_build_") || !reflect.DeepEqual(cmd.Command.Needs, []string{"start"}) { + t.Errorf("missing build dependency: %#v", cmd) } - }) + } + return nil, nil + }} + supervisor := newRuntimeSupervisorForTest(t, store, coreservices.NewRuntimeScrollManager(store), backend) + for i := 0; i < 2; i++ { + if _, err := supervisor.PublishUIPackage("build-ui", "private", true); err != nil { + t.Fatal(err) + } + } + want := []string{"build", "upload", "build", "upload"} + if !reflect.DeepEqual(runs, want) { + t.Fatalf("runs: %v, want %v", runs, want) } } -func TestNormalizeUIPackageRequestRejectsInvalidPaths(t *testing.T) { - for _, path := range []string{"data/private/dist/app.wasm", "data/public/dist/app.wasm", "../private/dist/app.wasm", "private/dist/app.js", "public/dist/app.wasm"} { - t.Run(path, func(t *testing.T) { - if _, _, err := normalizeUIPackageRequest("private", path); err == nil { - t.Fatal("expected error") - } - }) +func TestPublishRequiresDeclarationAndBuildOptIn(t *testing.T) { + store := newTestStateStore(t) + if err := store.CreateScroll(&domain.RuntimeScroll{ID: "ui-scroll", Root: "runtime://ui-scroll", ScrollName: "cached", ScrollYAML: uiScrollYAML()}); err != nil { + t.Fatal(err) + } + supervisor := newRuntimeSupervisorForTest(t, store, coreservices.NewRuntimeScrollManager(store), &fakeWorkerBackend{}) + if _, err := supervisor.PublishUIPackage("ui-scroll", "private", true); err == nil { + t.Fatal("accepted build without a buildcommand") + } + if _, err := supervisor.PublishUIPackage("ui-scroll", "unknown", false); err == nil { + t.Fatal("accepted unknown scope") } } func TestPublishUIPackageRunsOneShotCommandWithEphemeralURL(t *testing.T) { store := newTestStateStore(t) if err := store.CreateScroll(&domain.RuntimeScroll{ - ID: "ui-scroll", Root: "runtime://ui-scroll", ScrollName: "cached", ScrollYAML: cachedScrollYAML(""), + ID: "ui-scroll", Root: "runtime://ui-scroll", ScrollName: "cached", ScrollYAML: uiScrollYAML(), }); err != nil { t.Fatal(err) } @@ -58,14 +76,14 @@ func TestPublishUIPackageRunsOneShotCommandWithEphemeralURL(t *testing.T) { }} supervisor := newRuntimeSupervisorForTest(t, store, coreservices.NewRuntimeScrollManager(store), backend) - updated, err := supervisor.PublishUIPackage("ui-scroll", "private", "") + updated, err := supervisor.PublishUIPackage("ui-scroll", "private", false) if err != nil { t.Fatal(err) } if got := updated.UIPackages[domain.RuntimeUIPackageScopePrivate]; got.Path != "private/dist/app.wasm" || got.URL == "" { t.Fatalf("package = %#v", got) } - if !strings.HasPrefix(command.Name, "ui_publish_private_") || command.Command == nil || len(command.Command.Procedures) != 1 { + if !strings.HasPrefix(command.Name, "ui_publish_") || command.Command == nil || len(command.Command.Procedures) != 1 { t.Fatalf("command = %#v", command) } if got := command.Command.Procedures[0].Image; got != uiPublishImage { @@ -77,11 +95,14 @@ func TestPublishUIPackageRunsOneShotCommandWithEphemeralURL(t *testing.T) { } procedure := domain.ProcedureName(command.Name, 0, command.Command.Procedures[0]) values := command.ProcedureEnv[procedure] - if values["DRUID_UI_SOURCE"] != "private/dist/app.wasm" || values["DRUID_UI_UPLOAD_URL"] == "" || values["DRUID_UI_DOCUMENT_UPLOAD_URL"] == "" { + if values["DRUID_UI_PRIVATE_DOCUMENT_SOURCE"] != "private/templates/view.openui" || updated.UIPackages[domain.RuntimeUIPackageScopePrivate].OpenUIURL == "" { + t.Fatal("explicit document path or URL missing") + } + if values["DRUID_UI_PRIVATE_SOURCE"] != "private/dist/app.wasm" || values["DRUID_UI_PRIVATE_UPLOAD_URL"] == "" || values["DRUID_UI_PRIVATE_DOCUMENT_UPLOAD_URL"] == "" { t.Fatalf("procedure env = %#v", values) } script := command.Command.Procedures[0].Command[len(command.Command.Procedures[0].Command)-1] - if !strings.Contains(script, "$(printenv DRUID_UI_SOURCE)") || !strings.Contains(script, "$(printenv DRUID_UI_UPLOAD_URL)") { + if !strings.Contains(script, "$(printenv DRUID_UI_PRIVATE_SOURCE)") || !strings.Contains(script, "$(printenv DRUID_UI_PRIVATE_UPLOAD_URL)") { t.Fatalf("publish command lost request-scoped environment references: %q", script) } if strings.Contains(script, "sha256") || strings.Contains(script, "content_md5") || strings.Contains(script, "prepare_url") { @@ -93,7 +114,7 @@ func TestPublishUIPackageFailureKeepsPreviousPackage(t *testing.T) { store := newTestStateStore(t) previous := domain.RuntimeUIPackage{URL: "http://packages/previous/app.wasm", Path: "private/dist/app.wasm"} if err := store.CreateScroll(&domain.RuntimeScroll{ - ID: "ui-scroll", Root: "runtime://ui-scroll", ScrollName: "cached", ScrollYAML: cachedScrollYAML(""), + ID: "ui-scroll", Root: "runtime://ui-scroll", ScrollName: "cached", ScrollYAML: uiScrollYAML(), UIPackages: domain.RuntimeUIPackages{domain.RuntimeUIPackageScopePrivate: previous}, }); err != nil { t.Fatal(err) @@ -102,7 +123,7 @@ func TestPublishUIPackageFailureKeepsPreviousPackage(t *testing.T) { return nil, errors.New("document upload failed") }} supervisor := newRuntimeSupervisorForTest(t, store, coreservices.NewRuntimeScrollManager(store), backend) - if _, err := supervisor.PublishUIPackage("ui-scroll", "private", ""); err == nil { + if _, err := supervisor.PublishUIPackage("ui-scroll", "private", false); err == nil { t.Fatal("expected upload failure") } current, err := store.GetScroll("ui-scroll") @@ -159,7 +180,7 @@ func TestUIPublishRequiresBothAssetsAndStopsOnUploadFailure(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "curl"), []byte(script), 0755); err != nil { t.Fatal(err) } - args := uiPublishCommand().Procedures[0].Command[1:] + args := uiPublishCommand([]domain.RuntimeUIPackageScope{domain.RuntimeUIPackageScopePrivate}).Procedures[0].Command[1:] // Stored Scrolls expand environment variables before a worker sees the // command. Request-scoped values must survive that round trip. args[1] = os.ExpandEnv(args[1]) @@ -167,7 +188,7 @@ func TestUIPublishRequiresBothAssetsAndStopsOnUploadFailure(t *testing.T) { command.Dir = dir log := filepath.Join(dir, "uploads") command.Env = append(os.Environ(), "PATH="+dir+":"+os.Getenv("PATH"), "UPLOAD_LOG="+log, - "DRUID_UI_SOURCE=private/app.wasm", "DRUID_UI_DOCUMENT_SOURCE=private/app.openui", "DRUID_UI_UPLOAD_URL=http://uploads/app.wasm", "DRUID_UI_DOCUMENT_UPLOAD_URL=http://uploads/app.openui") + "DRUID_UI_PRIVATE_SOURCE=private/app.wasm", "DRUID_UI_PRIVATE_DOCUMENT_SOURCE=private/app.openui", "DRUID_UI_PRIVATE_UPLOAD_URL=http://uploads/app.wasm", "DRUID_UI_PRIVATE_DOCUMENT_UPLOAD_URL=http://uploads/app.openui") output, err := command.CombinedOutput() if (err != nil) != test.wantErr { t.Fatalf("error = %v, output = %s", err, output) @@ -182,3 +203,89 @@ func TestUIPublishRequiresBothAssetsAndStopsOnUploadFailure(t *testing.T) { }) } } + +func TestDeclaredUIPackagesShareOneCommandAndRetainReferencesOnFailure(t *testing.T) { + for _, scopes := range [][]domain.RuntimeUIPackageScope{{domain.RuntimeUIPackageScopePrivate}, {domain.RuntimeUIPackageScopePublic}, {domain.RuntimeUIPackageScopePrivate, domain.RuntimeUIPackageScopePublic}} { + for _, fail := range []bool{false, true} { + store := newTestStateStore(t) + previous := domain.RuntimeUIPackages{ + domain.RuntimeUIPackageScopePrivate: {URL: "http://old/private.wasm"}, + domain.RuntimeUIPackageScopePublic: {URL: "http://old/public.wasm"}, + } + runtime := &domain.RuntimeScroll{ID: "batch", Root: "runtime://batch", ScrollName: "cached", ScrollYAML: cachedScrollYAML(""), UIPackages: previous} + if err := store.CreateScroll(runtime); err != nil { + t.Fatal(err) + } + paths := map[domain.RuntimeUIPackageScope]*domain.UIPackageDeclaration{} + for _, scope := range scopes { + paths[scope] = &domain.UIPackageDeclaration{WASM: string(scope) + "/dist/app.wasm", OpenUI: string(scope) + "/view.openui"} + } + runs := 0 + backend := &fakeWorkerBackend{runCommand: func(command ports.RuntimeCommand) (*int, error) { + runs++ + if len(command.Command.Procedures) != 1 { + t.Fatal("upload used multiple containers") + } + for _, scope := range scopes { + procedure := domain.ProcedureName(command.Name, 0, command.Command.Procedures[0]) + prefix := "DRUID_UI_" + strings.ToUpper(string(scope)) + "_" + if command.ProcedureEnv[procedure][prefix+"SOURCE"] != paths[scope].WASM { + t.Fatal("scope source missing") + } + } + if fail { + return nil, errors.New("last upload failed") + } + return nil, nil + }} + supervisor := newRuntimeSupervisorForTest(t, store, coreservices.NewRuntimeScrollManager(store), backend) + _, err := supervisor.publishUIPackages("batch", paths, false) + if (err != nil) != fail || runs != 1 { + t.Fatalf("runs=%d err=%v fail=%v", runs, err, fail) + } + current, err := store.GetScroll("batch") + if err != nil { + t.Fatal(err) + } + for scope := range paths { + oldURL := "http://old/" + string(scope) + ".wasm" + if fail && current.UIPackages[scope].URL != oldURL { + t.Fatal("failed upload changed a reference") + } + if !fail && current.UIPackages[scope].URL == oldURL { + t.Fatal("successful upload did not update reference") + } + } + } + } +} + +func TestCombinedUploadChecksPublicPairBeforeAnyUpload(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "private"), 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"app.wasm", "app.openui"} { + if err := os.WriteFile(filepath.Join(dir, "private", name), []byte("fixture"), 0644); err != nil { + t.Fatal(err) + } + } + log := filepath.Join(dir, "uploads") + if err := os.WriteFile(filepath.Join(dir, "curl"), []byte("#!/bin/sh\necho upload >> \"$UPLOAD_LOG\"\n"), 0755); err != nil { + t.Fatal(err) + } + script := uiPublishCommand([]domain.RuntimeUIPackageScope{domain.RuntimeUIPackageScopePrivate, domain.RuntimeUIPackageScopePublic}).Procedures[0].Command[2] + cmd := exec.Command("sh", "-ec", os.ExpandEnv(script)) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "PATH="+dir+":"+os.Getenv("PATH"), "UPLOAD_LOG="+log) + for _, scope := range []string{"PRIVATE", "PUBLIC"} { + prefix := "DRUID_UI_" + scope + "_" + cmd.Env = append(cmd.Env, prefix+"SOURCE="+strings.ToLower(scope)+"/app.wasm", prefix+"DOCUMENT_SOURCE="+strings.ToLower(scope)+"/app.openui", prefix+"UPLOAD_URL=http://upload", prefix+"DOCUMENT_UPLOAD_URL=http://upload/document") + } + if err := cmd.Run(); err == nil { + t.Fatal("missing public files accepted") + } + if _, err := os.Stat(log); !os.IsNotExist(err) { + t.Fatal("uploaded private files before validating public pair") + } +} diff --git a/apps/druid/core/services/runtime_update.go b/apps/druid/core/services/runtime_update.go index e0bf5ddd..f7f43fe1 100644 --- a/apps/druid/core/services/runtime_update.go +++ b/apps/druid/core/services/runtime_update.go @@ -116,7 +116,7 @@ func (s *RuntimeSupervisor) updateExistingScroll(runtimeScroll *domain.RuntimeSc if err := s.store.UpdateScroll(runtimeScroll); err != nil { return nil, err } - if err := s.publishDeclaredPrivateUI(runtimeScroll, scroll); err != nil { + if err := s.publishDeclaredUI(runtimeScroll, scroll); err != nil { return nil, err } if wasRunning { diff --git a/internal/api/generated.go b/internal/api/generated.go index a733dd11..d9d3f24b 100644 --- a/internal/api/generated.go +++ b/internal/api/generated.go @@ -136,8 +136,8 @@ type ProcedureStatusMap map[string]map[string]LockStatus // PublishUIPackageRequest defines model for PublishUIPackageRequest. type PublishUIPackageRequest struct { - // Path Optional scroll-root-relative .wasm path. Defaults to private/dist/app.wasm or public/dist/app.wasm. - Path *string `json:"path,omitempty"` + // WithBuild Run the scope's declared buildcommand as a dependency before uploading its declared WASM/OpenUI pair. + WithBuild *bool `json:"with_build,omitempty"` } // RegistryCredential defines model for RegistryCredential. @@ -219,9 +219,11 @@ type RuntimeScrollStatus string // RuntimeUIPackage defines model for RuntimeUIPackage. type RuntimeUIPackage struct { - Path string `json:"path"` - UpdatedAt time.Time `json:"updated_at"` - Url string `json:"url"` + OpenuiPath string `json:"openui_path"` + OpenuiUrl string `json:"openui_url"` + Path string `json:"path"` + UpdatedAt time.Time `json:"updated_at"` + Url string `json:"url"` } // RuntimeUIPackages defines model for RuntimeUIPackages. @@ -3425,59 +3427,59 @@ func RegisterHandlersWithOptions(router fiber.Router, si ServerInterface, option // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xbW3PbtrP/Khievh1actokM/V5qev04jaZ+NjJ5CFxNRCwklCBAAKAtvX36Lv/BxdS", - "vICypTit3elLGwvAYvHbxd6wvM2ILJQUIKzJjm4zQxZQYP/PY6X46lyWlon5OXwuwVj3s9JSgbYM/CRs", - "DJuLolrOLBT+H99omGVH2f+MN+THkfb4vBSWFeBIw3G9PlvnmV0pyI4yrDVeZet1nmn4XDINNDv62Nrq", - "sp4rp38C8YtPNGALF0RLzof51ZbNMPEjFAzRTFkmRXaUvT05RdUo0jADDYIAkhpxSTBHxhNGCttFlmdw", - "gwvFA7NhjRlRXTI6ms/HFoz1/zly/8lqXo3VTMwdr4z2GXgFSgPBFijCnGGDZlIjgQsYobd+jmPC4ikH", - "pAOCiNFxmHA6Q7Jg1gLNkV0AohgKKdAcBGhswSAsEKOjFuN/yqlJ8eYoJuB5GBb+L4wxozhe+dMhYxnn", - "iMgCDJppWUSkRytc8PtzbBQmCbZ/L6egBbj961ke2Ip/DUaWmoAZodO5kBoomq6QkOKgsXSKyRIENaPU", - "7vJagJ6kJBoVHfkZiFFUGqB+d1IaKwvQBzNMmJgj7e4CwqVdSM3+g9365F4a5sxYvZoQDRSEZZjvcO/i", - "4pN67d13rrouqQv3CjhYoOHG9a9aQKR3BGOxLf2EjWBpoNQ/cYcd5qZEAimOfhKm1LuYgB531eCEsnlc", - "PXB5B+/Nv+r5ONTzV8DcLs7BKCkM9PWgkDQhkZNSaxAWLfxqFJQN+blNUySXqfMrLecajOmTPYsjSIEm", - "ICyeBzlzialD2DHmcXUGbiZ1gW12lM24xM5/FPiGFWWRHT07PMyzgonw12HNgiiLKeh4vbSdUGwTZ/uw", - "ANG0zX6uv3b1jm7hgdOKLM9Eybmz9dmR1SXcdTc9RCk5vJZkeVFf+rYM4IbZCYmCGNiPCQvzcDiOjZ0E", - "kUzIAou5X1czz4R9+TxLLWwYHeGQ+5jpUgh3jDyjUnjZai11lmfXmLmIp3GUgQNHmkmuUjicSZ2wRi0J", - "7WJVVCRX68bLFy++e9HQjmcpIJSWVhLJm1AsrFVZ7v/n3Stxf5VU3Q2BZy6y0qCdPL2WBKizzh6oN1h5", - "W0wpC3HFWdtGD/y+zX409GydYKDPUTnlzCzen55hssRzGHQYPuTbEhB5d3OgpbQHGji27ArQ6BqbwgeL", - "I/QKZrjk1iArkdLsClsYU2bsGCsV5kmNlOOGtH8fJR1i7yAJw9k7w0IOODOFjbmWOu3SSgN6QAE7muDp", - "NxY0CKe0Ibqe42i/31bWbz+nPeR2aAA+O5phbiB/ODfktvTGs8HOVEoOWOzmoyIOzjQMmcipLAVN7ZN7", - "0CdMJTHxY5WN6NuBJYA65uwK3mk8mzGSpOENGyaWXTG7mmDbMrZNT7G71UpapmAg0usadqsv/5vJdGUD", - "XPdxBj6iSlKyPTQacMfBnfaq1sjldprXTFB5neZpl9MNGOga276xzqOGbQ5fI7RFY7vJe8KzW2cK+Db9", - "3N3hTYZHtylIMK5brkOpedrGbTs/E/N3WM8hcfr75QK73I67Dr/v3THAgVipt3ndvkp2UTGgrxiByf2c", - "xYBWTgbCiQ75LVo5lIpu9R7E143oTvZtIAX0BjMEkqnhZio2LMM7PVQilAoeCfQVUK/l98+6fFTql2P6", - "VvBVJ/jeODwpB5xvuAkPXv3LsxBYDWt9P6iPoszyRnhvrFTK/1ZF+FW14TIh2JJNVAgH73uQOn70YWep", - "6I7KlCpx1PoacW9jkW9Sj4butvbeckdqfocD3T4qO59qi0ltntZNyrNYU92R/70ThR4QKYv23jOzdzX5", - "pxvFGWGWrxAmBJQFilhRlKF4ar3bGKE3zBif/Wu0GZpvqs8GYQ1Ig2MKKJrCTGpAXqPdMpfKx0LRKMDo", - "fG52lP3x8Y8fPn0yl//7g1ngb1+8PPqID2aHB99f3r58vv4mJS24UX6PCRPGYs6BNipgA4mP0kCkCPgj", - "sgCyBIrwHDsSnjdMbIk5qkmiipDUqBQUtJ9VYOfnBBYEEJdk2TnKfU/wiGpP3iGSUjO7unAbxEAesAZ9", - "XIY7Fv76ubpKv3145y1TE+jfPrxDVi5BhLI485zZFVJaXjEK2psFR96FlJ7cBhef07sjuPXVnm3yFwup", - "7YHLASj6XIJeVZtJjT7A9EKSJVhEpBBAqsoUcwv95KyK1MIWm52xYr+Dg8u5STGTbmMihQ3XZN095Ctd", - "MopOXp8ijktBFv6hgKICC3fFkV/JBOgDX+Sk1TMMVu5+hYpZjjhbwicx968JzglqkyOKLZ5iAyb3BK9h", - "Wo2NPnl2mfWVvJqBLM/caGDrcPRsdOh9tgKBFcuOsu/8T8FaeYGOsWLjq2fjUCp0v8RYsH3CX8BWBbdW", - "UTHzxEPee0rDxFCz9PLyHt2XLv1m3x4eVkjGeLsBwfhPE8pHQZ/v0vZOZdSLqs1zmOG98IvD7/7CjS9C", - "pIdKga8wC+VAf5/KosB6FeHs4mjx3PgqRJBEngW8s0u3tBJT0BzTkFMb/tfM2Is45wvB3yUQijFr39z0", - "sKnq9dVB2rg49utnA1Ofo4ImjjSxcZG2SQDRfEjNgtEDY3+UdPVgipB6q123LawLQ9c9OTx7MBY68N8F", - "N6piyzbq4SAd3LfD3lfJMfh3Kx9fJCXSfNf6ShJJPZ3dSyKHf5tEAmpdiYSDdCSC4IYZG1yLjPELX4UH", - "ELOzuG4ZXQc77xKJvrjCw2gtLoU1LsCCdlvcBh8aQ97oQn3Q3wY6b4DWjaEvv6IQ2o+6dwuhSqbWefb8", - "8PnwI2OcLqRFM19vakstbLvTPcrTZvwXsE8T+R3V/0sRd370C82WuwdjF5eVath2/ejHv7JIHt4e3vVC", - "8dhsY4AZuaXxQrat4g2QsnHBotT2kjiRRYEFNePb+K/1sPTPSxF4PglTv4YG5EkipN5wJ0qdF3PMrE+I", - "fKodRA8URdrIyhrwKjkPGkCZmI8G8iWzEiRrctF9puo9KP2tVicUQmjXVnyh9TkvRddFbwS2l06KGZsP", - "xva1UzgJ8x6ha+jWEHqCOMPabBLgeOC+TVepaftiaiQHcy9Uw8xHiGu6Qtgqcg9jXh0MLWEVWq82jxZ9", - "6GMngCFSeSNRg7IH+HUVfzvyZzIEsI8O9l1S4Mbz985pMHJAVZWABw+JWtT3kuPnEkq4W47/76c9sYg1", - "9RDVl5c/mscQtuD9uTFrA/TnCEsF+DDOGjhgsxXp06oOfR7nPkK4O81hw02oGpQ0zEq9uvuVpTE3r2he", - "3sP61YAhgoUUjGCONrR8Ms2xsciUhIAxs9INe2hR3OVLowTA1IdemyeEeoPNU4IGIjUN5tlN7vKkylZu", - "U9XGtumSsXJbPeY8TPg3qfnaGW/A+d5ZTSW4vSx14yE7LXX/QUyskMW5T0f0qa95Hpu4h1KNlszPQBtm", - "bOyYlvogfBcENLZQIl3Lpq8EvrXkThUYh/fZe4RfrTagJx+HtZua7hOKhQWowisREodX7fiFQiWbesEe", - "Mqr7LtOX9MIN/0MLgBehX3/7/fCTHqSyZ6xU24CW6h+Ls28ZugtnqbrZwrXUSy4xNeh6wTggFZqynMZT", - "bPF+YijZuNmStN0gNbpjnqZUmu1UiRpI6NkHit6fogoVF/f5nDtVDUksQO/PX5svlsX41u+5Hscthm9K", - "ZLojoL+uEhqw2Uan6p+LXydkVcds6guQrxSfDH2NsY5ByiN5c7xmdoFio1pTpQqw2F/xTrASToWwQJhr", - "wHR1MC0Ztyh0nbw/RR+OL95UVPbUSVV97ZVWv2Yn2xMKWFMNeE8lYHVZ72E/6z0WCBJ9gRe/Hn/74mXq", - "43NmUH3a3VNpt+D7/oLTXi4dPlqjyDC3qUuhg06ha2yqxr6OYoeTd/3fjPHYv9Ut5KT0OfaNV5o41Bd2", - "fHaaxXbSbJw5dYtEew1tgYnQOlaAsJ6T6q0GfK7oZjYMYxRX7yPQuCYUpTYEN0tDYSrxYJRqnEPhrWgJ", - "vocuUriGqfEzE1TOpLaIidBoy6SoIS0bBFTo3L5Ntm4FsZnkwtgl1V/6puSWHURZVqJNnb6SZp/Eq9Do", - "xtkMyIrw9PKoAv3VP7ug6RpbsnAhk+OdwhVwqbw040e4FX5uWoLGsRDSBtScOvprZhqnx/W4ydaX6/8G", - "AAD//y1wpUoBQwAA", + "H4sIAAAAAAAC/+xbW28bt7P/KsSeAufhrC2nTQLU56Wu04vbBPGxE+QhcQWKHEmsuCRDcm3rGPruf/Cy", + "q71wZUlxWrvoS2KbnOHwN8O5kXuXEVkoKUBYkx3fZYbMocD+xxOl+PJClpaJ2QV8LsFY92elpQJtGfhJ", + "2Bg2E0VFziwU/odvNEyz4+y/Rmv2o8h7dFEKywpwrOGkps9WeWaXCrLjDGuNl9lqlWcaPpdMA82OP7aW", + "uqrnysmfQDzxqQZs4ZJoyfmwvNqyKSZ+hIIhminLpMiOs7enZ6gaRRqmoEEQQFIjLgnmyHjGSGE7z/IM", + "bnGheBA20JhDqktGD2ezkQVj/T/H7p+sltVYzcTMycpoX4BXoDQQbIEizBk2aCo1EriAQ/TWz3FCWDzh", + "gHRAEDE6ChPOpkgWzFqgObJzQBRDIQWagQCNLRiEBWL0sCX4n3JiUrI5jgl4HkaE/w1jzCiOl353yFjG", + "OSKyAIOmWhYR6cMlLvj2EhuFSULs38sJaAFu/XqWB7aSX4ORpSZgDtHZTEgNFE2WSEhx0CCdYLIAQc1h", + "anV5I0CPUxqNho78DMQoKg1QvzopjZUF6IMpJkzMkHZnAeHSzqVm/48dfXItDTNmrF6OiQYKwjLMdzh3", + "kfi0pr3/zFXHJXXgXgEHCzScuP5RC4j0tmAstqWfsFYsDZz6O+6Iw9yUyCAl0U/ClHoXF9CTrhocUzaL", + "1AOHd/Dc/Guej8M8fwXM7fwCjJLCQN8OCkkTGjkttQZh0dxTo2BsyM9tuiK5SO1faTnTYEyf7XkcQQo0", + "AWHxLOiZS0wdwk4wj6tzcFOpC2yz42zKJXbxo8C3rCiL7PjZ0VGeFUyE345qEURZTEDH46XtmGKb2NuH", + "OYimb/Zz/bGrV3SEB84qsjwTJefO12fHVpdw39n0EKX08FqSxWV96Ns6gFtmxyQqYmA9JizMwuY4NnYc", + "VDImcyxmnq4Wngn78nmWImw4HeGQ+5jpUgi3jTyjUnjdai11lmc3mLmMp7GVgQ1HnkmpUjicS53wRi0N", + "7eJVVGRX28bLFy++e9GwjmcpIJSWVhLJm1DMrVVZ7v/z4ZW430qq7ofACxdFafBO7l5LAtR5Zw/UG6y8", + "L6aUhbzivO2jB/6+yX807GyVEKAvUTnhzMzfn51jssAzGAwYN8zOx5OS8ehDp7jkNjueYm4g7/tUf8IM", + "kQr+2yAKhGPvtR09kUWBBUXYIIwoKBAUBFmiCUylBlSqyh0w2yD9cHL5ZvRWgXh/hhRmuuF6J1JywMIr", + "prfBhEPt7W0uB4KcwsbcSJ0OdaUBPWCYHQvx/BsEDcYpK4kh6ST69beVV9wvmA+Fo64KHyw8uSW9U22I", + "01TR1rEr4uBcxpDrnMhS0NQ6uQd9zFQSEz9W+Y6+f1gAqBPOruGdxtMpI0ke3uFhYtk1s8sxti0n3Iwg", + "u3uzpMcKjiNN1/Bnff3fjidLG+DaJkj4TCvJyfbQaMAdB3daq6KRi808b5ig8iYt0y67G3DcNbZ9J55H", + "C1tvvkZog8V2i/pExLfOFfBN9rl7IBwPj24yEOVCANlwHErN0z5u0/6ZmL3DegaJ3W9XI+xyOu7b/L5n", + "xwAHYqXeFI37JtlFxYC+ZgTG2wWLAascD6QZHfYbrHKoRN0YPYjvJ9Gd/NtAaegdZkgwU8PNEm1Yh/dG", + "qESKFSIS6Gug3sq3r8Z8turJMX0r+LKTlK8DnpQDwTechAfvCuZZ6A8NW30/2Y+qzPJG2m+sVMr/rcr8", + "qy7EVUKxJRurkCZuu5E6r/TpaKnojsaUan3U9hpxb2ORr0uShu221t5wRmp5+8dEKhAeADtP228YTztL", + "l/EN0O0OygaP3ATLTYrrtlZpiZq39rUNMnuXJj2IU77yvZdz7/71T7eKM8IsXyJMCCgLFLGiKEO71vqA", + "dIjeMGN8v0Gj9dBs3e82CGtAGpxQrmoJhYk/K47MlTaxNXUYEHbRPDvO/vj4xw+fPpmr//nBzPG3L14e", + "f8QH06OD76/uXj5ffZNSJNwqv8aYCWMx50AbPbeB3rPSQKQI+CMyB7IAivAMOxZeNkxsiTmqWaKKkdSo", + "FBS0n1VgF0EFFgQQl2TR2cq2O3hE3S4fakmpmV1eugViiQBYgz4pw/ELv/1cnbLfPrzLutXrbx/eISsX", + "IEIjnnnJ7BIpLa8ZBe0djmPvklXPbo2L7yK4LTj6as02+8u51PbAVRcUfS5BL6vFpEYfYHIpyQIsIlII", + "IFUvjDlCPzmrcsCwxHplrNjv4OByAVhMpVuYSGHDMVl1N/lKl4yi09dniONSkLm/mqCowMIdceQpmQB9", + "4NuqtLr4wcqdr9CjyxFnC/gkZv7+woVXbXJEscUTbMDknuENTKqxw09eXGZ977AWIMszNxrEOjp8dnhU", + "eVOsWHacfef/FByZV+gIKza6fjYKzUn3l5hltnf4C9iqxddqYwaXFyrqMxomhi6p15fPFXyz1C/27dFR", + "hWTM5BsQjP40oWEV7Pk+a+/0Yr2q2jKHGT6+vzj67i9c+DLkkKgU+Bqz0ID056ksCqyXEc4ujhbPjO9v", + "BE3kWcA7u3KklZqC5ZiGntrwv2bGXsY5Xwj+LilWzIb77maVD9wQVBtp4+LEry8qTL2PCpo40sTG5fAm", + "AUTz6jYLTg+M/VHS5YMZQup2eNX2sC7BXfX08OzBROjAfx/cqMpa26iHjXRw3wx73yRH4G/KfH6R1Ejz", + "Ju0raSR1WbeVRo7+No0E1LoaCRvpaATBLTM2hBYZ8xe+DFcuZmd13TG6Cn7elSh9dYWr2FpdCmtcgAXt", + "lrgLMTRmwzGE+nKiDXTeAK2bXl99RSW0r5HvV0JVpq3y7PnR8+FrzThdSIumvpPV1lpYdqdzlKfd+C9g", + "nybyO5r/lyLu4ugXui13DkYuLyvVsO/60Y9/ZZU8vD+87+7jsfnGADNypPFAtr3iLZCyccCi1vbSeLw3", + "M6O7+NNqWPsXpQgyn4apX8MC8iQTUi+4E6fOHT1m1hdEvtQOqgeKqptDK2vAq+I8WABlYnY4UC+ZpSBZ", + "U4ruBVjvqupv9TqhEUK7vuILvc9FKboheq2wvWxSTNlsMLevg8JpmPcIQ0O3h9BTxDnWZl0Axw33fbpK", + "TdsXUyM5mK1QDTMfIa7pDmGrfT6MebUxtIBleOy1vg7pQx/QO/CvDZyTqEHZA/z6fmAz8ucyJLCPDvZd", + "SuDGxfrOZTByQFWdgAdPiVrc99Lj5xJKuF+P/+enPbGMNXXF1deX35rHEDbg/bkxaw305whLBfgwzho4", + "YLMR6bOqD30R5z5CuDvP0YafvWpQ0jAr9fL+C5jG3LziebWF96sBQwQLKRjBHK15+WKaY2ORKQkBY6al", + "G/bQorjKl2YJgKlPvdZXCPUC66sEDURqGtyzm9yVSZWt2qbqjW2yJWPlpn7MRZjwb1HztSvegPPWVU2l", + "uL08deOKPK11/wlO7JDFuU9H9anvhx6buodKjZbOz0EbZmx8oy31QfgSCSgK74eQrnXTNwL/aOVeExiF", + "+9kt0q/WA6Mnn4e1n0ttk4oFAlThlUiJw612/Cai0k1NsIeO6hed6UN66Yb/oQ3Ay/CFwObz4Sc9SGfP", + "WKk2AS3VPxZn/xjpPpyl6lYLN1IvuMTUoJs544BUeO7lLJ5ii/dTQ8lGzcdOmx1S43XM09RK86FWogcS", + "vhIAivzr+zDR5X2+5k51QxIE6P3Fa/PFuhjd+TVXo7jE8EmJQncU9Nd1QgM2m/hUL/OUZtehAAuxNPXN", + "yVfKT4a+/1jFJOWR3DneMDtH8Q1b06QKsNgf8U6yEnaFsECYa8B0eTApGbcovDp5f+Y/J6m47GmTqvq+", + "LG1+zZdsTyhhTT3AeyoJq6t6j/pV74lAkHgXePnrybcvXqY+d2cG1bvdvZR2BN/3Cc56tXT4TI4iw9yi", + "roQONoVusKke9nUMO+y8G/+mjMf3W91GTsqe44v0yhKH3oWdnJ9l8aVpNsqcuUWmvQdtQYjwdKwAYb0k", + "1V0N+FrRzWw4xqiu3menkSY0pdYM16ShMZW4MEo9nEPhrmgB/g1d5HADE+NnJricS20RE+ENLpOihrRs", + "MFDhTfhd8ulWUJtJEsZXUn3SNyW37CDqslJtaveVNvssXoWHbpxNgSwJT5NHE+hT/+ySphtsydylTE52", + "CtfApfLajJ/9Vvi5aQkeJ0JIG1Bz5uiPmWnsHtfjJltdrf4TAAD//1DXuFpzQwAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/core/domain/runtime_scroll.go b/internal/core/domain/runtime_scroll.go index 24331abb..d133b8d3 100644 --- a/internal/core/domain/runtime_scroll.go +++ b/internal/core/domain/runtime_scroll.go @@ -83,7 +83,9 @@ const ( type RuntimeUIPackages map[RuntimeUIPackageScope]RuntimeUIPackage type RuntimeUIPackage struct { - URL string `json:"url"` - Path string `json:"path"` - UpdatedAt time.Time `json:"updated_at"` + URL string `json:"url"` + Path string `json:"path"` + OpenUIURL string `json:"openui_url"` + OpenUIPath string `json:"openui_path"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/core/domain/scroll.go b/internal/core/domain/scroll.go index 24b63647..1be3b639 100644 --- a/internal/core/domain/scroll.go +++ b/internal/core/domain/scroll.go @@ -64,10 +64,13 @@ type File struct { type UIDeclaration struct { Private *UIPackageDeclaration `yaml:"private,omitempty" json:"private,omitempty"` + Public *UIPackageDeclaration `yaml:"public,omitempty" json:"public,omitempty"` } type UIPackageDeclaration struct { - Path string `yaml:"path" json:"path"` + BuildCommand string `yaml:"buildcommand,omitempty" json:"buildcommand,omitempty"` + WASM string `yaml:"wasm" json:"wasm"` + OpenUI string `yaml:"openui" json:"openui"` } type Scroll struct { @@ -280,19 +283,28 @@ func (sc *Scroll) Validate(strict bool) error { } } if sc.UI != nil { - if sc.UI.Private == nil { - return fmt.Errorf("ui.private declaration is required") + if sc.UI.Private == nil && sc.UI.Public == nil { + return fmt.Errorf("ui must declare a private or public package") } - uiPath := sc.UI.Private.Path - cleaned := path.Clean(uiPath) - if uiPath == "" || path.IsAbs(uiPath) || cleaned != uiPath || cleaned == ".." || strings.HasPrefix(cleaned, "../") { - return fmt.Errorf("private ui package path must be a normalized relative path") - } - if !strings.HasPrefix(cleaned, "private/") { - return fmt.Errorf("private ui package path must be rooted at private/") - } - if path.Ext(cleaned) != ".wasm" { - return fmt.Errorf("private ui package path must point to a .wasm file") + for scope, declaration := range map[string]*UIPackageDeclaration{"private": sc.UI.Private, "public": sc.UI.Public} { + if declaration == nil { + continue + } + if declaration.BuildCommand != "" && sc.Commands[declaration.BuildCommand] == nil { + return fmt.Errorf("%s ui buildcommand %q is not defined", scope, declaration.BuildCommand) + } + for extension, uiPath := range map[string]string{".wasm": declaration.WASM, ".openui": declaration.OpenUI} { + cleaned := path.Clean(uiPath) + if uiPath == "" || path.IsAbs(uiPath) || cleaned != uiPath || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return fmt.Errorf("%s ui package path must be a normalized relative path", scope) + } + if !strings.HasPrefix(cleaned, scope+"/") { + return fmt.Errorf("%s ui package path must be rooted at %s/", scope, scope) + } + if path.Ext(cleaned) != extension { + return fmt.Errorf("%s ui package path must point to a %s file", scope, extension) + } + } } } diff --git a/internal/core/domain/scroll_test.go b/internal/core/domain/scroll_test.go index 0a583f14..3c48370c 100644 --- a/internal/core/domain/scroll_test.go +++ b/internal/core/domain/scroll_test.go @@ -25,20 +25,40 @@ func TestProcedureDefaultsToContainer(t *testing.T) { func TestPrivateUIPackagePathValidation(t *testing.T) { valid := testScroll(t, &Procedure{Image: "alpine:3.20"}) - valid.UI = &UIDeclaration{Private: &UIPackageDeclaration{Path: "private/dist/app.wasm"}} + valid.UI = &UIDeclaration{Private: &UIPackageDeclaration{WASM: "private/dist/app.wasm", OpenUI: "private/view.openui"}} if err := valid.Validate(false); err != nil { t.Fatalf("Validate() valid UI error = %v", err) } for _, invalidPath := range []string{"", "../private/app.wasm", "public/app.wasm", "private/app.js"} { scroll := testScroll(t, &Procedure{Image: "alpine:3.20"}) - scroll.UI = &UIDeclaration{Private: &UIPackageDeclaration{Path: invalidPath}} + scroll.UI = &UIDeclaration{Private: &UIPackageDeclaration{WASM: invalidPath, OpenUI: "private/view.openui"}} if err := scroll.Validate(false); err == nil { t.Fatalf("Validate() path %q error = nil", invalidPath) } } } +func TestUIRequiresDocumentAndExistingBuildCommand(t *testing.T) { + scroll := testScroll(t, &Procedure{Image: "alpine:3.20"}) + declaration := &UIPackageDeclaration{WASM: "private/artifact.wasm", OpenUI: "private/templates/view.openui"} + scroll.UI = &UIDeclaration{Private: declaration} + if err := scroll.Validate(false); err != nil { + t.Fatal(err) + } + declaration.BuildCommand = "missing" + if err := scroll.Validate(false); err == nil { + t.Fatal("accepted missing buildcommand") + } + declaration.BuildCommand = "" + for _, invalid := range []string{"", "public/view.openui", "../view.openui", "private/../view.openui", "private/view.json"} { + declaration.OpenUI = invalid + if err := scroll.Validate(false); err == nil { + t.Fatalf("accepted document path %q", invalid) + } + } +} + func TestSignalProcedureValidation(t *testing.T) { scroll := testScroll(t, &Procedure{ Type: ProcedureTypeSignal, @@ -51,6 +71,25 @@ func TestSignalProcedureValidation(t *testing.T) { } } +func TestPublicUIAndCombinedDeclarations(t *testing.T) { + for _, private := range []bool{false, true} { + scroll := testScroll(t, &Procedure{Image: "alpine:3.20"}) + scroll.UI = &UIDeclaration{Public: &UIPackageDeclaration{WASM: "public/custom/app.wasm", OpenUI: "public/view.openui"}} + if private { + scroll.UI.Private = &UIPackageDeclaration{WASM: "private/dist/app.wasm", OpenUI: "private/view.openui"} + } + if err := scroll.Validate(false); err != nil { + t.Fatal(err) + } + for _, invalid := range []string{"", "private/app.wasm", "../public/app.wasm", "public/app.js"} { + scroll.UI.Public.WASM = invalid + if err := scroll.Validate(false); err == nil { + t.Fatalf("accepted public path %q", invalid) + } + } + } +} + func TestUnsupportedProcedureFieldsRejected(t *testing.T) { tests := []struct { name string diff --git a/internal/core/services/watch_service.go b/internal/core/services/watch_service.go index c5697e6c..820c18ce 100644 --- a/internal/core/services/watch_service.go +++ b/internal/core/services/watch_service.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" @@ -41,6 +42,7 @@ type WatchService struct { ctx context.Context cancel context.CancelFunc isWatching bool + ignoredBuildPaths []string hotReloadCommands []string queueManager ports.QueueManagerInterface scrollService ports.ScrollServiceInterface @@ -50,13 +52,14 @@ type WatchService struct { } func NewDevService( - queueManager ports.QueueManagerInterface, scrollService ports.ScrollServiceInterface, + queueManager ports.QueueManagerInterface, scrollService ports.ScrollServiceInterface, ignoredBuildPaths ...string, ) ports.WatchServiceInterface { return &WatchService{ - watchPaths: make([]string, 0), - isWatching: false, - queueManager: queueManager, - scrollService: scrollService, + watchPaths: make([]string, 0), + ignoredBuildPaths: ignoredBuildPaths, + isWatching: false, + queueManager: queueManager, + scrollService: scrollService, } } @@ -241,7 +244,7 @@ func (uds *WatchService) addWatchPath(path string) error { if info.IsDir() { // Skip node_modules directories - if info.Name() == "node_modules" { + if info.Name() == "node_modules" || info.Name() == "target" || info.Name() == ".git" { return filepath.SkipDir } return uds.watcher.Add(walkPath) @@ -343,6 +346,16 @@ func (uds *WatchService) handleFileEvent(event fsnotify.Event) { } } + for _, output := range uds.ignoredBuildPaths { + if filepath.Clean(relativePath) == filepath.Clean(output) { + return + } + } + for _, part := range strings.Split(filepath.ToSlash(relativePath), "/") { + if (part == "dist" && len(uds.ignoredBuildPaths) > 0) || part == "target" || part == "node_modules" || part == ".git" { + return + } + } uds.scheduleHotReload() } diff --git a/scripts/build-ui.sh b/scripts/build-ui.sh new file mode 100644 index 00000000..084ccd8e --- /dev/null +++ b/scripts/build-ui.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Run a declared Druid build command and wait; never upload artifacts. +set -eu +exec python3 - "$@" <<'PY' +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +if len(sys.argv) != 2: + sys.exit("Usage: druid-build-ui.sh ") +command = sys.argv[1] +base = os.environ["DRUID_API_URL"].rstrip("/") + "/api/v1" + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + +opener = urllib.request.build_opener(NoRedirect) + +def request(path, payload=None): + headers = {"Content-Type": "application/json"} + token_file = os.environ.get("DRUID_TOKEN_FILE") + if token_file: + with open(token_file) as credential: + headers["Authorization"] = "Bearer " + credential.read().strip() + data = None if payload is None else json.dumps(payload).encode() + try: + with opener.open(urllib.request.Request(base + path, data=data, headers=headers), timeout=40) as response: + return json.load(response) + except urllib.error.HTTPError as error: + sys.exit("Druid build request failed (HTTP %s)" % error.code) + except urllib.error.URLError: + sys.exit("Druid build request could not connect") + +request("/command", {"command": command}) +while True: + result = request("/commands/" + urllib.parse.quote(command, safe="") + "/wait?timeout_seconds=30") + if result["status"] == "done": + break + if result["status"] == "error": + sys.exit("Druid build command failed; inspect its logs") +PY