From 86b96fd14e9a3e42ceec0dde446b0828c295a0db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 11 Sep 2026 15:51:32 +0100 Subject: [PATCH] fix: stabilize canvas runtime and permission review (#3600) --- apps/backend/AGENTS.md | 8 + .../internal/backendapp/canvas_authoring.go | 48 +- .../canvas_authoring_scaffold_test.go | 9 +- .../internal/backendapp/canvas_routes.go | 49 +- .../backendapp/canvas_source_projection.go | 80 +++ .../canvas_source_projection_test.go | 81 +++ apps/backend/internal/canvas/authoring.go | 218 ++++++-- .../backend/internal/canvas/authoring_test.go | 360 ++++++++++++++ apps/backend/internal/canvas/canvas_test.go | 26 + apps/backend/internal/canvas/repository.go | 92 ++++ apps/backend/internal/canvas/service.go | 20 +- apps/backend/internal/canvas/types.go | 54 +- .../internal/mcp/canvasskill/files/SKILL.md | 21 +- .../canvasskill/files/references/manifest.md | 21 +- .../persistence/requiredstores/catalog.go | 2 +- .../internal/plugins/instances/store.go | 29 ++ .../backend/internal/plugins/webapp/policy.go | 1 + .../internal/plugins/webapp/policy_test.go | 12 +- .../internal/plugins/webapp/runtime.go | 37 +- .../plugins/webapp/runtime_bootstrap.go | 164 ++++++ .../internal/plugins/webapp/runtime_test.go | 123 ++++- .../components/plugins/canvas-page.test.tsx | 25 +- .../components/plugins/web-app-frame.test.tsx | 86 +++- apps/web/components/plugins/web-app-frame.tsx | 156 +++++- .../plugins/web-app-startup.test.ts | 71 +++ .../web/components/plugins/web-app-startup.ts | 77 +++ .../settings/canvas-host-components.tsx | 5 +- .../settings/canvas-host-route.test.tsx | 7 +- .../components/settings/canvas-host-route.tsx | 31 +- .../canvas-lifecycle-dialogs.test.tsx | 138 +++++- .../settings/canvas-lifecycle-dialogs.tsx | 469 +++--------------- .../settings/canvas-permission-summary.tsx | 55 ++ .../settings/canvas-release-review.tsx | 439 ++++++++++++++++ apps/web/e2e/tests/canvas/canvas-fixture.ts | 8 + .../tests/canvas/canvas-host-origins.spec.ts | 86 ++++ .../e2e/tests/canvas/canvas-origin-fixture.ts | 304 ++++++++++++ .../tests/canvas/mobile-plugin-canvas.spec.ts | 22 +- .../e2e/tests/canvas/plugin-canvas.spec.ts | 69 ++- apps/web/lib/api/domains/canvas-api.ts | 4 + apps/web/lib/canvas-permission-copy.test.ts | 91 ++++ apps/web/lib/canvas-permission-copy.ts | 211 ++++++++ apps/web/src/locales/en/canvases.json | 22 + apps/web/src/locales/pseudo/canvases.json | 22 + apps/web/src/locales/pt-pt/canvases.json | 22 + apps/web/src/locales/zh-cn/canvases.json | 22 + apps/web/src/locales/zh-hk/canvases.json | 22 + apps/web/src/locales/zh-tw/canvases.json | 22 + ...26-08-26-plugin-backed-web-app-canvases.md | 6 + .../2026-09-10-canvas-creation-authority.md | 59 +++ docs/decisions/INDEX.md | 3 +- .../canvas-runtime-permission-fixes/plan.md | 312 ++++++++++++ .../task-01-same-origin-embedding.md | 112 +++++ .../task-02-initial-owner-publication.md | 157 ++++++ .../task-03-runtime-startup.md | 161 ++++++ .../task-04-release-review.md | 176 +++++++ .../plan.md | 8 + docs/plans/plugin-backed-canvases/plan.md | 8 + docs/public/canvases.md | 37 +- docs/public/plugins-authoring.md | 15 + docs/public/security.md | 24 +- docs/specs/canvases/README.md | 6 +- .../requirements/agent-authored-web-apps.md | 34 +- .../requirements/local-creation-authority.md | 73 +++ .../system-design/agent-authored-web-apps.md | 91 ++-- .../system-design/local-creation-authority.md | 124 +++++ .../isolated-web-app-contributions.md | 37 +- .../isolated-web-app-contributions.md | 89 +++- 67 files changed, 4829 insertions(+), 644 deletions(-) create mode 100644 apps/backend/internal/backendapp/canvas_source_projection.go create mode 100644 apps/backend/internal/backendapp/canvas_source_projection_test.go create mode 100644 apps/backend/internal/plugins/webapp/runtime_bootstrap.go create mode 100644 apps/web/components/plugins/web-app-startup.test.ts create mode 100644 apps/web/components/plugins/web-app-startup.ts create mode 100644 apps/web/components/settings/canvas-permission-summary.tsx create mode 100644 apps/web/components/settings/canvas-release-review.tsx create mode 100644 apps/web/e2e/tests/canvas/canvas-host-origins.spec.ts create mode 100644 apps/web/e2e/tests/canvas/canvas-origin-fixture.ts create mode 100644 apps/web/lib/canvas-permission-copy.test.ts create mode 100644 apps/web/lib/canvas-permission-copy.ts create mode 100644 docs/decisions/2026-09-10-canvas-creation-authority.md create mode 100644 docs/plans/canvas-runtime-permission-fixes/plan.md create mode 100644 docs/plans/canvas-runtime-permission-fixes/task-01-same-origin-embedding.md create mode 100644 docs/plans/canvas-runtime-permission-fixes/task-02-initial-owner-publication.md create mode 100644 docs/plans/canvas-runtime-permission-fixes/task-03-runtime-startup.md create mode 100644 docs/plans/canvas-runtime-permission-fixes/task-04-release-review.md create mode 100644 docs/specs/canvases/requirements/local-creation-authority.md create mode 100644 docs/specs/canvases/system-design/local-creation-authority.md diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 687468c3b5..e53575e8c8 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -97,6 +97,14 @@ apps/backend/ │ └── worktree/ # Git worktree management for workspace isolation ``` +Canvas creation authority is recorded only by the trusted task authoring +adapter. It binds the workspace owner, creating session, task scope, and +policy version to a new draft. The first valid static web-app release may +consume that single-use authority for exact task-scoped grants in the same +transaction as release insertion and activation. Existing drafts, imports, +later permission increases, and revoked grants remain on the human review +path. Never use source metadata or a manifest trust field as authority. + ## Key Concepts **Orchestrator** coordinates task execution: diff --git a/apps/backend/internal/backendapp/canvas_authoring.go b/apps/backend/internal/backendapp/canvas_authoring.go index d055555f86..8889431f5c 100644 --- a/apps/backend/internal/backendapp/canvas_authoring.go +++ b/apps/backend/internal/backendapp/canvas_authoring.go @@ -12,6 +12,7 @@ import ( "time" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/canvas" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/mcp/canvasskill" @@ -137,11 +138,15 @@ func (s *canvasAuthoringService) CreateCanvas(ctx context.Context, request mcpha } else if editSession { return nil, canvasOperationError("canvas_edit_restricted", "canvas edit sessions can only modify their target canvas", nil) } - ownerID := s.workspaceOwner(ctx, task.WorkspaceID) + ownerID, _, err := s.workspaceOwner(ctx, task.WorkspaceID) + if err != nil { + return nil, canvasOperationError("workspace_owner_unavailable", "the workspace owner could not be verified", err) + } created, err := s.canvases.CreateCanvas(ctx, canvas.CreateCanvasRequest{ WorkspaceID: task.WorkspaceID, TaskID: task.ID, OriginTaskID: task.ID, Title: request.Title, CreatedBySessionID: taskSessionID(request.Agent), + OwnerUserID: ownerID, }) if err != nil { return nil, canvasOperationError(canvasErrorCode(err), "canvas could not be created", err) @@ -179,6 +184,13 @@ func canvasCreateResponse(created *canvas.Canvas, root, ownerID string, scaffold "source_path": root, "source_root": root, "owner_id": ownerID, + "initial_permission_policy": map[string]any{ + "version": canvas.CreationAuthorityPolicyVersion, + "eligible": ownerID != "" && created.TaskID != "" && created.CreatedBySessionID != "", + "scope": canvas.ScopeTask, + "supported_permission_kinds": []string{"api_read", "api_write", "events", "state", "network"}, + "exact_https_origins": true, + }, "skill": map[string]any{ "slug": canvasskill.Slug, "version": canvasskill.Version, "read_tool": "read_canvas_authoring_skill_kandev", @@ -369,6 +381,10 @@ func (s *canvasAuthoringService) publishCanvasSource(ctx context.Context, execut if artifacts == nil || store == nil { return nil, canvasOperationError("runtime_unavailable", "canvas release storage is unavailable", nil) } + ownerID, ownerSynthetic, err := s.workspaceOwner(ctx, task.WorkspaceID) + if err != nil { + return nil, canvasOperationError("workspace_owner_unavailable", "the workspace owner could not be verified", err) + } // Reserve the maximum expanded artifact size before reading agentctl. The // exact package size is not known until the bounded stream validates, and // this reservation prevents concurrent publishers from bypassing the @@ -405,7 +421,7 @@ func (s *canvasAuthoringService) publishCanvasSource(ctx context.Context, execut }() result, err := s.canvases.PublishPackage(ctx, canvas.PublishRequest{ CanvasID: item.ID, Package: pkg, Artifact: artifact, ExpectedAuthority: expectedAuthority, ExpectedBaseReleaseID: expectedBaseReleaseID, - SourceActorKind: canvasSourceActor, SourceUserID: s.workspaceOwner(ctx, task.WorkspaceID), + SourceActorKind: canvasSourceActor, SourceUserID: ownerID, SourceUserSynthetic: ownerSynthetic, SourceTaskID: task.ID, SourceSessionID: request.Agent.SessionID, }) if result != nil && result.ReleasePersisted { @@ -606,13 +622,29 @@ func canvasEditTargetMatches(target canvasEditSessionTarget, taskID, canvasID st target.ReleaseID != "" } -func (s *canvasAuthoringService) workspaceOwner(ctx context.Context, workspaceID string) string { - if s.tasks != nil { - if workspace, err := s.tasks.GetWorkspace(ctx, workspaceID); err == nil && workspace != nil && workspace.OwnerID != "" { - return workspace.OwnerID - } +func (s *canvasAuthoringService) workspaceOwner(ctx context.Context, workspaceID string) (string, bool, error) { + if s.tasks == nil || strings.TrimSpace(workspaceID) == "" { + return "", false, errors.New("task service is unavailable") } - return userstore.DefaultUserID + workspace, err := s.tasks.GetWorkspace(ctx, workspaceID) + if err != nil { + return "", false, err + } + if workspace != nil && strings.TrimSpace(workspace.OwnerID) != "" { + return strings.TrimSpace(workspace.OwnerID), false, nil + } + identity, ok := authn.IdentityFromContext(ctx) + if ok && identity.Synthetic && strings.TrimSpace(identity.UserID) != "" { + return strings.TrimSpace(identity.UserID), true, nil + } + if !ok { + // Auth-disabled in-session MCP dispatches do not carry the HTTP + // middleware identity. Preserve the single-user owner used by the + // pre-auth workspace contract, while marking the source synthetic so + // the publication transaction still requires an unowned workspace. + return userstore.DefaultUserID, true, nil + } + return "", false, errors.New("workspace has no persisted owner") } func (s *canvasAuthoringService) beginPublish(sessionID, canvasID string) error { diff --git a/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go b/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go index 91b2244c76..1a67cea9aa 100644 --- a/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go +++ b/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go @@ -71,7 +71,7 @@ func TestCanvasCoreBundle_ContainsOneReadContract(t *testing.T) { } func TestCanvasCreateResponseCarriesCurrentAuthoringVersion(t *testing.T) { - item := &canvas.Canvas{ID: "canvas-1", Title: "Canvas", WorkspaceID: "workspace-1"} + item := &canvas.Canvas{ID: "canvas-1", Title: "Canvas", WorkspaceID: "workspace-1", TaskID: "task-1", CreatedBySessionID: "session-1"} response := canvasCreateResponse(item, canvasSourceRoot(item.ID), "owner-1", []canvasScaffoldFile{{ Path: "manifest.yaml", Content: []byte("id: canvas-1\n"), }}) @@ -80,6 +80,13 @@ func TestCanvasCreateResponseCarriesCurrentAuthoringVersion(t *testing.T) { require.True(t, ok) require.Equal(t, canvasskill.Version, skill["version"]) require.Equal(t, "2", skill["version"]) + policy, ok := response["initial_permission_policy"].(map[string]any) + require.True(t, ok) + require.Equal(t, canvas.CreationAuthorityPolicyVersion, policy["version"]) + require.Equal(t, true, policy["eligible"]) + require.Equal(t, canvas.ScopeTask, policy["scope"]) + require.Equal(t, []string{"api_read", "api_write", "events", "state", "network"}, policy["supported_permission_kinds"]) + require.Equal(t, true, policy["exact_https_origins"]) } func TestCanvasCreateResponseShapeIsExecutorPortable(t *testing.T) { diff --git a/apps/backend/internal/backendapp/canvas_routes.go b/apps/backend/internal/backendapp/canvas_routes.go index 7e1e64f8f9..ea9feb341c 100644 --- a/apps/backend/internal/backendapp/canvas_routes.go +++ b/apps/backend/internal/backendapp/canvas_routes.go @@ -412,7 +412,9 @@ type canvasReleaseResponse struct { SourceActorKind string `json:"source_actor_kind,omitempty"` SourceUserID string `json:"source_user_id,omitempty"` SourceTaskID string `json:"source_task_id,omitempty"` + SourceTaskTitle string `json:"source_task_title,omitempty"` SourceSessionID string `json:"source_session_id,omitempty"` + SourceSessionName string `json:"source_session_name,omitempty"` ProtocolVersion int `json:"protocol_version,omitempty"` CreatedAt time.Time `json:"created_at"` } @@ -508,7 +510,9 @@ func (h *canvasHTTPHandler) get(c *gin.Context) { if !h.authorizeWorkspace(c, canvas.WorkspaceID) { return } - writeCanvasJSON(c, http.StatusOK, canvasResponse(*canvas)) + response := canvasResponse(*canvas) + h.decorateCanvasReleaseSources(c.Request.Context(), canvas.WorkspaceID, response.ActiveRelease, response.PendingRelease) + writeCanvasJSON(c, http.StatusOK, response) } func (h *canvasHTTPHandler) releases(c *gin.Context) { @@ -538,7 +542,9 @@ func (h *canvasHTTPHandler) releases(c *gin.Context) { } result := make([]canvasReleaseResponse, 0, len(list)) for _, release := range list { - result = append(result, releaseResponse(release, instance.ScopeKind, grants)) + response := releaseResponse(release, instance.ScopeKind, grants) + h.decorateCanvasReleaseSources(c.Request.Context(), canvas.WorkspaceID, &response) + result = append(result, response) } writeCanvasJSON(c, http.StatusOK, map[string]interface{}{"releases": result}) } @@ -587,22 +593,31 @@ func (h *canvasHTTPHandler) promotionPreview(c *gin.Context) { if preview == nil || preview.Canvas == nil { return } + sourceLabels := projectCanvasSourceLabels( + c.Request.Context(), + h.tasks, + item.WorkspaceID, + preview.SourceTaskID, + preview.SourceSessionID, + ) writeCanvasJSON(c, http.StatusOK, map[string]interface{}{ - "canvas_id": preview.Canvas.ID, - "title": preview.Canvas.Title, - "origin_task_id": preview.Canvas.OriginTaskID, - "active_release": releaseResponseFromCanvas(preview.Canvas), - "source_actor_kind": preview.SourceActorKind, - "source_user_id": preview.SourceUserID, - "source_task_id": preview.SourceTaskID, - "source_session_id": preview.SourceSessionID, - "permissions": preview.Permissions, - "active_release_id": preview.ActiveReleaseID, - "permission_digest": preview.PermissionDigest, - "grant_generation": preview.GrantGeneration, - "current_scope": preview.CurrentScope, - "target_scope": preview.TargetScope, - "placement": preview.Placement, + "canvas_id": preview.Canvas.ID, + "title": preview.Canvas.Title, + "origin_task_id": preview.Canvas.OriginTaskID, + "active_release": releaseResponseFromCanvas(preview.Canvas), + "source_actor_kind": preview.SourceActorKind, + "source_user_id": preview.SourceUserID, + "source_task_id": preview.SourceTaskID, + "source_task_title": sourceLabels.TaskTitle, + "source_session_id": preview.SourceSessionID, + "source_session_name": sourceLabels.SessionName, + "permissions": preview.Permissions, + "active_release_id": preview.ActiveReleaseID, + "permission_digest": preview.PermissionDigest, + "grant_generation": preview.GrantGeneration, + "current_scope": preview.CurrentScope, + "target_scope": preview.TargetScope, + "placement": preview.Placement, }) } diff --git a/apps/backend/internal/backendapp/canvas_source_projection.go b/apps/backend/internal/backendapp/canvas_source_projection.go new file mode 100644 index 0000000000..10fb91b20d --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_source_projection.go @@ -0,0 +1,80 @@ +package backendapp + +import ( + "context" + "strings" + + "github.com/kandev/kandev/internal/task/models" +) + +type canvasSourceLookup interface { + GetTask(context.Context, string) (*models.Task, error) + GetTaskSession(context.Context, string) (*models.TaskSession, error) +} + +type canvasSourceLabels struct { + TaskTitle string + SessionName string +} + +// projectCanvasSourceLabels adds only labels that the current caller can read. +// The source identifiers remain action and lookup identities, never fallbacks. +func projectCanvasSourceLabels( + ctx context.Context, + lookup canvasSourceLookup, + workspaceID string, + taskID string, + sessionID string, +) canvasSourceLabels { + if lookup == nil { + return canvasSourceLabels{} + } + + if taskID == "" && sessionID != "" { + session, err := lookup.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + return canvasSourceLabels{} + } + taskID = session.TaskID + } + if taskID == "" { + return canvasSourceLabels{} + } + + task, err := lookup.GetTask(ctx, taskID) + if err != nil || task == nil || task.WorkspaceID != workspaceID { + return canvasSourceLabels{} + } + labels := canvasSourceLabels{TaskTitle: strings.TrimSpace(task.Title)} + if sessionID == "" { + return labels + } + + session, err := lookup.GetTaskSession(ctx, sessionID) + if err != nil || session == nil || session.TaskID != task.ID { + return labels + } + labels.SessionName = strings.TrimSpace(session.Name) + return labels +} + +func (h *canvasHTTPHandler) decorateCanvasReleaseSources( + ctx context.Context, + workspaceID string, + responses ...*canvasReleaseResponse, +) { + for _, response := range responses { + if response == nil || (response.SourceTaskID == "" && response.SourceSessionID == "") { + continue + } + labels := projectCanvasSourceLabels( + ctx, + h.tasks, + workspaceID, + response.SourceTaskID, + response.SourceSessionID, + ) + response.SourceTaskTitle = labels.TaskTitle + response.SourceSessionName = labels.SessionName + } +} diff --git a/apps/backend/internal/backendapp/canvas_source_projection_test.go b/apps/backend/internal/backendapp/canvas_source_projection_test.go new file mode 100644 index 0000000000..5103b281f2 --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_source_projection_test.go @@ -0,0 +1,81 @@ +package backendapp + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +type canvasSourceLookupStub struct { + tasks map[string]*models.Task + sessions map[string]*models.TaskSession + err error +} + +func (s canvasSourceLookupStub) GetTask(_ context.Context, id string) (*models.Task, error) { + if s.err != nil { + return nil, s.err + } + return s.tasks[id], nil +} + +func (s canvasSourceLookupStub) GetTaskSession(_ context.Context, id string) (*models.TaskSession, error) { + if s.err != nil { + return nil, s.err + } + return s.sessions[id], nil +} + +func TestProjectCanvasSourceLabelsRequiresCurrentWorkspaceAccess(t *testing.T) { + lookup := canvasSourceLookupStub{ + tasks: map[string]*models.Task{ + "task-1": {ID: "task-1", WorkspaceID: "workspace-1", Title: "Canvas source task"}, + "task-2": {ID: "task-2", WorkspaceID: "workspace-2", Title: "Foreign task"}, + }, + sessions: map[string]*models.TaskSession{ + "session-1": {ID: "session-1", TaskID: "task-1", Name: "Canvas source session"}, + "session-2": {ID: "session-2", TaskID: "task-2", Name: "Foreign session"}, + }, + } + + if got := projectCanvasSourceLabels(context.Background(), lookup, "workspace-1", "task-1", "session-1"); got != (canvasSourceLabels{TaskTitle: "Canvas source task", SessionName: "Canvas source session"}) { + t.Fatalf("authorized labels = %+v", got) + } + if got := projectCanvasSourceLabels(context.Background(), lookup, "workspace-1", "task-2", "session-2"); got != (canvasSourceLabels{}) { + t.Fatalf("foreign labels = %+v, want empty", got) + } + if got := projectCanvasSourceLabels(context.Background(), lookup, "workspace-1", "missing-task", "missing-session"); got != (canvasSourceLabels{}) { + t.Fatalf("deleted labels = %+v, want empty", got) + } +} + +func TestProjectCanvasSourceLabelsRejectsMismatchedSession(t *testing.T) { + lookup := canvasSourceLookupStub{ + tasks: map[string]*models.Task{ + "task-1": {ID: "task-1", WorkspaceID: "workspace-1", Title: "Canvas source task"}, + }, + sessions: map[string]*models.TaskSession{ + "session-1": {ID: "session-1", TaskID: "other-task", Name: "Wrong session"}, + }, + } + + got := projectCanvasSourceLabels(context.Background(), lookup, "workspace-1", "task-1", "session-1") + if got.TaskTitle != "Canvas source task" || got.SessionName != "" { + t.Fatalf("mismatched session labels = %+v", got) + } +} + +func TestProjectCanvasSourceLabelsFailsClosedOnLookupError(t *testing.T) { + got := projectCanvasSourceLabels( + context.Background(), + canvasSourceLookupStub{err: errors.New("lookup failed")}, + "workspace-1", + "task-1", + "session-1", + ) + if got != (canvasSourceLabels{}) { + t.Fatalf("error labels = %+v, want empty", got) + } +} diff --git a/apps/backend/internal/canvas/authoring.go b/apps/backend/internal/canvas/authoring.go index 5ecbe411fb..43a0cabf67 100644 --- a/apps/backend/internal/canvas/authoring.go +++ b/apps/backend/internal/canvas/authoring.go @@ -43,6 +43,7 @@ type PublishRequest struct { ExpectedBaseReleaseID string SourceActorKind string SourceUserID string + SourceUserSynthetic bool SourceTaskID string SourceSessionID string } @@ -107,6 +108,14 @@ type conditionalReleaseStore interface { CreateReleaseIfAuthorityTx(context.Context, *sqlx.Tx, string, plugininstances.PublishAuthority, plugininstances.Release) error } +type initialGrantStore interface { + AddInitialGrantsTx(context.Context, *sqlx.Tx, string, string, []plugininstances.Grant) error +} + +type creationAuthorityStore interface { + ConsumeCreationAuthorityTx(context.Context, *sqlx.Tx, CreationAuthority, string, string, string, bool) error +} + type legacyConditionalReleaseStore interface { CreateReleaseIfActiveReleaseTx(context.Context, *sqlx.Tx, string, string, plugininstances.Release) error } @@ -134,11 +143,11 @@ func (s *Service) PublishPackage(ctx context.Context, request PublishRequest) (* if !ok { return nil, ErrCanvasNotConfigured } - instance, release, activated, err := s.preparePublishedRelease(ctx, store, request) + instance, release, activated, creationAuthority, initialGrants, err := s.preparePublishedRelease(ctx, store, request) if err != nil { return nil, err } - persisted, err := persistPublishedRelease(ctx, store, instance.ID, release, activated, request.ExpectedAuthority, request.ExpectedBaseReleaseID) + persisted, err := persistPublishedRelease(ctx, store, s.repo, request.CanvasID, instance.ID, release, activated, request.ExpectedAuthority, request.ExpectedBaseReleaseID, creationAuthority, initialGrants, request.SourceUserID, request.SourceUserSynthetic, request.SourceSessionID, request.SourceTaskID) if err != nil { if persisted { return &PublishResult{Release: release, Activated: activated, PermissionRequired: !activated, ReleasePersisted: true}, err @@ -165,46 +174,104 @@ func (s *Service) PublishPackage(ctx context.Context, request PublishRequest) (* return result, nil } -func (s *Service) preparePublishedRelease(ctx context.Context, store authoringInstanceStore, request PublishRequest) (plugininstances.Instance, plugininstances.Release, bool, error) { - if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" { - return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas) +func (s *Service) preparePublishedRelease(ctx context.Context, store authoringInstanceStore, request PublishRequest) (plugininstances.Instance, plugininstances.Release, bool, CreationAuthority, []plugininstances.Grant, error) { + if err := validatePublishRequest(request); err != nil { + return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } _, instance, err := s.load(ctx, request.CanvasID) if err != nil { - return plugininstances.Instance{}, plugininstances.Release{}, false, err + return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } - if instance.Status == StatusRemoved || instance.Status == StatusArchived { - return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, instance.Status) - } - if !request.ExpectedAuthority.IsZero() && request.ExpectedAuthority != instance.PublishAuthority() { - return plugininstances.Instance{}, plugininstances.Release{}, false, ErrStaleCanvasPublish - } - if request.Artifact.Digest != request.Package.Digest { - return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: artifact digest does not match package", ErrInvalidCanvas) - } - if err := request.Package.Manifest.Validate(); err != nil { - return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: invalid manifest: %v", ErrInvalidCanvas, err) + if err := validatePublishInstance(instance, request.ExpectedAuthority); err != nil { + return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } permissions := ManifestPermissions(request.Package.Manifest) - grants, err := store.ListGrants(ctx, instance.ID) + grants, releases, err := loadPublicationState(ctx, store, instance.ID) if err != nil { - return plugininstances.Instance{}, plugininstances.Release{}, false, err + return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err + } + creationAuthority, initialGrants, err := s.resolveCreationAuthority(ctx, request, instance, permissions, grants, releases) + if err != nil { + return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } activated := permissionsFit(permissions, instance.ScopeKind, grants) + if creationAuthority.CanvasID != "" { + activated = true + } + release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated) + if err != nil { + return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err + } + return instance, release, activated, creationAuthority, initialGrants, nil +} + +func validatePublishRequest(request PublishRequest) error { + if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" { + return fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas) + } + if request.Artifact.Digest != request.Package.Digest { + return fmt.Errorf("%w: artifact digest does not match package", ErrInvalidCanvas) + } + if err := request.Package.Manifest.Validate(); err != nil { + return fmt.Errorf("%w: invalid manifest: %v", ErrInvalidCanvas, err) + } + return validateLocalCanvasManifest(request.Package.Manifest) +} + +func validatePublishInstance(instance plugininstances.Instance, expectedAuthority plugininstances.PublishAuthority) error { + if instance.Status == StatusRemoved || instance.Status == StatusArchived { + return fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, instance.Status) + } + if !expectedAuthority.IsZero() && expectedAuthority != instance.PublishAuthority() { + return ErrStaleCanvasPublish + } + return nil +} + +func loadPublicationState(ctx context.Context, store authoringInstanceStore, instanceID string) ([]plugininstances.Grant, []plugininstances.Release, error) { + grants, err := store.ListGrants(ctx, instanceID) + if err != nil { + return nil, nil, err + } + releases, err := store.ListReleases(ctx, instanceID) + if err != nil { + return nil, nil, err + } + return grants, releases, nil +} + +func (s *Service) resolveCreationAuthority(ctx context.Context, request PublishRequest, instance plugininstances.Instance, permissions PermissionSummary, grants []plugininstances.Grant, releases []plugininstances.Release) (CreationAuthority, []plugininstances.Grant, error) { + if request.ExpectedAuthority.IsZero() { + return CreationAuthority{}, nil, nil + } + candidate, err := s.repo.GetCreationAuthority(ctx, request.CanvasID) + if err != nil { + if errors.Is(err, ErrCreationAuthorityNotFound) { + return CreationAuthority{}, nil, nil + } + return CreationAuthority{}, nil, err + } + if !creationAuthorityEligible(candidate, instance, request, grants, releases) { + return CreationAuthority{}, nil, nil + } + return candidate, grantsForManifest(permissions, candidate.OwnerUserID, ScopeTask), nil +} + +func (s *Service) buildPublishedRelease(request PublishRequest, instanceID string, permissions PermissionSummary, activated bool) (plugininstances.Release, error) { + manifestJSON, err := json.Marshal(request.Package.Manifest) + if err != nil { + return plugininstances.Release{}, fmt.Errorf("marshal canvas manifest: %w", err) + } + permissionsJSON, err := json.Marshal(permissions) + if err != nil { + return plugininstances.Release{}, fmt.Errorf("marshal canvas permissions: %w", err) + } validationStatus, validationError := plugininstances.ValidationPendingPermission, "permission_review_required" if activated { validationStatus, validationError = plugininstances.ValidationValid, "" } - manifestJSON, err := json.Marshal(request.Package.Manifest) - if err != nil { - return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("marshal canvas manifest: %w", err) - } - permissionsJSON, err := json.Marshal(permissions) - if err != nil { - return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("marshal canvas permissions: %w", err) - } - release := plugininstances.Release{ - ID: uuid.NewString(), PluginID: request.Package.Manifest.ID, InstanceID: instance.ID, + return plugininstances.Release{ + ID: uuid.NewString(), PluginID: request.Package.Manifest.ID, InstanceID: instanceID, PackageDigest: request.Package.Digest, SourceKind: plugininstances.SourceLocalCanvas, SourceActorKind: strings.TrimSpace(request.SourceActorKind), SourceUserID: strings.TrimSpace(request.SourceUserID), SourceTaskID: strings.TrimSpace(request.SourceTaskID), SourceSessionID: strings.TrimSpace(request.SourceSessionID), @@ -212,11 +279,10 @@ func (s *Service) preparePublishedRelease(ctx context.Context, store authoringIn ArtifactPath: request.Artifact.RelativePath, ArtifactBytes: request.Artifact.Bytes, ProtocolVersion: 1, ValidationStatus: validationStatus, ValidationError: validationError, CreatedAt: s.nowUTC(), - } - return instance, release, activated, nil + }, nil } -func persistPublishedRelease(ctx context.Context, store authoringInstanceStore, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority, expectedBaseReleaseID string) (bool, error) { +func persistPublishedRelease(ctx context.Context, store authoringInstanceStore, authorityStore creationAuthorityStore, canvasID, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority, expectedBaseReleaseID string, creationAuthority CreationAuthority, initialGrants []plugininstances.Grant, ownerUserID string, allowUnownedWorkspace bool, sessionID, taskID string) (bool, error) { if !expectedAuthority.IsZero() { if expectedBaseReleaseID != "" && expectedBaseReleaseID != expectedAuthority.ActiveReleaseID { return false, ErrStaleCanvasEdit @@ -226,7 +292,7 @@ func persistPublishedRelease(ctx context.Context, store authoringInstanceStore, if !ok || !supportsConditional { return false, ErrStaleCanvasPublish } - err := persistAuthorityRelease(ctx, transactional, conditional, instanceID, release, activated, expectedAuthority) + err := persistAuthorityRelease(ctx, transactional, conditional, authorityStore, canvasID, instanceID, release, activated, expectedAuthority, creationAuthority, initialGrants, ownerUserID, allowUnownedWorkspace, sessionID, taskID) return err == nil, err } if expectedBaseReleaseID != "" { @@ -252,7 +318,7 @@ func persistPublishedRelease(ctx context.Context, store authoringInstanceStore, return persistActivatedReleaseFallback(ctx, store, instanceID, release) } -func persistAuthorityRelease(ctx context.Context, transactional transactionalAuthoringStore, conditional conditionalReleaseStore, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority) error { +func persistAuthorityRelease(ctx context.Context, transactional transactionalAuthoringStore, conditional conditionalReleaseStore, authorityStore creationAuthorityStore, canvasID, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority, creationAuthority CreationAuthority, initialGrants []plugininstances.Grant, ownerUserID string, allowUnownedWorkspace bool, sessionID, taskID string) error { return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error { if err := conditional.CreateReleaseIfAuthorityTx(ctx, tx, instanceID, expectedAuthority, release); err != nil { return err @@ -260,6 +326,18 @@ func persistAuthorityRelease(ctx context.Context, transactional transactionalAut if !activated { return nil } + if creationAuthority.CanvasID != "" { + grantStore, ok := transactional.(initialGrantStore) + if !ok || authorityStore == nil || creationAuthority.CanvasID != canvasID { + return ErrStaleCanvasPublish + } + if err := grantStore.AddInitialGrantsTx(ctx, tx, instanceID, ownerUserID, initialGrants); err != nil { + return err + } + if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil { + return err + } + } if err := transactional.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil { return err } @@ -744,6 +822,78 @@ func permissionsFit(summary PermissionSummary, scope string, grants []plugininst return true } +func creationAuthorityEligible(authority CreationAuthority, instance plugininstances.Instance, request PublishRequest, grants []plugininstances.Grant, releases []plugininstances.Release) bool { + return creationAuthorityPolicyIsCurrent(authority) && + creationAuthorityInstanceMatches(authority, instance, grants, releases) && + creationAuthoritySourceMatches(authority, instance, request) +} + +func validateLocalCanvasManifest(m *manifest.Manifest) error { + if !localCanvasUIIsValid(m) { + return invalidLocalCanvasManifest() + } + if !localCanvasIntegrationsAreValid(m) { + return invalidLocalCanvasManifest() + } + if !localCanvasCapabilitiesAreValid(m) { + return invalidLocalCanvasManifest() + } + return nil +} + +func localCanvasUIIsValid(m *manifest.Manifest) bool { + return m.IsStaticWebAppOnly() && len(m.UI.Pages) == 0 && m.UI.Bundle == "" && len(m.UI.Styles) == 0 && len(m.UI.Keybindings) == 0 +} + +func localCanvasIntegrationsAreValid(m *manifest.Manifest) bool { + return len(m.Webhooks) == 0 && len(m.Actions) == 0 && len(m.RepositoryProviders) == 0 && len(m.ReferenceSources) == 0 && len(m.AuthProviders) == 0 && len(m.AgentTools) == 0 +} + +func localCanvasCapabilitiesAreValid(m *manifest.Manifest) bool { + return !m.Capabilities.Secrets && !m.Capabilities.AgentInvoke && !m.Capabilities.Auth && !m.Capabilities.UserState && + supportedCanvasCapabilityValues(m.Capabilities.APIRead, permissionKindAPIRead) && + supportedCanvasCapabilityValues(m.Capabilities.APIWrite, permissionKindAPIWrite) && + supportedCanvasCapabilityValues(m.Capabilities.Events, permissionKindEvents) +} + +func supportedCanvasCapabilityValues(values []string, kind string) bool { + for _, value := range values { + if !supportedCanvasCapability(kind, value) { + return false + } + } + return true +} + +func supportedCanvasCapability(kind, value string) bool { + switch kind { + case permissionKindAPIRead: + return value == canvasCapabilityTasks || value == canvasCapabilityWorkflows + case permissionKindAPIWrite: + return value == canvasCapabilityTasks || value == canvasCapabilityMessages + case permissionKindEvents: + return value == canvasCapabilityTaskUpdate + default: + return false + } +} + +func invalidLocalCanvasManifest() error { + return fmt.Errorf("%w: canvas packages support only static web applications and task-scoped data permissions", ErrInvalidCanvas) +} + +func creationAuthorityPolicyIsCurrent(authority CreationAuthority) bool { + return authority.PolicyVersion == CreationAuthorityPolicyVersion && authority.ConsumedAt.IsZero() && authority.OwnerUserID != "" && authority.CreatingSessionID != "" && authority.TaskID != "" +} + +func creationAuthorityInstanceMatches(authority CreationAuthority, instance plugininstances.Instance, grants []plugininstances.Grant, releases []plugininstances.Release) bool { + return instance.SourceKind == plugininstances.SourceLocalCanvas && instance.ScopeKind == ScopeTask && instance.Status == StatusPending && instance.ActiveReleaseID == "" && instance.GrantGeneration == 0 && instance.TaskID == authority.TaskID && len(grants) == 0 && len(releases) == 0 +} + +func creationAuthoritySourceMatches(authority CreationAuthority, instance plugininstances.Instance, request PublishRequest) bool { + return request.SourceActorKind == "agent" && request.SourceUserID == authority.OwnerUserID && request.SourceTaskID == instance.TaskID && request.SourceSessionID == authority.CreatingSessionID +} + func grantCovers(permission, scope string, grants []plugininstances.Grant) bool { parts := strings.SplitN(permission, ":", 2) kind, resource := parts[0], "" diff --git a/apps/backend/internal/canvas/authoring_test.go b/apps/backend/internal/canvas/authoring_test.go index 326625b503..c382a23ec2 100644 --- a/apps/backend/internal/canvas/authoring_test.go +++ b/apps/backend/internal/canvas/authoring_test.go @@ -67,6 +67,358 @@ func TestPublishPackageFirstReleaseRequiresMatchingGrants(t *testing.T) { } } +func TestCanvasCreationAuthorityFirstPublish(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Owner-authorized canvas", + CreatedBySessionID: "session-1", + OwnerUserID: "owner-1", + }) + + authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID) + if err != nil { + t.Fatalf("get creation authority: %v", err) + } + instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + pkg := testCanvasPackage("owner-first", []string{"tasks"}) + pkg.Manifest.Capabilities.APIWrite = []string{"messages"} + pkg.Manifest.Capabilities.Events = []string{"task.updated"} + pkg.Manifest.Capabilities.State = true + pkg.Manifest.UI.WebApps[0].NetworkOrigins = []string{"https://api.example.test"} + result, err := service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: created.ID, + Package: pkg, + Artifact: webapp.Artifact{Digest: "owner-first", RelativePath: "releases/owner-first", Bytes: 1}, + ExpectedAuthority: instance.PublishAuthority(), + SourceActorKind: "agent", + SourceUserID: "owner-1", + SourceTaskID: "task-1", + SourceSessionID: "session-1", + }) + if err != nil { + t.Fatalf("publish owner-authorized canvas: %v", err) + } + if !result.Activated || result.PermissionRequired { + t.Fatalf("publish result = %+v, want activated without review", result) + } + + updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get updated instance: %v", err) + } + if updated.ActiveReleaseID != result.Release.ID || updated.PluginID != result.Release.PluginID { + t.Fatalf("updated instance = %+v, want active release and package identity", updated) + } + grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("list initial grants: %v", err) + } + if len(grants) != 5 { + t.Fatalf("initial grants = %+v, want five declared grants", grants) + } + wantGrants := map[string]bool{ + "api_read:tasks": false, + "api_write:messages": false, + "events:task.updated": false, + "state:": false, + "network:https://api.example.test": false, + } + for _, grant := range grants { + key := grant.PermissionKind + ":" + if grant.PermissionKind == "api_read" || grant.PermissionKind == "api_write" || grant.PermissionKind == "events" { + key += grant.Resource + } + if grant.PermissionKind == "network" { + key += grant.NetworkOrigin + } + if _, ok := wantGrants[key]; !ok || grant.ScopeCeiling != ScopeTask || grant.ApprovedBy != "owner-1" { + t.Fatalf("initial grant = %+v, want task-scoped owner grant", grant) + } + wantGrants[key] = true + } + for key, found := range wantGrants { + if !found { + t.Fatalf("initial grant %q missing from %+v", key, grants) + } + } + + consumed, err := service.repo.GetCreationAuthority(context.Background(), created.ID) + if err != nil { + t.Fatalf("get consumed authority: %v", err) + } + if consumed.PolicyVersion != authority.PolicyVersion || consumed.ConsumedAt.IsZero() { + t.Fatalf("consumed authority = %+v, want policy %d and consumed timestamp", consumed, authority.PolicyVersion) + } +} + +func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Mismatched owner", + CreatedBySessionID: "session-1", + OwnerUserID: "owner-1", + }) + instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + result, err := service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: created.ID, + Package: testCanvasPackage("mismatched-owner", []string{"tasks"}), + Artifact: webapp.Artifact{Digest: "mismatched-owner", RelativePath: "releases/mismatched-owner", Bytes: 1}, + ExpectedAuthority: instance.PublishAuthority(), + SourceActorKind: "agent", + SourceUserID: "foreign-owner", + SourceTaskID: "task-1", + SourceSessionID: "session-1", + }) + if err != nil { + t.Fatalf("publish mismatched owner: %v", err) + } + if result.Activated || !result.PermissionRequired { + t.Fatalf("mismatched owner result = %+v, want manual review", result) + } + grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("list mismatched-owner grants: %v", err) + } + if len(grants) != 0 { + t.Fatalf("mismatched-owner grants = %+v, want none", grants) + } + authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID) + if err != nil { + t.Fatalf("get unconsumed authority: %v", err) + } + if !authority.ConsumedAt.IsZero() { + t.Fatalf("mismatched owner consumed authority at %s", authority.ConsumedAt) + } +} + +func TestCanvasCreationAuthorityDoesNotApproveLaterIncrease(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Later review", + CreatedBySessionID: "session-1", + OwnerUserID: "owner-1", + }) + firstInstance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get first instance: %v", err) + } + first, err := service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: created.ID, + Package: testCanvasPackage("owner-base", []string{"tasks"}), + Artifact: webapp.Artifact{Digest: "owner-base", RelativePath: "releases/owner-base", Bytes: 1}, + ExpectedAuthority: firstInstance.PublishAuthority(), + SourceActorKind: "agent", + SourceUserID: "owner-1", + SourceTaskID: "task-1", + SourceSessionID: "session-1", + }) + if err != nil { + t.Fatalf("publish first owner release: %v", err) + } + if !first.Activated { + t.Fatalf("first owner release = %+v, want active", first) + } + + secondInstance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get second instance: %v", err) + } + secondPackage := testCanvasPackage("owner-increase", []string{"tasks", "workflows"}) + second, err := service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: created.ID, + Package: secondPackage, + Artifact: webapp.Artifact{Digest: "owner-increase", RelativePath: "releases/owner-increase", Bytes: 1}, + ExpectedAuthority: secondInstance.PublishAuthority(), + SourceActorKind: "agent", + SourceUserID: "owner-1", + SourceTaskID: "task-1", + SourceSessionID: "session-1", + }) + if err != nil { + t.Fatalf("publish later owner release: %v", err) + } + if second.Activated || !second.PermissionRequired { + t.Fatalf("later owner release = %+v, want pending review", second) + } + current, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get current instance: %v", err) + } + if current.ActiveReleaseID != first.Release.ID { + t.Fatalf("active release after later increase = %q, want %q", current.ActiveReleaseID, first.Release.ID) + } +} + +func TestCanvasCreationAuthorityRejectsUnsupportedCapabilities(t *testing.T) { + tests := []struct { + name string + digest string + setup func(*manifest.Manifest) + }{ + { + name: "unknown read", + digest: "unsupported-unknown-read", + setup: func(m *manifest.Manifest) { + m.Capabilities.APIRead = []string{"sessions"} + }, + }, + { + name: "wildcard event", + digest: "unsupported-wildcard-event", + setup: func(m *manifest.Manifest) { + m.Capabilities.Events = []string{"task.*"} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Unsupported capability", + CreatedBySessionID: "session-1", + OwnerUserID: "owner-1", + }) + instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + pkg := testCanvasPackage(tt.digest, nil) + tt.setup(pkg.Manifest) + _, err = service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: created.ID, + Package: pkg, + Artifact: webapp.Artifact{Digest: pkg.Digest, RelativePath: "releases/" + pkg.Digest, Bytes: 1}, + ExpectedAuthority: instance.PublishAuthority(), + SourceActorKind: "agent", + SourceUserID: "owner-1", + SourceTaskID: "task-1", + SourceSessionID: "session-1", + }) + if !errors.Is(err, ErrInvalidCanvas) { + t.Fatalf("publish error = %v, want ErrInvalidCanvas", err) + } + }) + } +} + +func TestCanvasCreationAuthorityRechecksWorkspaceOwner(t *testing.T) { + service, instanceStore, pool := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Transferred workspace", + CreatedBySessionID: "session-1", + OwnerUserID: "owner-1", + }) + instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + if _, err := pool.Writer().Exec(`UPDATE workspaces SET owner_id = 'owner-2' WHERE id = 'workspace-1'`); err != nil { + t.Fatalf("transfer workspace owner: %v", err) + } + + _, err = service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: created.ID, + Package: testCanvasPackage("transferred-workspace", []string{"tasks"}), + Artifact: webapp.Artifact{Digest: "transferred-workspace", RelativePath: "releases/transferred-workspace", Bytes: 1}, + ExpectedAuthority: instance.PublishAuthority(), + SourceActorKind: "agent", + SourceUserID: "owner-1", + SourceTaskID: "task-1", + SourceSessionID: "session-1", + }) + if !errors.Is(err, ErrStaleCanvasPublish) { + t.Fatalf("publish after workspace transfer = %v, want ErrStaleCanvasPublish", err) + } + grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("list grants after workspace transfer: %v", err) + } + if len(grants) != 0 { + t.Fatalf("grants after workspace transfer = %+v, want none", grants) + } + authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID) + if err != nil { + t.Fatalf("get authority after workspace transfer: %v", err) + } + if !authority.ConsumedAt.IsZero() { + t.Fatalf("authority after workspace transfer consumed at %s", authority.ConsumedAt) + } +} + +func TestCanvasCreationAuthorityRollsBackOnActivationFailure(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Rollback authority", + CreatedBySessionID: "session-1", + OwnerUserID: "owner-1", + }) + instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + service.instances = &activationFailureStore{Store: instanceStore} + _, err = service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: created.ID, + Package: testCanvasPackage("rollback-authority", []string{"tasks"}), + Artifact: webapp.Artifact{Digest: "rollback-authority", RelativePath: "releases/rollback-authority", Bytes: 1}, + ExpectedAuthority: instance.PublishAuthority(), + SourceActorKind: "agent", + SourceUserID: "owner-1", + SourceTaskID: "task-1", + SourceSessionID: "session-1", + }) + if err == nil { + t.Fatal("publish with activation failure succeeded, want rollback") + } + releases, err := instanceStore.ListReleases(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("list releases after rollback: %v", err) + } + if len(releases) != 0 { + t.Fatalf("releases after rollback = %+v, want none", releases) + } + grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("list grants after rollback: %v", err) + } + if len(grants) != 0 { + t.Fatalf("grants after rollback = %+v, want none", grants) + } + current, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance after rollback: %v", err) + } + if current.ActiveReleaseID != "" || current.PluginID != CanvasPluginID { + t.Fatalf("instance after rollback = %+v, want pending synthetic instance", current) + } + authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID) + if err != nil { + t.Fatalf("get authority after rollback: %v", err) + } + if !authority.ConsumedAt.IsZero() { + t.Fatalf("authority after rollback consumed at %s", authority.ConsumedAt) + } +} + func TestFirstTaskReleaseCanBeReviewedAndApproved(t *testing.T) { service, instanceStore, _ := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ @@ -450,6 +802,14 @@ type publishTransactionBarrier struct { once sync.Once } +type activationFailureStore struct { + *plugininstances.Store +} + +func (s *activationFailureStore) ActivateReleaseTx(context.Context, *sqlx.Tx, string, string) error { + return errors.New("injected activation failure") +} + func (s *publishTransactionBarrier) WithTransaction(ctx context.Context, fn func(*sqlx.Tx) error) error { first := false s.once.Do(func() { diff --git a/apps/backend/internal/canvas/canvas_test.go b/apps/backend/internal/canvas/canvas_test.go index e0d73e6cc3..b59c8250f0 100644 --- a/apps/backend/internal/canvas/canvas_test.go +++ b/apps/backend/internal/canvas/canvas_test.go @@ -41,6 +41,12 @@ func newCanvasService(t *testing.T) (*Service, *plugininstances.Store, *db.Pool) if err != nil { t.Fatalf("new canvas repository: %v", err) } + if _, err := pool.Writer().Exec(`CREATE TABLE workspaces (id TEXT PRIMARY KEY, owner_id TEXT DEFAULT '')`); err != nil { + t.Fatalf("create workspace fixture: %v", err) + } + if _, err := pool.Writer().Exec(`INSERT INTO workspaces (id, owner_id) VALUES ('workspace-1', 'owner-1')`); err != nil { + t.Fatalf("insert workspace fixture: %v", err) + } return NewService(repo, instanceStore), instanceStore, pool } @@ -77,6 +83,26 @@ func TestGetExposesPendingFirstRelease(t *testing.T) { } } +func TestRemoveDeletesCreationAuthority(t *testing.T) { + service, _, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Authority cleanup", + CreatedBySessionID: "session-1", + OwnerUserID: "owner-1", + }) + if _, err := service.repo.GetCreationAuthority(context.Background(), created.ID); err != nil { + t.Fatalf("get creation authority before remove: %v", err) + } + if err := service.Remove(context.Background(), created.ID); err != nil { + t.Fatalf("remove canvas: %v", err) + } + if _, err := service.repo.GetCreationAuthority(context.Background(), created.ID); !errors.Is(err, ErrCreationAuthorityNotFound) { + t.Fatalf("get creation authority after remove = %v, want ErrCreationAuthorityNotFound", err) + } +} + func TestPendingReleaseLeavesRuntimeIdentityAndGenerationUntouched(t *testing.T) { service, store, _ := newCanvasService(t) created := createCanvas(t, service, CreateCanvasRequest{ diff --git a/apps/backend/internal/canvas/repository.go b/apps/backend/internal/canvas/repository.go index 644103dcc3..aa93c25dd7 100644 --- a/apps/backend/internal/canvas/repository.go +++ b/apps/backend/internal/canvas/repository.go @@ -42,6 +42,15 @@ CREATE INDEX IF NOT EXISTS idx_canvas_lifecycle_workspace ON canvas_lifecycle_metadata(workspace_id, updated_at, id); CREATE INDEX IF NOT EXISTS idx_canvas_lifecycle_task ON canvas_lifecycle_metadata(task_id, updated_at, id); +CREATE TABLE IF NOT EXISTS canvas_creation_authority ( + canvas_id TEXT PRIMARY KEY, + owner_user_id TEXT NOT NULL, + creating_session_id TEXT NOT NULL, + policy_version INTEGER NOT NULL, + consumed_at TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_canvas_creation_authority_owner + ON canvas_creation_authority(owner_user_id, consumed_at); CREATE TABLE IF NOT EXISTS canvas_lifecycle_admission ( id INTEGER PRIMARY KEY, version INTEGER NOT NULL @@ -148,6 +157,85 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), if err != nil { return err } + if metadata.CreationOwnerUserID != "" { + if metadata.TaskID == "" || metadata.CreatedBySessionID == "" { + return ErrInvalidCanvas + } + if _, err := tx.ExecContext(ctx, tx.Rebind( + `INSERT INTO canvas_creation_authority (canvas_id, owner_user_id, creating_session_id, policy_version, consumed_at) VALUES (?, ?, ?, ?, '')`, + ), metadata.ID, metadata.CreationOwnerUserID, metadata.CreatedBySessionID, CreationAuthorityPolicyVersion); err != nil { + return err + } + } + return nil +} + +// GetCreationAuthority returns the recorded first-publication authority. A +// missing row is expected for legacy drafts and imported instances. +func (r *Repository) GetCreationAuthority(ctx context.Context, canvasID string) (CreationAuthority, error) { + var row struct { + CanvasID string `db:"canvas_id"` + OwnerUserID string `db:"owner_user_id"` + CreatingSessionID string `db:"creating_session_id"` + TaskID string `db:"task_id"` + PolicyVersion int `db:"policy_version"` + ConsumedAt string `db:"consumed_at"` + } + err := r.ro.GetContext(ctx, &row, r.ro.Rebind( + `SELECT a.canvas_id, a.owner_user_id, a.creating_session_id, m.task_id, a.policy_version, a.consumed_at +FROM canvas_creation_authority a +JOIN canvas_lifecycle_metadata m ON m.id = a.canvas_id +WHERE a.canvas_id = ?`, + ), canvasID) + if errors.Is(err, sql.ErrNoRows) { + return CreationAuthority{}, ErrCreationAuthorityNotFound + } + if err != nil { + return CreationAuthority{}, err + } + consumedAt, err := parseOptionalTime(row.ConsumedAt) + if err != nil { + return CreationAuthority{}, fmt.Errorf("canvas: parse creation authority consumed_at: %w", err) + } + result := CreationAuthority{ + CanvasID: row.CanvasID, OwnerUserID: row.OwnerUserID, + CreatingSessionID: row.CreatingSessionID, TaskID: row.TaskID, + PolicyVersion: row.PolicyVersion, + } + if consumedAt != nil { + result.ConsumedAt = *consumedAt + } + return result, nil +} + +// ConsumeCreationAuthorityTx atomically consumes the single-use authority +// while rechecking its owner, session, task, policy, task scope, and current +// workspace ownership. +func (r *Repository) ConsumeCreationAuthorityTx(ctx context.Context, tx *sqlx.Tx, authority CreationAuthority, ownerUserID, sessionID, taskID string, allowUnownedWorkspace bool) error { + if authority.CanvasID == "" || ownerUserID == "" || sessionID == "" || taskID == "" { + return ErrStaleCanvasPublish + } + now := time.Now().UTC().Format(time.RFC3339Nano) + result, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE canvas_creation_authority +SET consumed_at = ? +WHERE canvas_creation_authority.canvas_id = ? AND owner_user_id = ? AND creating_session_id = ? + AND policy_version = ? AND consumed_at = '' + AND EXISTS ( + SELECT 1 + FROM canvas_lifecycle_metadata m + JOIN workspaces w ON w.id = m.workspace_id + WHERE m.id = canvas_creation_authority.canvas_id + AND m.task_id = ? + AND (w.owner_id = ? OR (? AND COALESCE(w.owner_id, '') = '')) + )`, + ), now, authority.CanvasID, ownerUserID, sessionID, CreationAuthorityPolicyVersion, taskID, ownerUserID, allowUnownedWorkspace) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrStaleCanvasPublish + } return nil } @@ -256,6 +344,10 @@ func (r *Repository) Delete(ctx context.Context, id string) error { // DeleteTx removes metadata in an existing lifecycle transaction. func (r *Repository) DeleteTx(ctx context.Context, tx *sqlx.Tx, id string) error { + if _, err := tx.ExecContext(ctx, tx.Rebind( + `DELETE FROM canvas_creation_authority WHERE canvas_id = ?`), id); err != nil { + return err + } result, err := tx.ExecContext(ctx, tx.Rebind( `DELETE FROM canvas_lifecycle_metadata WHERE id = ?`), id) if err != nil { diff --git a/apps/backend/internal/canvas/service.go b/apps/backend/internal/canvas/service.go index 14a2f886c2..dcbb61379e 100644 --- a/apps/backend/internal/canvas/service.go +++ b/apps/backend/internal/canvas/service.go @@ -96,15 +96,16 @@ func (s *Service) createLocked(ctx context.Context, request CreateCanvasRequest) scopeKind = ScopeTask } metadata := CanvasMetadata{ - ID: canvasID, - PluginInstanceID: instanceID, - WorkspaceID: request.WorkspaceID, - TaskID: request.TaskID, - OriginTaskID: request.OriginTaskID, - Title: request.Title, - CreatedBySessionID: request.CreatedBySessionID, - CreatedAt: now, - UpdatedAt: now, + ID: canvasID, + PluginInstanceID: instanceID, + WorkspaceID: request.WorkspaceID, + TaskID: request.TaskID, + OriginTaskID: request.OriginTaskID, + Title: request.Title, + CreatedBySessionID: request.CreatedBySessionID, + CreationOwnerUserID: request.OwnerUserID, + CreatedAt: now, + UpdatedAt: now, } instance := plugininstances.Instance{ ID: instanceID, @@ -772,6 +773,7 @@ func normalizeCreateRequest(request CreateCanvasRequest) (CreateCanvasRequest, e request.TaskID = strings.TrimSpace(request.TaskID) request.OriginTaskID = strings.TrimSpace(request.OriginTaskID) request.CreatedBySessionID = strings.TrimSpace(request.CreatedBySessionID) + request.OwnerUserID = strings.TrimSpace(request.OwnerUserID) request.PluginID = strings.TrimSpace(request.PluginID) request.Title = strings.TrimSpace(request.Title) if request.PluginID == "" { diff --git a/apps/backend/internal/canvas/types.go b/apps/backend/internal/canvas/types.go index 3f319733b1..ea128f3dc1 100644 --- a/apps/backend/internal/canvas/types.go +++ b/apps/backend/internal/canvas/types.go @@ -16,8 +16,9 @@ import ( const ( // CanvasPluginID identifies the synthetic plugin binding used by local // canvas instances. The package identity remains in the release metadata. - CanvasPluginID = "kandev.canvas" - MaxTitleLength = 200 + CanvasPluginID = "kandev.canvas" + MaxTitleLength = 200 + CreationAuthorityPolicyVersion = 1 MaxTaskCanvases = plugininstances.MaxTaskInstances MaxWorkspaceCanvases = plugininstances.MaxWorkspaceInstances @@ -46,6 +47,11 @@ const ( permissionKindEvents = "events" permissionKindNetwork = "network" permissionKindState = "state" + + canvasCapabilityTasks = "tasks" + canvasCapabilityWorkflows = "workflows" + canvasCapabilityMessages = "messages" + canvasCapabilityTaskUpdate = "task.updated" ) var ( @@ -61,8 +67,9 @@ var ( // These aliases preserve the stable plugin admission errors while keeping // the canvas package convenient for service callers and API adapters. - ErrTaskCanvasLimit = plugininstances.ErrTaskCanvasLimit - ErrWorkspaceCanvasLimit = plugininstances.ErrWorkspaceCanvasLimit + ErrTaskCanvasLimit = plugininstances.ErrTaskCanvasLimit + ErrWorkspaceCanvasLimit = plugininstances.ErrWorkspaceCanvasLimit + ErrCreationAuthorityNotFound = errors.New("canvas creation authority not found") ) // CreateCanvasRequest contains trusted scope metadata supplied by the caller. @@ -74,7 +81,21 @@ type CreateCanvasRequest struct { OriginTaskID string `json:"origin_task_id,omitempty"` Title string `json:"title"` CreatedBySessionID string `json:"created_by_session_id,omitempty"` - PluginID string `json:"plugin_id,omitempty"` + // OwnerUserID is populated only by the trusted task authoring adapter. It + // records the owner who may authorize the first publication. + OwnerUserID string `json:"-"` + PluginID string `json:"plugin_id,omitempty"` +} + +// CreationAuthority is the durable, single-use permission to publish the +// first release of a newly created task canvas. +type CreationAuthority struct { + CanvasID string + OwnerUserID string + CreatingSessionID string + TaskID string + PolicyVersion int + ConsumedAt time.Time } // Canvas is the lifecycle projection consumed by host, API, and event layers. @@ -135,17 +156,18 @@ type GrantProjection struct { // CanvasMetadata is the canvas-owned half of a lifecycle record. The plugin // instance table is intentionally not duplicated here. type CanvasMetadata struct { - ID string `json:"id"` - PluginInstanceID string `json:"plugin_instance_id"` - WorkspaceID string `json:"workspace_id"` - TaskID string `json:"task_id,omitempty"` - OriginTaskID string `json:"origin_task_id,omitempty"` - Title string `json:"title"` - CreatedBySessionID string `json:"created_by_session_id,omitempty"` - PromotedByUserID string `json:"promoted_by_user_id,omitempty"` - PromotedAt *time.Time `json:"promoted_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + PluginInstanceID string `json:"plugin_instance_id"` + WorkspaceID string `json:"workspace_id"` + TaskID string `json:"task_id,omitempty"` + OriginTaskID string `json:"origin_task_id,omitempty"` + Title string `json:"title"` + CreatedBySessionID string `json:"created_by_session_id,omitempty"` + CreationOwnerUserID string `json:"-"` + PromotedByUserID string `json:"promoted_by_user_id,omitempty"` + PromotedAt *time.Time `json:"promoted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // PluginInstanceStore is the small adapter surface used by the lifecycle diff --git a/apps/backend/internal/mcp/canvasskill/files/SKILL.md b/apps/backend/internal/mcp/canvasskill/files/SKILL.md index b0ed507e7a..aaa793c10b 100644 --- a/apps/backend/internal/mcp/canvasskill/files/SKILL.md +++ b/apps/backend/internal/mcp/canvasskill/files/SKILL.md @@ -16,7 +16,7 @@ core bundle again during the same authoring task. 1. Call `create_canvas_kandev` with a short title and an application summary. It creates an inactive task canvas and returns its source directory, - manifest scaffold, permission ceiling, and exact scaffold inventory. + manifest scaffold, initial permission policy, and exact scaffold inventory. 2. Use native file tools in that returned directory. The initial files are `manifest.yaml`, `index.html`, `appearance.js`, `script.js`, and `styles.css`. Replace or extend them in the same directory. @@ -27,6 +27,13 @@ core bundle again during the same authoring task. ID and source path. Read validation diagnostics and correct rejected source before publishing again. +The first valid release of a new owner-created task canvas uses the returned +initial permission policy. It can activate without a second approval for its +declared supported task-scoped data, event, state, and exact HTTPS-origin +permissions. A later permission increase, imported package, or workspace +promotion still requires human review. Do not add a trust flag to the +manifest, and do not request permissions outside the policy. + ## Core application contract - Include ``. @@ -42,14 +49,22 @@ core bundle again during the same authoring task. explicit and explain their result. - Use accessible labels, keyboard operation, visible focus, and touch targets. +Kandev injects a reserved startup bootstrap into the entry document before +authored scripts. It reports early document errors and checks the relative +context route after document load. The host reveals the frame only after a +versioned acknowledgement for the current attempt. A missing acknowledgement +or context failure becomes recoverable after 15 seconds. Keep the entry valid +HTML and render loading, empty, error, and retry states in the app. + ## Minimal manifest Use the returned `manifest_scaffold` as the starting point. New manifests use `api_version: 2`, one lowercase web-app key, a package-relative `entry`, and at least one `task-canvas` or `workspace-canvas` placement. Declare only the `api_read`, `api_write`, `events`, `state`, and `network_origins` permissions -that the application needs. The entry and all relative assets must be in the -published package. +that the application needs. The owner-authorized first release can receive +only these supported task-scoped grants. The entry and all relative assets +must be in the published package. ## Browser protocol summary diff --git a/apps/backend/internal/mcp/canvasskill/files/references/manifest.md b/apps/backend/internal/mcp/canvasskill/files/references/manifest.md index bb7e6747f4..7c308cd734 100644 --- a/apps/backend/internal/mcp/canvasskill/files/references/manifest.md +++ b/apps/backend/internal/mcp/canvasskill/files/references/manifest.md @@ -50,18 +50,27 @@ contain `ui/index.html`, `ui/app.js`, and `ui/app.css`; references such as symlinks, or a path below `_kandev`. `network_origins` contains exact HTTPS origins only. Do not add a path, -wildcard, credentials, query, or fragment. Each origin also needs an approved -grant before the release can run. Network requests go directly from the -sandboxed browser to the approved origin. If a release, grant, archive, or +wildcard, credentials, query, or fragment. A new owner-created task canvas can +receive its declared exact origins in its first release through the initial +permission policy. Imported packages and later permission increases need an +approved grant before the release can run. Network requests go directly from +the sandboxed browser to the approved origin. If a release, grant, archive, or canvas authority changes, the host immediately tears down the old iframe, so the old direct requests cannot continue under the old binding. Capabilities are declarations, not grants. Use `api_read` for `tasks` and `workflows`, `api_write` for `tasks` and `messages`, `events` for event -subscriptions, and `state: true` for instance state. The operator reviews the -declaration before the first release or any release that needs new grants. -Request only the capabilities used by the application. +subscriptions, and `state: true` for instance state. The owner-authorized +first release can receive only these supported task-scoped grants. The +operator reviews a later permission increase, an imported package, or a +workspace promotion. Request only the capabilities used by the application. The package must include the declared entry document and every local asset it references. Bundle executable dependencies. A build tool, package manager, or network build step is not available when the canvas runs. + +The host injects a reserved startup bootstrap into the entry document before +authored scripts. It checks document startup and relative context access. Keep +the entry valid HTML so the host can insert the bootstrap without changing the +stored package. The host waits up to 15 seconds for the startup acknowledgement +and exposes retry controls outside the frame when startup fails. diff --git a/apps/backend/internal/persistence/requiredstores/catalog.go b/apps/backend/internal/persistence/requiredstores/catalog.go index c4dc43dc29..deceeb341c 100644 --- a/apps/backend/internal/persistence/requiredstores/catalog.go +++ b/apps/backend/internal/persistence/requiredstores/catalog.go @@ -70,7 +70,7 @@ var catalog = []Descriptor{ {ID: "plugin-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_state"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, {ID: "plugin-instance-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_instance_state"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, {ID: "plugin-user-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_user_state"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "canvas", OwnerPackage: "internal/canvas", RequiredTables: []string{"canvas_lifecycle_metadata"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "canvas", OwnerPackage: "internal/canvas", RequiredTables: []string{"canvas_lifecycle_metadata", "canvas_creation_authority"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, {ID: "github", OwnerPackage: "internal/github", RequiredTables: []string{"github_pr_watches"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, {ID: "gitlab", OwnerPackage: "internal/gitlab", RequiredTables: []string{"gitlab_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, {ID: "jira", OwnerPackage: "internal/jira", RequiredTables: []string{"jira_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, diff --git a/apps/backend/internal/plugins/instances/store.go b/apps/backend/internal/plugins/instances/store.go index 2f35c6a292..72d6b1a20a 100644 --- a/apps/backend/internal/plugins/instances/store.go +++ b/apps/backend/internal/plugins/instances/store.go @@ -1651,6 +1651,35 @@ func (s *Store) AddGrant(ctx context.Context, grant Grant) error { return tx.Commit() } +// AddInitialGrantsTx inserts the exact grants delegated by a trusted local +// canvas creation. It does not advance grant_generation because SetPluginIDTx +// increments it before the public ActivateReleaseTx call in the same +// activation transaction. The empty-grant case is valid. +func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, approvedBy string, grants []Grant) error { + if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(approvedBy) == "" { + return ErrInvalidScope + } + var exists int + if err := tx.GetContext(ctx, &exists, tx.Rebind( + `SELECT COUNT(*) FROM plugin_instances WHERE id = ? AND status <> ?`, + ), instanceID, StatusRemoved); err != nil { + return err + } + if exists == 0 { + return ErrNotFound + } + var existing int + if err := tx.GetContext(ctx, &existing, tx.Rebind( + `SELECT COUNT(*) FROM plugin_instance_grants WHERE plugin_instance_id = ?`, + ), instanceID); err != nil { + return err + } + if existing != 0 { + return ErrStaleCanvasPublish + } + return insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants) +} + func (s *Store) ListGrants(ctx context.Context, instanceID string) ([]Grant, error) { rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT plugin_instance_id, permission_kind, resource, network_origin, scope_ceiling, approved_by, approved_at FROM plugin_instance_grants WHERE plugin_instance_id = ? ORDER BY permission_kind, resource, network_origin`), instanceID) if err != nil { diff --git a/apps/backend/internal/plugins/webapp/policy.go b/apps/backend/internal/plugins/webapp/policy.go index 2f6bb0e82e..f4555633e2 100644 --- a/apps/backend/internal/plugins/webapp/policy.go +++ b/apps/backend/internal/plugins/webapp/policy.go @@ -41,6 +41,7 @@ func BuildContentSecurityPolicy(networkOrigins, frameAncestors []string) (string if err != nil { return "", err } + frames = append([]string{"'self'"}, frames...) connect := append([]string{"'self'"}, network...) images := append([]string{"'self'", "data:"}, network...) fonts := append([]string{"'self'", "data:"}, network...) diff --git a/apps/backend/internal/plugins/webapp/policy_test.go b/apps/backend/internal/plugins/webapp/policy_test.go index 742abcc38f..60e0135d8e 100644 --- a/apps/backend/internal/plugins/webapp/policy_test.go +++ b/apps/backend/internal/plugins/webapp/policy_test.go @@ -18,7 +18,7 @@ func TestBuildContentSecurityPolicyIsGrantBoundAndOpaque(t *testing.T) { "object-src 'none'", "script-src 'self' 'unsafe-inline'", "connect-src 'self' https://api.example.com:443", - "frame-ancestors http://127.0.0.1:38429 tauri://localhost http://tauri.localhost", + "frame-ancestors 'self' http://127.0.0.1:38429 tauri://localhost http://tauri.localhost", } { if !strings.Contains(policy, required) { t.Fatalf("policy %q missing %q", policy, required) @@ -32,6 +32,16 @@ func TestBuildContentSecurityPolicyIsGrantBoundAndOpaque(t *testing.T) { } } +func TestBuildContentSecurityPolicyIncludesSelf(t *testing.T) { + policy, err := BuildContentSecurityPolicy(nil, []string{"http://127.0.0.1:38429"}) + if err != nil { + t.Fatalf("BuildContentSecurityPolicy() unexpected error: %v", err) + } + if !strings.Contains(policy, "frame-ancestors 'self' http://127.0.0.1:38429") { + t.Fatalf("policy %q does not include the host origin", policy) + } +} + func TestNormalizeNetworkOriginsRejectsNonHTTPSAndPaths(t *testing.T) { if _, err := NormalizeNetworkOrigins([]string{"http://example.com"}); err == nil { t.Fatal("NormalizeNetworkOrigins(http) succeeded, want error") diff --git a/apps/backend/internal/plugins/webapp/runtime.go b/apps/backend/internal/plugins/webapp/runtime.go index 3fabc60b12..f876a4ed15 100644 --- a/apps/backend/internal/plugins/webapp/runtime.go +++ b/apps/backend/internal/plugins/webapp/runtime.go @@ -108,6 +108,10 @@ func (rt *Runtime) Serve(w http.ResponseWriter, r *http.Request, token, requestP return } } + if requestPath == "_kandev/host-runtime.js" || requestPath == "/_kandev/host-runtime.js" { + rt.serveHostRuntime(w, r, binding) + return + } if protocolPath, ok := runtimeProtocolPath(requestPath); ok { if rt.protocol == nil { writeRuntimeError(w, http.StatusNotFound, ErrArtifactUnavailable) @@ -147,10 +151,36 @@ func (rt *Runtime) Serve(w http.ResponseWriter, r *http.Request, token, requestP contentType = "application/octet-stream" } w.Header().Set("Content-Type", contentType) - if stat, err := file.Stat(); err == nil { - w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) + data, err := io.ReadAll(io.LimitReader(file, MaxFileBytes+1)) + if err != nil { + writeRuntimeError(w, http.StatusNotFound, ErrArtifactUnavailable) + return } - _, _ = io.Copy(w, file) + if int64(len(data)) > MaxFileBytes { + writeRuntimeError(w, http.StatusNotFound, ErrRuntimeBootstrapUnavailable) + return + } + if name == strings.TrimPrefix(strings.TrimSpace(binding.Entry), "/") { + data, err = injectRuntimeBootstrap(data) + if err != nil { + writeRuntimeError(w, http.StatusNotFound, err) + return + } + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + _, _ = w.Write(data) +} + +func (rt *Runtime) serveHostRuntime(w http.ResponseWriter, r *http.Request, binding CapabilityBinding) { + policy, err := BuildContentSecurityPolicy(binding.NetworkOrigins, rt.frameAncestors) + if err != nil { + writeRuntimeError(w, http.StatusInternalServerError, err) + return + } + setRuntimeHeaders(w, policy, r.Header.Get("Origin")) + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(hostRuntimeBootstrap))) + _, _ = w.Write([]byte(hostRuntimeBootstrap)) } // Handler returns a standard-library handler bound to one capability token. @@ -238,6 +268,7 @@ func writeRuntimeError(w http.ResponseWriter, status int, err error) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Del("Content-Length") w.WriteHeader(status) _, _ = w.Write(body) } diff --git a/apps/backend/internal/plugins/webapp/runtime_bootstrap.go b/apps/backend/internal/plugins/webapp/runtime_bootstrap.go new file mode 100644 index 0000000000..b57bbdaa5b --- /dev/null +++ b/apps/backend/internal/plugins/webapp/runtime_bootstrap.go @@ -0,0 +1,164 @@ +package webapp + +import ( + "bytes" + "errors" + "io" + "strings" + + "golang.org/x/net/html" +) + +var ErrRuntimeBootstrapUnavailable = errors.New("webapp: runtime startup bootstrap unavailable") + +const hostRuntimeBootstrap = `(() => { + "use strict"; + const VERSION = 1; + const PROBE_TYPE = "kandev.web_app.startup_probe"; + const RESULT_TYPE = "kandev.web_app.startup_result"; + const MAX_NONCE_BYTES = 128; + let outcome = null; + let pendingNonce = null; + let outcomeSent = false; + + const sendOutcome = () => { + if (outcomeSent || !pendingNonce || !outcome || window.parent === window) return; + outcomeSent = true; + const message = { + type: RESULT_TYPE, + version: VERSION, + nonce: pendingNonce, + result: outcome.result, + }; + if (outcome.code) message.code = outcome.code; + window.parent.postMessage(message, "*"); // Sandboxed iframes have a null origin, so "*" is the only viable target. + }; + + const finish = (result, code) => { + if (outcome) return; + outcome = code ? { result, code } : { result }; + sendOutcome(); + }; + + const failDocument = () => finish("failed", "document_error"); + window.addEventListener("error", (event) => { + if (event.target !== window || event instanceof ErrorEvent) failDocument(); + }, true); + window.addEventListener("unhandledrejection", failDocument, true); + + window.addEventListener("message", (event) => { + if (event.source !== window.parent || outcomeSent) return; + const data = event.data; + if (!data || typeof data !== "object" || Array.isArray(data)) return; + if (data.type !== PROBE_TYPE || data.version !== VERSION) return; + if (typeof data.nonce !== "string" || data.nonce.length === 0 || data.nonce.length > MAX_NONCE_BYTES) return; + if (pendingNonce) return; + pendingNonce = data.nonce; + sendOutcome(); + }); + + const checkContext = () => { + if (outcome) return; + fetch("./_kandev/v1/context", { credentials: "omit", cache: "no-store" }) + .then((response) => { + if (!response.ok) throw new Error("context unavailable"); + return response.json(); + }) + .then(() => finish("ready")) + .catch(() => finish("failed", "context_unavailable")); + }; + + if (document.readyState === "complete") checkContext(); + else window.addEventListener("load", checkContext, { once: true }); +})(); +` + +const hostRuntimeBootstrapTag = `` + +func injectRuntimeBootstrap(entry []byte) ([]byte, error) { + if int64(len(entry)) > MaxFileBytes { + return nil, ErrRuntimeBootstrapUnavailable + } + if len(entry) == 0 { + return nil, ErrRuntimeBootstrapUnavailable + } + insertion, err := runtimeBootstrapInsertion(entry) + if err != nil { + return nil, err + } + result := make([]byte, 0, len(entry)+len(hostRuntimeBootstrapTag)) + result = append(result, entry[:insertion]...) + result = append(result, hostRuntimeBootstrapTag...) + result = append(result, entry[insertion:]...) + return result, nil +} + +func runtimeBootstrapInsertion(entry []byte) (int, error) { + + tokenizer := html.NewTokenizer(bytes.NewReader(entry)) + tokenizer.SetMaxBuf(len(entry) + 1) + position := 0 + insertion := -1 + fallback := -1 + for { + tokenType := tokenizer.Next() + raw := tokenizer.Raw() + if tokenType == html.ErrorToken { + if errors.Is(tokenizer.Err(), io.EOF) { + break + } + return -1, ErrRuntimeBootstrapUnavailable + } + if len(raw) == 0 { + return -1, ErrRuntimeBootstrapUnavailable + } + insertion, fallback = updateRuntimeBootstrapPosition(tokenizer, tokenType, raw, position, insertion, fallback) + position += len(raw) + } + if position != len(entry) { + return -1, ErrRuntimeBootstrapUnavailable + } + if insertion < 0 { + insertion = fallback + } + if insertion < 0 || insertion > len(entry) { + return -1, ErrRuntimeBootstrapUnavailable + } + return insertion, nil +} + +func updateRuntimeBootstrapPosition(tokenizer *html.Tokenizer, tokenType html.TokenType, raw []byte, position, insertion, fallback int) (int, int) { + name, hasTagName := runtimeBootstrapTagName(tokenizer, tokenType) + if !hasTagName { + return insertion, fallback + } + if tokenType == html.StartTagToken { + switch strings.ToLower(name) { + case "script": + if insertion < 0 { + insertion = position + } + case "head": + if fallback < 0 { + fallback = position + len(raw) + } + case "body": + if fallback < 0 { + fallback = position + } + } + return insertion, fallback + } + if fallback < 0 && (strings.EqualFold(name, "head") || strings.EqualFold(name, "body") || strings.EqualFold(name, "html")) { + fallback = position + } + return insertion, fallback +} + +func runtimeBootstrapTagName(tokenizer *html.Tokenizer, tokenType html.TokenType) (string, bool) { + if tokenType != html.StartTagToken && tokenType != html.EndTagToken { + return "", false + } + name, _ := tokenizer.TagName() + return string(name), true +} diff --git a/apps/backend/internal/plugins/webapp/runtime_test.go b/apps/backend/internal/plugins/webapp/runtime_test.go index c75a9502eb..714c89165a 100644 --- a/apps/backend/internal/plugins/webapp/runtime_test.go +++ b/apps/backend/internal/plugins/webapp/runtime_test.go @@ -3,9 +3,12 @@ package webapp import ( "bytes" "context" + "errors" + "fmt" "io" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -56,7 +59,7 @@ func TestRuntimeServesEntryWithSecurityHeadersAndNoCookies(t *testing.T) { if !strings.Contains(response.Body.String(), "safe") { t.Fatalf("body = %q", response.Body.String()) } - if got := response.Header().Get("Content-Security-Policy"); !strings.Contains(got, "sandbox allow-scripts allow-forms") || !strings.Contains(got, "form-action 'none'") || !strings.Contains(got, "frame-ancestors http://127.0.0.1:38429") { + if got := response.Header().Get("Content-Security-Policy"); !strings.Contains(got, "sandbox allow-scripts allow-forms") || !strings.Contains(got, "form-action 'none'") || !strings.Contains(got, "frame-ancestors 'self' http://127.0.0.1:38429") { t.Fatalf("CSP = %q", got) } for key, want := range map[string]string{ @@ -130,6 +133,122 @@ func TestRuntimeResolvesNestedEntryAssetsAndKeepsProtocolRoot(t *testing.T) { } } +func TestRuntimeStartupBootstrapPreservesArtifact(t *testing.T) { + entry := "\nready" + archive := canvasArchive(t, map[string]string{ + "manifest.yaml": staticManifestYAML, + "ui/index.html": entry, + "ui/app.js": "window.__authored = true;", + "_kandev/host-runtime.js": "window.__package_shadow = true;", + "ui/asset-without-bootstrap.js": "unchanged", + }) + pkg, err := ValidatePackage(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("ValidatePackage: %v", err) + } + artifacts, err := NewArtifactStore(filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatalf("NewArtifactStore: %v", err) + } + artifact, err := artifacts.Put(pkg) + if err != nil { + t.Fatalf("Put: %v", err) + } + manager := NewTokenManager(nil) + token, err := manager.Issue(CapabilityBinding{ + UserID: "user-1", InstanceID: "instance-1", ReleaseID: "release-1", WebAppKey: "main", + Placement: "task-canvas", Artifact: artifact, Entry: "ui/index.html", + }, 0) + if err != nil { + t.Fatalf("Issue: %v", err) + } + runtime := NewRuntime(manager, artifacts, nil, nil) + + response := httptest.NewRecorder() + runtime.Serve(response, httptest.NewRequest(http.MethodGet, "/", nil), token, "") + if response.Code != http.StatusOK { + t.Fatalf("entry status = %d, body = %s", response.Code, response.Body.String()) + } + body := response.Body.String() + bootstrap := `` + if !strings.Contains(body, bootstrap) { + t.Fatalf("entry body does not contain host bootstrap: %q", body) + } + if strings.Index(body, bootstrap) >= strings.Index(body, `