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/Makefile b/Makefile index 3673c385..2acf1a51 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,8 @@ 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_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 diff --git a/README.md b/README.md index eea87aad..ec6cb48a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,55 @@ 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 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. + +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 +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/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/cli/daemon.go b/apps/druid/adapters/cli/daemon.go index 7209c1af..d83580a2 100644 --- a/apps/druid/adapters/cli/daemon.go +++ b/apps/druid/adapters/cli/daemon.go @@ -42,6 +42,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 +78,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") @@ -143,7 +145,7 @@ 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 } 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/auth.go b/apps/druid/adapters/http/handlers/auth.go index 616243c6..529c807b 100644 --- a/apps/druid/adapters/http/handlers/auth.go +++ b/apps/druid/adapters/http/handlers/auth.go @@ -3,24 +3,40 @@ 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" ) 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 { + 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") @@ -51,14 +67,18 @@ func (h *ScrollHandler) authorizeRuntimeOwner(id string, subject string) error { } func (h *WebsocketHandler) PublicQueryAuth(c *websocket.Conn) bool { + token := c.Query("token") + if token == "" { + token = toolrequest.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_handler.go b/apps/druid/adapters/http/handlers/command_handler.go new file mode 100644 index 00000000..604d8419 --- /dev/null +++ b/apps/druid/adapters/http/handlers/command_handler.go @@ -0,0 +1,62 @@ +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" +) + +func (h *ScrollHandler) callCommandToolHTTP(c *fiber.Ctx, name string) error { + var input services.ToolInput + if len(c.Body()) > 0 { + var err error + input, err = toolrequest.Decode(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(), toolrequest.Bearer(c.Get("Authorization")), c.Params("id"), name, input) + if err != nil { + return fiber.NewError(toolrequest.Status(err), toolrequest.Message(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_tools_test.go b/apps/druid/adapters/http/handlers/command_tools_test.go new file mode 100644 index 00000000..c3d0fb6c --- /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" + "strings" + "sync" + "testing" + + "github.com/gofiber/fiber/v2" + 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" +) + +type toolRouteBackend struct { + ports.RuntimeBackendInterface + credentials sync.Map + logs string + uploadError error +} + +func (b *toolRouteBackend) PrepareCommandIdentity(context.Context, string) error { + return nil +} +func (b *toolRouteBackend) AuthenticateCommandIdentity(_ context.Context, token string) (workloadidentity.Identity, error) { + if record, ok := b.credentials.Load(token); ok { + return record.(workloadidentity.Identity), nil + } + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized +} +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) 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 +} +func (b *toolRouteBackend) WriteConsoleInput(context.Context, string, string, []byte) (int, error) { + return 0, 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 TestCommandToolsRealMCPClient(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + token := "fixture-token" + 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\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) + } + 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 }), "") + 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()}) + 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 + for _, registered := range app.GetRoutes() { + if registered.Method == route.method && registered.Path == route.path { + count++ + } + } + if count != 1 { + t.Fatalf("shared REST route %s is registered %d times", route.path, count) + } + } + 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{"/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) + 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) + } + defer session.Close() + listed, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatal(err) + } + 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 + for _, test := range []struct { + tool string + arguments map[string]any + failure bool + }{ + {"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} + } + 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) + } + 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 + } + }) + 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) + } + encodedPublication, _ := json.Marshal(publication) + if !strings.Contains(string(encodedPublication), "published") || strings.Contains(string(encodedPublication), "upload-private") { + t.Fatal("unsafe publication projection") + } + 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") + } + var arguments map[string]any + 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}) + 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) + } + 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"}}) + 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") + } + 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") + } + 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") + } + 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{} + 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", `{"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}, + } { + 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) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != route.status { + t.Fatalf("owner REST %s: %d", route.path, response.StatusCode) + } + } +} 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 new file mode 100644 index 00000000..00be0023 --- /dev/null +++ b/apps/druid/adapters/http/handlers/docker_command_tools_test.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "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\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) + } + } + 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 _, path := range []string{"/one/api/v1/scroll"} { + 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) + } + } + 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 != 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() + 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(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/routes.go b/apps/druid/adapters/http/handlers/routes.go index adb9fc97..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" ) @@ -29,10 +30,16 @@ func RegisterRoutes(app *fiber.App, handlers RouteHandlers) { } func RegisterManagementRoutes(app *fiber.App, handlers RouteHandlers) { + app.Use(func(ctx *fiber.Ctx) error { + 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() }) @@ -58,6 +65,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 { + h := handlers.Server.ScrollHandler + 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) + 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) @@ -66,11 +86,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/adapters/http/handlers/routes_test.go b/apps/druid/adapters/http/handlers/routes_test.go index 11fca1de..f5cea0d1 100644 --- a/apps/druid/adapters/http/handlers/routes_test.go +++ b/apps/druid/adapters/http/handlers/routes_test.go @@ -92,10 +92,42 @@ 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 - })) + }), "") if 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/commands.go b/apps/druid/adapters/mcp/commands.go new file mode 100644 index 00000000..9bb1acf6 --- /dev/null +++ b/apps/druid/adapters/mcp/commands.go @@ -0,0 +1,40 @@ +package commandmcp + +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/mcp/console.go b/apps/druid/adapters/mcp/console.go new file mode 100644 index 00000000..0f4782af --- /dev/null +++ b/apps/druid/adapters/mcp/console.go @@ -0,0 +1,7 @@ +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")) + 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/mcp/handler.go b/apps/druid/adapters/mcp/handler.go new file mode 100644 index 00000000..a36113a3 --- /dev/null +++ b/apps/druid/adapters/mcp/handler.go @@ -0,0 +1,66 @@ +package commandmcp + +import ( + "context" + "encoding/json" + "net/http" + + "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" +) + +type commandMCP struct { + server *mcp.Server + supervisor *services.RuntimeSupervisor + identity workloadidentity.Identity + deployment, token string +} + +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)) + } + 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. +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 := 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: toolrequest.Message(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/mcp/schema.go b/apps/druid/adapters/mcp/schema.go new file mode 100644 index 00000000..8a0e4904 --- /dev/null +++ b/apps/druid/adapters/mcp/schema.go @@ -0,0 +1,45 @@ +package commandmcp + +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/mcp/schema_test.go b/apps/druid/adapters/mcp/schema_test.go new file mode 100644 index 00000000..fa57c53a --- /dev/null +++ b/apps/druid/adapters/mcp/schema_test.go @@ -0,0 +1,98 @@ +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 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) + 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/mcp/status.go b/apps/druid/adapters/mcp/status.go new file mode 100644 index 00000000..4ca369bd --- /dev/null +++ b/apps/druid/adapters/mcp/status.go @@ -0,0 +1,5 @@ +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/mcp/ui.go b/apps/druid/adapters/mcp/ui.go new file mode 100644 index 00000000..ab04e93b --- /dev/null +++ b/apps/druid/adapters/mcp/ui.go @@ -0,0 +1,16 @@ +package commandmcp + +import ( + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func publishUITool() *mcp.Tool { + 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") +} + +func (m *commandMCP) addUI() { + m.add(publishUITool()) +} diff --git a/apps/druid/adapters/toolrequest/request.go b/apps/druid/adapters/toolrequest/request.go new file mode 100644 index 00000000..8e945637 --- /dev/null +++ b/apps/druid/adapters/toolrequest/request.go @@ -0,0 +1,48 @@ +package toolrequest + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + + services "github.com/highcard-dev/daemon/apps/druid/core/services" + "github.com/highcard-dev/daemon/internal/workloadidentity" +) + +func Status(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 Message(err error) string { + if Status(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 Decode(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/core/services/command_tools.go b/apps/druid/core/services/command_tools.go new file mode 100644 index 00000000..f0a470bf --- /dev/null +++ b/apps/druid/core/services/command_tools.go @@ -0,0 +1,303 @@ +package services + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + "unicode/utf8" + + "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"` + WithBuild bool `json:"with_build,omitempty"` + Instructions *domain.CommandInstructionSet `json:"instructions,omitempty"` + Command string `json:"command,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", + "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", +} + +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 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 := domain.RuntimeUIPackageScope(input.Scope) + updated, err := s.PublishUIPackage(deployment, input.Scope, input.WithBuild) + 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 "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 { + 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, 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{}) + _, 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, "accepted": true}, nil +} + +func (s *RuntimeSupervisor) waitToolCommand(ctx context.Context, deployment string, input ToolInput) (any, error) { + if input.Command == "" { + return nil, toolError(400, "command is 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 { + queue, err := s.Queue(deployment) + if err != nil { + return nil, err + } + statuses := queue[input.Command] + if len(statuses) == 0 { + return nil, toolError(410, "command stopped or unavailable") + } + state := domain.ScrollLockStatusDone + var exitCode *int + for _, status := range statuses { + 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, "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..9620fea0 --- /dev/null +++ b/apps/druid/core/services/command_tools_test.go @@ -0,0 +1,402 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "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 || run.(map[string]any)["accepted"] != true { + t.Fatal(err) + } + _, err = s.CallCommandTool(ctx, token, "tools", "wait_for_command", ToolInput{Command: "created", 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.AuthenticateCommandIdentity(ctx, token); err != nil { + 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") + } + 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"}} { + 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.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"}} { + _, 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") + } + 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") + } + 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 + credentials sync.Map + 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) AuthenticateCommandIdentity(_ context.Context, token string) (workloadidentity.Identity, error) { + if record, ok := b.credentials.Load(token); ok { + b.authentications.Add(1) + return record.(workloadidentity.Identity), nil + } + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized +} +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() + 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 +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 + 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": {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) + token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln" + backend.credentials.Store(token, workloadidentity.Identity{DeploymentID: "tools", Permissions: permissions}) + return supervisor, backend, 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") + } + backend.credentials.Delete(token) + if _, err := supervisor.CallCommandTool(ctx, token, "tools", "list_commands", ToolInput{}); !errors.Is(err, workloadidentity.ErrUnauthorized) { + t.Fatal("revoked token accepted") + } +} + +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) + } + 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", 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: "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", 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 TestProcedureEventsUpdateCurrentCommandStatus(t *testing.T) { + supervisor, _, _ := commandToolsFixture(t) + session := supervisor.sessions["tools"] + 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.Status != domain.ScrollLockStatusError { + t.Fatal("procedure event did not update the named command") + } +} diff --git a/apps/druid/core/services/runtime_commands.go b/apps/druid/core/services/runtime_commands.go index 04882fa3..427724ff 100644 --- a/apps/druid/core/services/runtime_commands.go +++ b/apps/druid/core/services/runtime_commands.go @@ -12,6 +12,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 +22,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 diff --git a/apps/druid/core/services/runtime_dev_test.go b/apps/druid/core/services/runtime_dev_test.go index 6a21c6ee..90ecf231 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{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") diff --git a/apps/druid/core/services/runtime_identity.go b/apps/druid/core/services/runtime_identity.go new file mode 100644 index 00000000..0a3ed2c5 --- /dev/null +++ b/apps/druid/core/services/runtime_identity.go @@ -0,0 +1,31 @@ +package services + +import ( + "context" + + "github.com/highcard-dev/daemon/internal/core/ports" + "github.com/highcard-dev/daemon/internal/workloadidentity" +) + +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.Identity{DeploymentID: deployment} + for _, permission := range ToolPermissions { + record.Permissions = append(record.Permissions, permission) + } + return record, nil + } + backend, ok := s.runtimeBackend.(ports.CommandIdentityBackend) + if !ok { + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized + } + record, err := backend.AuthenticateCommandIdentity(ctx, token) + if err != nil { + return workloadidentity.Identity{}, err + } + if record.DeploymentID != deployment { + 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 new file mode 100644 index 00000000..9d3e2d16 --- /dev/null +++ b/apps/druid/core/services/runtime_identity_test.go @@ -0,0 +1,113 @@ +package services + +import ( + "context" + "errors" + "strings" + "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 TestHydratedIdentityCommandPassesConfigurationToBackend(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.Identity == nil || seen.WorkloadAPIURL != "http://runtime.local" || seen.Command.Procedures[0].RuntimeIdentity != nil { + t.Fatal("session must pass identity configuration without preparing credentials") + } +} + +type podIdentityBackend struct { + ports.RuntimeBackendInterface + record workloadidentity.Identity + name string +} + +func (b podIdentityBackend) AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Identity, error) { + return b.record, nil +} + +func (b podIdentityBackend) Name() string { + if b.name != "" { + return b.name + } + 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 }), "") + 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") + } +} + +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_session.go b/apps/druid/core/services/runtime_session.go index 52f2cacd..4fd698fb 100644 --- a/apps/druid/core/services/runtime_session.go +++ b/apps/druid/core/services/runtime_session.go @@ -14,6 +14,7 @@ import ( // the command queue and cached scroll.yaml; storage and containers stay behind // the runtime backend. type RuntimeSession struct { + workloadAPIURL string 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_execution.go b/apps/druid/core/services/runtime_session_execution.go index e455d0b8..d3993958 100644 --- a/apps/druid/core/services/runtime_session_execution.go +++ b/apps/druid/core/services/runtime_session_execution.go @@ -68,15 +68,15 @@ func (s *RuntimeSession) runCommand(cmd string, extraEnv ...map[string]map[strin procedureEnv[procedure][key] = value } } - 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/apps/druid/core/services/runtime_supervisor.go b/apps/druid/core/services/runtime_supervisor.go index 2cfa7461..0b2172d7 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" @@ -20,6 +21,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 +50,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 { @@ -58,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 { @@ -292,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 + } + 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.PublishUIPackage(runtimeScroll.ID, string(domain.RuntimeUIPackageScopePrivate), scroll.UI.Private.Path) + _, 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_supervisor_test.go b/apps/druid/core/services/runtime_supervisor_test.go index 26a509d6..ab2ba19f 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) } @@ -1636,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/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 cd8272d5..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) + 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) } - if got != tt.want { - t.Fatalf("path = %q, want %q", got, tt.want) - } - }) + } + 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") @@ -123,6 +144,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 +155,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) } } @@ -146,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]) @@ -154,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) @@ -169,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/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/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..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", "create", "update", "patch", "delete"] + 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/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/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/command_identity.go b/internal/core/domain/command_identity.go new file mode 100644 index 00000000..dc7c4e64 --- /dev/null +++ b/internal/core/domain/command_identity.go @@ -0,0 +1,20 @@ +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 { + DeploymentID string + Permissions []string +} + +func ValidCommandPermission(permission string) bool { + switch permission { + 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 new file mode 100644 index 00000000..6db72795 --- /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", "commands:remove", "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/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 2b492135..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 { @@ -83,18 +86,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 +203,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") @@ -278,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) + } + } } } @@ -312,6 +326,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/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/ports/command_runtime.go b/internal/core/ports/command_runtime.go new file mode 100644 index 00000000..2a6cbbbb --- /dev/null +++ b/internal/core/ports/command_runtime.go @@ -0,0 +1,17 @@ +package ports + +import ( + "context" + "github.com/highcard-dev/daemon/internal/workloadidentity" +) + +// CommandIdentityBackend is implemented by runtimes that authenticate command workloads. +// It does not replace infrastructure ServiceAccount authentication. +type CommandIdentityBackend interface { + AuthenticateCommandIdentity(context.Context, string) (workloadidentity.Identity, 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..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/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/internal/runtime/docker/command_identity_test.go b/internal/runtime/docker/command_identity_test.go new file mode 100644 index 00000000..7d461225 --- /dev/null +++ b/internal/runtime/docker/command_identity_test.go @@ -0,0 +1,17 @@ +package docker + +import ( + "github.com/highcard-dev/daemon/internal/core/domain" + "testing" +) + +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 len(host.Mounts) != 0 { + t.Fatal("Docker command identity created credential state") + } +} 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..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 new file mode 100644 index 00000000..3ef787e0 --- /dev/null +++ b/internal/runtime/kubernetes/command_authentication_test.go @@ -0,0 +1,123 @@ +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"}, + 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}, + } +} + +func TestCommandServiceAccountAuthentication(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*corev1.Pod, *authv1.TokenReviewStatus) + 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 }}, + {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: "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" || 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) + } + // 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.ensureRuntimeServiceAccounts(context.Background(), "games"); 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 new file mode 100644 index 00000000..e823747a --- /dev/null +++ b/internal/runtime/kubernetes/command_identity.go @@ -0,0 +1,85 @@ +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" +) + +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 { + return nil + } + return map[string]string{commandDeploymentAnnotation: identity.DeploymentID, labelCommand: command, commandPermissionsAnnotation: strings.Join(identity.Permissions, " ")} +} + +func prepareCommandIdentity(command ports.RuntimeCommand) (ports.RuntimeCommand, error) { + if command.Command.Identity == nil { + return command, nil + } + 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) { + pod, err := b.authenticateBoundPod(ctx, token, commandTokenAudience) + if err != nil { + return workloadidentity.Identity{}, err + } + deployment, command := pod.Annotations[commandDeploymentAnnotation], pod.Annotations[labelCommand] + 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.Identity{}, workloadidentity.ErrUnauthorized + } + return workloadidentity.Identity{DeploymentID: deployment, Permissions: strings.Fields(pod.Annotations[commandPermissionsAnnotation])}, nil + } + } + return workloadidentity.Identity{}, workloadidentity.ErrUnauthorized +} diff --git a/internal/runtime/kubernetes/command_identity_test.go b/internal/runtime/kubernetes/command_identity_test.go new file mode 100644 index 00000000..e1a146c0 --- /dev/null +++ b/internal/runtime/kubernetes/command_identity_test.go @@ -0,0 +1,103 @@ +package kubernetes + +import ( + "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} + job, err := procedureJobSpec("runtime", ref("runtime", "data"), "automation", "automation.0", "job", 1, procedure, nil, "", "") + if err != nil { + t.Fatal(err) + } + 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") + } + 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") + } + 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 set.Spec.Template.Annotations[commandPermissionsAnnotation] != "commands:list logs:read" { + t.Fatal("persistent Pod authorization grants missing") + } +} + +func TestCommandsWithoutIdentityReceiveNoCredential(t *testing.T) { + 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/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/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.go b/internal/runtime/kubernetes/resources.go index f026be10..000c48d5 100644 --- a/internal/runtime/kubernetes/resources.go +++ b/internal/runtime/kubernetes/resources.go @@ -227,6 +227,7 @@ func procedureJobSpec(namespace string, root string, commandName string, procedu Containers: []corev1.Container{container}, Volumes: []corev1.Volume{pvcVolume("data", pvc)}, } + applyCommandIdentity(&podSpec, procedure.RuntimeIdentity) if registrySecret != "" { podSpec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: registrySecret}} } @@ -240,7 +241,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, }, }, @@ -335,6 +336,7 @@ func persistentStatefulSetSpec(namespace string, root string, commandName string Containers: []corev1.Container{container}, Volumes: []corev1.Volume{pvcVolume("data", pvc)}, } + applyCommandIdentity(&podSpec, mainProcedure.RuntimeIdentity) if registrySecret != "" { podSpec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: registrySecret}} } @@ -349,13 +351,30 @@ 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, }, }, }, nil } +func applyCommandIdentity(pod *corev1.PodSpec, identity *domain.RuntimeCommandIdentity) { + pod.AutomountServiceAccountToken = ptrBool(false) + if identity == nil { + 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"}}}}}}) + 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/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 e55c7b14..9cfd49a9 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,26 @@ 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) - } + if err := b.ensureRuntimeServiceAccount(ctx, namespace, runtimeWorkerServiceAccount); err != nil { + return err } - return nil + return b.ensureRuntimeServiceAccount(ctx, namespace, commandServiceAccount) } 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/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/internal/workloadidentity/identity.go b/internal/workloadidentity/identity.go new file mode 100644 index 00000000..af081207 --- /dev/null +++ b/internal/workloadidentity/identity.go @@ -0,0 +1,30 @@ +// Package workloadidentity defines command-workload authorization, distinct +// from owner JWTs and infrastructure Kubernetes ServiceAccount identities. +package workloadidentity + +import ( + "errors" + "regexp" +) + +const TokenFile = "/run/secrets/druid/token" + +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 Identity struct { + DeploymentID string + Permissions []string +} + +func (r Identity) Allows(permission string) bool { + for _, allowed := range r.Permissions { + if allowed == permission { + return true + } + } + return false +} + +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..a3fb6b9a --- /dev/null +++ b/internal/workloadidentity/identity_test.go @@ -0,0 +1,10 @@ +package workloadidentity + +import "testing" + +func TestCredentialRedaction(t *testing.T) { + token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlIn0.c2ln" + if got := Redact("echo " + token); got != "echo [REDACTED]" { + t.Fatal("credential exposed") + } +} 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 diff --git a/test/integration/agent_ui_test.go b/test/integration/agent_ui_test.go new file mode 100644 index 00000000..0b13c592 --- /dev/null +++ b/test/integration/agent_ui_test.go @@ -0,0 +1,236 @@ +//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" + 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/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) + } + supervisor, err := appservices.NewRuntimeSupervisor(runtime.Store, coreservices.NewRuntimeScrollManager(runtime.Store), runtime, "http://host.docker.internal:8082") + 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.Run(id, "agent"); err != nil { + t.Fatal(err) + } + 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) { + 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"}, + }}}}) + call("run_command", map[string]any{"command": "build_fixture"}) + for { + wait := call("wait_for_command", map[string]any{"command": "build_fixture", "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..eb563955 --- /dev/null +++ b/test/integration/workload_identity_test.go @@ -0,0 +1,414 @@ +//go:build integration + +package integration_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "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" + 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" + 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() + if t.token != "" { + copy.Header.Set("Authorization", "Bearer "+t.token) + } else { + copy.Header.Del("Authorization") + } + 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{}) }) + // 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() + 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) + } + 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) + } + 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, commands:remove, 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 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) + } + 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) + })) + server.Listener = listener + server.Start() + defer server.Close() + if _, err := supervisor.Run(id, "agent"); err != nil { + t.Fatal(err) + } + var token 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) + } + + readCredential := func() bool { + queue, err := supervisor.Queue(id) + if err != nil { + return false + } + if len(queue["agent"]) == 0 { + return false + } + if kubernetes != nil { + 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 || pod.DeletionTimestamp != nil { + 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 != "" + } + } + return false + } + return true // Docker tools intentionally need no credential. + } + 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) + } + 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_MCP_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) + 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"} { + 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") + } + _ = session.Close() // Accepted work must survive the caller going away. + 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"}) + if err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(result) + 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 agent 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, runtime authentication, MCP initialize and tool discovery") + } + // Reconnect a new supervisor to persisted running resources. + reattached, err := runtimebackend.NewRuntime(backendName, state, options...) + if err != nil { + t.Fatal(err) + } + second, err := appservices.NewRuntimeSupervisor(reattached.Store, coreservices.NewRuntimeScrollManager(reattached.Store), reattached, workloadAPIURL) + if 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) + } + 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 := token + 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 { + if !readCredential() { + return false + } + return kubernetes == nil || token != oldToken + }) + + if _, err := supervisor.AuthenticateCommand(ctx, oldToken, id); kubernetes != nil && err == nil { + t.Fatal("old credential resurrected after replacement") + } + t.Log("verified runtime MCP, tool discovery, command creation/execution, stdin/logs, completed logs, disconnection, daemon reattachment, and backend-specific authentication") +}