package backendapp import ( "context" "encoding/json" "errors" "fmt" "io" "path/filepath" "strings" "sync" "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" mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" "github.com/kandev/kandev/internal/plugins" "github.com/kandev/kandev/internal/plugins/instances" "github.com/kandev/kandev/internal/plugins/state" "github.com/kandev/kandev/internal/plugins/webapp" "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" userstore "github.com/kandev/kandev/internal/user/store" "go.uber.org/zap" ) const ( canvasSourceRootPrefix = ".kandev/canvases" canvasPublishWindow = 5 * time.Minute canvasPublishAttempts = 10 canvasSourceCleanupTimeout = 10 * time.Second canvasSourceActor = "agent" canvasEditOrigin = "canvas_edit" canvasErrorCodeDefault = "canvas_error" canvasErrorCodeInvalid = "invalid_canvas" canvasInvalidRelease = "invalid_release" // This key is written only by the authenticated canvas edit endpoint. MCP // canvas authorization treats it as trusted session state, never as tool // input or task metadata. canvasEditSessionTargetMetadataKey = "canvas_edit_target" ) type canvasEditSessionTarget struct { Origin string `json:"origin"` TaskID string `json:"task_id"` CanvasID string `json:"canvas_id"` ReleaseID string `json:"release_id"` } // canvasAuthoringService is the trusted bridge between the in-session MCP // handler and the canvas/domain services. It deliberately accepts the // execution context separately from all user-provided canvas fields. type canvasAuthoringService struct { canvases *canvas.Service plugins *plugins.Service tasks *taskservice.Service executions canvasExecutionResolver home string log *logger.Logger mu sync.Mutex attempts map[string][]time.Time inflight map[string]bool } var _ mcphandlers.CanvasAuthoringService = (*canvasAuthoringService)(nil) func newCanvasAuthoringService( canvases *canvas.Service, pluginsSvc *plugins.Service, tasks *taskservice.Service, executions canvasExecutionResolver, home string, log *logger.Logger, ) *canvasAuthoringService { return &canvasAuthoringService{ canvases: canvases, plugins: pluginsSvc, tasks: tasks, executions: executions, home: home, log: log, attempts: make(map[string][]time.Time), inflight: make(map[string]bool), } } func (s *canvasAuthoringService) ListCanvases(ctx context.Context, request mcphandlers.CanvasListRequest) (any, error) { _, task, err := s.resolveExecution(ctx, request.Agent) if err != nil { return nil, err } if target, editSession, err := s.canvasEditTarget(ctx, task, request.Agent); err != nil { return nil, canvasOperationError("execution_invalid", "the canvas edit session is no longer valid", err) } else if editSession { item, err := s.authorizedCanvasWithTarget(ctx, task, request.Agent, target, target.CanvasID) if err != nil { return nil, err } return map[string]any{"canvases": []canvas.Canvas{*item}}, nil } items, err := s.canvases.ListForTask(ctx, task.WorkspaceID, task.ID, false) if err != nil { return nil, canvasOperationError("list_failed", "canvas list is unavailable", err) } return map[string]any{"canvases": items}, nil } func (s *canvasAuthoringService) ReadCanvasAuthoringSkill(ctx context.Context, request mcphandlers.CanvasReadSkillRequest) (any, error) { if _, err := s.resolveExecutionOnly(ctx, request.Agent); err != nil { return nil, err } if err := canvasskill.EnsureMaterialized(s.home); err != nil { return nil, canvasOperationError("skill_unavailable", "canvas authoring skill is unavailable", err) } if request.Path == "" { bundle, err := canvasCoreBundle(s.home) if err != nil { return nil, canvasOperationError("skill_unavailable", "canvas authoring skill is unavailable", err) } return bundle, nil } content, err := canvasskill.ReadMaterialized(s.home, request.Path) if err != nil { return nil, canvasOperationError("skill_path_invalid", "canvas authoring skill path is not available", err) } return map[string]any{ "slug": canvasskill.Slug, "version": canvasskill.Version, "path": request.Path, "content": string(content), }, nil } func (s *canvasAuthoringService) CreateCanvas(ctx context.Context, request mcphandlers.CanvasCreateRequest) (any, error) { execution, task, err := s.resolveExecution(ctx, request.Agent) if err != nil { return nil, err } if _, editSession, err := s.canvasEditTarget(ctx, task, request.Agent); err != nil { return nil, canvasOperationError("execution_invalid", "the canvas edit session is no longer valid", err) } else if editSession { return nil, canvasOperationError("canvas_edit_restricted", "canvas edit sessions can only modify their target canvas", nil) } 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) } root := canvasSourceRoot(created.ID) client := execution.GetAgentCtlClient() if client == nil { _ = s.canvases.Remove(ctx, created.ID) return nil, canvasOperationError("agent_unavailable", "the task agent is not ready", errors.New("agentctl client is unavailable")) } if err := canvasskill.EnsureMaterialized(s.home); err != nil { _ = s.canvases.Remove(ctx, created.ID) return nil, canvasOperationError("skill_unavailable", "canvas authoring skill is unavailable", err) } scaffold, err := canvasScaffoldFiles(created, request.Summary) if err != nil { _ = s.canvases.Remove(ctx, created.ID) return nil, canvasOperationError("skill_unavailable", "canvas scaffold is unavailable", err) } // Stage the marker and every scaffold file under an authenticated agentctl // path, then rename the complete directory into its assigned root. This // keeps local, Docker, SSH, and other executors consistent and lets every // failure path remove the complete staged tree. if err := materializeCanvasScaffoldAtomically(ctx, client, created.ID, scaffold); err != nil { _ = s.canvases.Remove(ctx, created.ID) return nil, canvasOperationError("source_unavailable", "canvas scaffold could not be created", err) } return canvasCreateResponse(created, root, ownerID, scaffold), nil } func canvasCreateResponse(created *canvas.Canvas, root, ownerID string, scaffold []canvasScaffoldFile) map[string]any { return map[string]any{ "canvas": *created, "canvas_id": created.ID, "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", }, "manifest_scaffold": string(scaffold[0].Content), "scaffold_inventory": canvasskill.ScaffoldInventory(), } } func canvasCoreBundle(home string) (map[string]any, error) { files := make([]map[string]string, 0, len(canvasskill.CoreInventory())) for _, rel := range canvasskill.CoreInventory() { content, err := canvasskill.ReadMaterialized(home, rel) if err != nil { return nil, fmt.Errorf("read canvas core file %q: %w", rel, err) } files = append(files, map[string]string{"path": rel, "content": string(content)}) } if len(files) == 0 { return nil, errors.New("canvas core bundle is empty") } return map[string]any{ "slug": canvasskill.Slug, "version": canvasskill.Version, "path": "SKILL.md", "content": files[0]["content"], "inventory": canvasskill.Inventory(), "core_inventory": canvasskill.CoreInventory(), "scaffold_inventory": canvasskill.ScaffoldInventory(), "core": map[string]any{ "inventory": canvasskill.CoreInventory(), "files": files, }, }, nil } type canvasScaffoldFile struct { Path string Content []byte } func canvasScaffoldFiles(item *canvas.Canvas, summary string) ([]canvasScaffoldFile, error) { if item == nil { return nil, errors.New("canvas is required for scaffold generation") } manifest := []byte(canvasManifestScaffold(item, summary)) templates, err := canvasskill.ScaffoldTemplateFiles() if err != nil { return nil, err } files := make([]canvasScaffoldFile, 0, 1+len(templates)) files = append(files, canvasScaffoldFile{Path: "manifest.yaml", Content: manifest}) for _, template := range templates { files = append(files, canvasScaffoldFile{Path: template.Path, Content: template.Content}) } return files, nil } func materializeCanvasScaffold(ctx context.Context, client canvasAgentCtlClient, root string, files []canvasScaffoldFile) error { for _, file := range files { path := filepath.ToSlash(filepath.Join(root, file.Path)) if _, err := client.CreateFile(ctx, path, ""); err != nil { return fmt.Errorf("create %s: %w", file.Path, err) } content := string(file.Content) if _, err := client.ApplyFileDiff(ctx, path, canvasScaffoldDiff(path, file.Content), "", "", &content); err != nil { return fmt.Errorf("write %s: %w", file.Path, err) } } return nil } func materializeCanvasScaffoldAtomically(ctx context.Context, client canvasAgentCtlClient, canvasID string, files []canvasScaffoldFile) error { stagingRoot := canvasStagingRoot(canvasID) finalRoot := canvasSourceRoot(canvasID) cleanup := func() { cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), canvasSourceCleanupTimeout) defer cancel() // Delete both paths. A failed rename can leave the staging tree, while a // transport error after a successful rename can leave the final tree. // DeleteFile is authenticated by the agentctl session and safely ignores // whichever path was never created. for _, root := range []string{stagingRoot, finalRoot} { // Cleanup is best effort, but the original operation error remains // the actionable response for the authoring tool. _, _ = client.DeleteFile(cleanupCtx, root, "") } } marker := filepath.ToSlash(filepath.Join(stagingRoot, ".canvas-root")) if _, err := client.CreateFile(ctx, marker, ""); err != nil { cleanup() return fmt.Errorf("create canvas marker: %w", err) } if err := materializeCanvasScaffold(ctx, client, stagingRoot, files); err != nil { cleanup() return err } if _, err := client.RenameFile(ctx, stagingRoot, finalRoot, ""); err != nil { cleanup() return fmt.Errorf("activate canvas source directory: %w", err) } return nil } func canvasScaffoldDiff(path string, content []byte) string { value := string(content) hasFinalNewline := strings.HasSuffix(value, "\n") value = strings.TrimSuffix(value, "\n") lines := []string{} if value != "" { lines = strings.Split(value, "\n") } var diff strings.Builder if len(lines) == 0 { return fmt.Sprintf("--- %s\n+++ %s\n@@ -0,0 +0,0 @@\n", path, path) } fmt.Fprintf(&diff, "--- %s\n+++ %s\n@@ -0,0 +1,%d @@\n", path, path, len(lines)) for index, line := range lines { diff.WriteByte('+') diff.WriteString(line) diff.WriteByte('\n') if index == len(lines)-1 && !hasFinalNewline { diff.WriteString("\\ No newline at end of file\n") } } return diff.String() } func (s *canvasAuthoringService) GetCanvas(ctx context.Context, request mcphandlers.CanvasGetRequest) (any, error) { _, task, err := s.resolveExecution(ctx, request.Agent) if err != nil { return nil, err } item, err := s.authorizedCanvas(ctx, task, request.Agent, request.CanvasID) if err != nil { return nil, err } return item, nil } func (s *canvasAuthoringService) PublishCanvas(ctx context.Context, request mcphandlers.CanvasPublishRequest) (any, error) { execution, task, err := s.resolveExecution(ctx, request.Agent) if err != nil { return nil, err } target, editSession, err := s.canvasEditTarget(ctx, task, request.Agent) if err != nil { return nil, canvasOperationError("execution_invalid", "the canvas edit session is no longer valid", err) } item, err := s.authorizedCanvas(ctx, task, request.Agent, request.CanvasID) if err != nil { return nil, err } if err := validateCanvasPublishSource(item, request); err != nil { return nil, err } if err := s.beginPublish(request.Agent.SessionID, item.ID); err != nil { return nil, err } defer s.endPublish(request.Agent.SessionID, item.ID) expectedAuthority := canvasPublishAuthority(item) expectedBaseReleaseID := "" if editSession { expectedBaseReleaseID = target.ReleaseID } return s.publishCanvasSource(ctx, execution, task, item, request, expectedAuthority, expectedBaseReleaseID) } func canvasPublishAuthority(item *canvas.Canvas) instances.PublishAuthority { return instances.PublishAuthority{ InstanceID: item.PluginInstanceID, ScopeKind: item.ScopeKind, DataScopeKind: item.DataScopeKind, WorkspaceID: item.WorkspaceID, TaskID: item.TaskID, Status: item.Status, ActiveReleaseID: item.ActiveReleaseID, GrantGeneration: item.GrantGeneration, } } func validateCanvasPublishSource(item *canvas.Canvas, request mcphandlers.CanvasPublishRequest) error { if request.SourcePath != canvasSourceRoot(item.ID) { return canvasOperationError("invalid_source_root", "source_path must be the assigned canvas directory", nil) } return nil } func (s *canvasAuthoringService) publishCanvasSource(ctx context.Context, execution *canvasAgentExecution, task *models.Task, item *canvas.Canvas, request mcphandlers.CanvasPublishRequest, expectedAuthority instances.PublishAuthority, expectedBaseReleaseID string) (any, error) { client := execution.GetAgentCtlClient() if client == nil { return nil, canvasOperationError("agent_unavailable", "the task agent is not ready", nil) } artifacts := s.plugins.WebArtifacts() store := s.plugins.Instances() 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 // workspace and installation budget during transfer. reservation, err := store.ReserveBytes(ctx, item.WorkspaceID, webapp.MaxExpandedBytes, instances.WorkspaceArtifactLimitBytes, instances.InstallationArtifactLimitBytes) if err != nil { return nil, canvasOperationError(canvasErrorCode(err), "canvas release storage limit reached", err) } defer func() { _ = store.ReleaseBytes(context.WithoutCancel(ctx), reservation.ID) }() stream, err := client.StreamCanvasSource(ctx, canvasSourceRoot(item.ID)) if err != nil { return nil, canvasOperationError("source_unavailable", "canvas source could not be read", err) } if stream == nil { return nil, canvasOperationError("source_unavailable", "canvas source could not be read", errors.New("agentctl returned an empty source stream")) } defer func() { _ = stream.Close() }() pkg, err := readCanvasSourcePackage(stream) if err != nil { return nil, err } if err := store.ResizeReservation(ctx, reservation.ID, pkg.ExpandedBytes, instances.WorkspaceArtifactLimitBytes, instances.InstallationArtifactLimitBytes); err != nil { return nil, canvasOperationError(canvasErrorCode(err), "canvas release storage limit reached", err) } artifact, created, err := artifacts.PutWithCreated(pkg) if err != nil { return nil, canvasOperationError("artifact_unavailable", "canvas release could not be stored", err) } releasePersisted := false defer func() { if created && !releasePersisted { _ = artifacts.Remove(artifact) } }() result, err := s.canvases.PublishPackage(ctx, canvas.PublishRequest{ CanvasID: item.ID, Package: pkg, Artifact: artifact, ExpectedAuthority: expectedAuthority, ExpectedBaseReleaseID: expectedBaseReleaseID, SourceActorKind: canvasSourceActor, SourceUserID: ownerID, SourceUserSynthetic: ownerSynthetic, SourceTaskID: task.ID, SourceSessionID: request.Agent.SessionID, }) if result != nil && result.ReleasePersisted { releasePersisted = true } if err != nil { return nil, canvasOperationError(canvasErrorCode(err), "canvas release could not be published", err) } return map[string]any{ "canvas": *result.Canvas, "release": result.Release, "activated": result.Activated, "permission_required": result.PermissionRequired, }, nil } func readCanvasSourcePackage(stream io.Reader) (*webapp.Package, error) { counted := &canvasSourceCountingReader{reader: io.LimitReader(stream, int64(types.MaxCanvasSourceWireBytes)+1)} pkg, err := webapp.ValidateTarPackageWithLimits(counted, canvasSourcePackageLimits(), counted.bytes) if err != nil { if counted.bytes > int64(types.MaxCanvasSourceWireBytes) { return nil, canvasOperationError("source_limit_exceeded", "canvas source exceeds the transfer limit", nil) } return nil, canvasOperationError("invalid_release", "canvas source failed validation", err) } if counted.bytes > int64(types.MaxCanvasSourceWireBytes) { return nil, canvasOperationError("source_limit_exceeded", "canvas source exceeds the transfer limit", nil) } pkg.CompressedBytes = counted.bytes return pkg, nil } func (s *canvasAuthoringService) GetCanvasState(ctx context.Context, request mcphandlers.CanvasGetStateRequest) (any, error) { _, task, err := s.resolveExecution(ctx, request.Agent) if err != nil { return nil, err } item, err := s.authorizedCanvas(ctx, task, request.Agent, request.CanvasID) if err != nil { return nil, err } store := s.plugins.InstanceState() if store == nil { return nil, canvasOperationError("state_unavailable", "canvas state is unavailable", nil) } if request.Key == "" { entries, err := store.List(ctx, item.PluginInstanceID) if err != nil { return nil, canvasOperationError("state_unavailable", "canvas state is unavailable", err) } return map[string]any{"canvas_id": item.ID, "entries": entries}, nil } entry, found, err := store.Get(ctx, item.PluginInstanceID, request.Key) if err != nil { return nil, canvasOperationError("state_unavailable", "canvas state is unavailable", err) } if !found { return nil, canvasOperationError("state_not_found", "canvas state key was not found", nil) } return map[string]any{"canvas_id": item.ID, "entry": entry}, nil } func (s *canvasAuthoringService) SetCanvasState(ctx context.Context, request mcphandlers.CanvasSetStateRequest) (any, error) { _, task, err := s.resolveExecution(ctx, request.Agent) if err != nil { return nil, err } item, err := s.authorizedCanvas(ctx, task, request.Agent, request.CanvasID) if err != nil { return nil, err } store := s.plugins.InstanceState() if store == nil { return nil, canvasOperationError("state_unavailable", "canvas state is unavailable", nil) } entry, err := store.Set(ctx, item.PluginInstanceID, request.Key, request.Value, request.ExpectedRevision, "agent") if err != nil { var conflict *state.ConflictError if errors.As(err, &conflict) { return nil, &mcphandlers.CanvasAuthoringError{Code: "canvas_state_conflict", Message: "canvas state revision is stale", Details: map[string]interface{}{"current_revision": conflict.CurrentRevision}} } return nil, canvasOperationError("state_write_failed", "canvas state could not be written", err) } return map[string]any{"canvas_id": item.ID, "entry": entry}, nil } func (s *canvasAuthoringService) resolveExecution(ctx context.Context, agent mcphandlers.CanvasAgentContext) (*canvasAgentExecution, *models.Task, error) { execution, err := s.resolveExecutionOnly(ctx, agent) if err != nil { return nil, nil, err } if s.tasks == nil || agent.TaskID == "" { return nil, nil, canvasOperationError("execution_invalid", "canvas authoring requires a task execution", nil) } task, err := s.tasks.GetTask(ctx, agent.TaskID) if err != nil || task == nil || task.WorkspaceID == "" { return nil, nil, canvasOperationError("task_unavailable", "the task execution is no longer available", err) } return execution, task, nil } func (s *canvasAuthoringService) resolveExecutionOnly(_ context.Context, agent mcphandlers.CanvasAgentContext) (*canvasAgentExecution, error) { if s == nil || s.executions == nil || agent.ExecutionID == "" || agent.TaskID == "" || agent.SessionID == "" { return nil, canvasOperationError("execution_invalid", "canvas authoring requires a bound task execution", nil) } execution, err := s.executions.ResolveCanvasExecution(agent.ExecutionID) if err != nil || execution == nil || execution.TaskID != agent.TaskID || execution.SessionID != agent.SessionID { return nil, canvasOperationError("execution_invalid", "canvas authoring execution is not valid", nil) } return execution, nil } func (s *canvasAuthoringService) authorizedCanvas(ctx context.Context, task *models.Task, agent mcphandlers.CanvasAgentContext, canvasID string) (*canvas.Canvas, error) { target, editSession, err := s.canvasEditTarget(ctx, task, agent) if err != nil { return nil, canvasOperationError("execution_invalid", "the canvas edit session is no longer valid", err) } if editSession { return s.authorizedCanvasWithTarget(ctx, task, agent, target, canvasID) } return s.authorizedTaskCanvas(ctx, task, agent, canvasID) } func (s *canvasAuthoringService) authorizedCanvasWithTarget(ctx context.Context, task *models.Task, agent mcphandlers.CanvasAgentContext, target canvasEditSessionTarget, canvasID string) (*canvas.Canvas, error) { if !canvasEditTargetMatches(target, task.ID, canvasID) { return nil, canvasOperationError("canvas_not_found", "canvas was not found for this edit session", nil) } item, err := s.canvases.GetCanvas(ctx, canvasID) if err != nil || item == nil || item.WorkspaceID != task.WorkspaceID || item.TaskID != "" || item.ScopeKind != canvas.ScopeWorkspace { return nil, canvasOperationError("canvas_not_found", "canvas was not found for this edit session", nil) } return item, nil } func (s *canvasAuthoringService) authorizedTaskCanvas(ctx context.Context, task *models.Task, _ mcphandlers.CanvasAgentContext, canvasID string) (*canvas.Canvas, error) { if strings.TrimSpace(canvasID) == "" { return nil, canvasOperationError("canvas_not_found", "canvas was not found", nil) } item, err := s.canvases.GetCanvas(ctx, canvasID) if err != nil || !taskCanvasMatchesTask(item, task) { return nil, canvasOperationError("canvas_not_found", "canvas was not found for this task", nil) } return item, nil } func taskCanvasMatchesTask(item *canvas.Canvas, task *models.Task) bool { return item != nil && task != nil && item.WorkspaceID == task.WorkspaceID && item.TaskID == task.ID && item.ScopeKind == canvas.ScopeTask } // canvasEditTarget loads the server-written target binding for an edit // session. A present but malformed binding remains an edit session and is // therefore denied by the callers below; it must never fall back to the // broader task-canvas authorization path. func (s *canvasAuthoringService) canvasEditTarget(ctx context.Context, task *models.Task, agent mcphandlers.CanvasAgentContext) (canvasEditSessionTarget, bool, error) { if s.tasks == nil || task == nil || agent.SessionID == "" { return canvasEditSessionTarget{}, false, errors.New("canvas edit session context is unavailable") } session, err := s.tasks.GetTaskSession(ctx, agent.SessionID) if err != nil { return canvasEditSessionTarget{}, false, err } if session == nil || session.TaskID != task.ID { return canvasEditSessionTarget{}, false, errors.New("canvas edit session does not belong to task") } raw, present := session.Metadata[canvasEditSessionTargetMetadataKey] if !present { return canvasEditSessionTarget{}, false, nil } target, err := decodeCanvasEditSessionTarget(raw) if err != nil { if s.log != nil { s.log.Warn("malformed canvas edit session target", zap.String("session_id", agent.SessionID), zap.Error(err)) } return canvasEditSessionTarget{}, true, nil } return target, true, nil } func decodeCanvasEditSessionTarget(raw interface{}) (canvasEditSessionTarget, error) { data, err := json.Marshal(raw) if err != nil { return canvasEditSessionTarget{}, err } var target canvasEditSessionTarget if err := json.Unmarshal(data, &target); err != nil { return canvasEditSessionTarget{}, err } return target, nil } func canvasEditTargetMatches(target canvasEditSessionTarget, taskID, canvasID string) bool { return target.Origin == canvasEditOrigin && target.TaskID != "" && target.TaskID == taskID && target.CanvasID != "" && target.CanvasID == canvasID && target.ReleaseID != "" } 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") } 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 { now := time.Now() s.mu.Lock() defer s.mu.Unlock() if s.inflight[canvasID] { return canvasOperationError("publish_in_progress", "a canvas publish is already in progress", nil) } cutoff := now.Add(-canvasPublishWindow) window := s.attempts[sessionID][:0] for _, attempt := range s.attempts[sessionID] { if attempt.After(cutoff) { window = append(window, attempt) } } if len(window) >= canvasPublishAttempts { s.attempts[sessionID] = window return canvasOperationError("publish_rate_limited", "canvas publish rate limit exceeded", nil) } s.attempts[sessionID] = append(window, now) s.inflight[canvasID] = true return nil } func (s *canvasAuthoringService) endPublish(_ string, canvasID string) { s.mu.Lock() delete(s.inflight, canvasID) s.mu.Unlock() } func canvasSourceRoot(canvasID string) string { return filepath.ToSlash(filepath.Join(canvasSourceRootPrefix, canvasID)) } func canvasStagingRoot(canvasID string) string { return filepath.ToSlash(filepath.Join(canvasSourceRootPrefix, ".canvas-"+canvasID+".staging")) } func canvasSourcePackageLimits() webapp.Limits { limits := webapp.DefaultLimits() limits.MaxCompressedBytes = int64(types.MaxCanvasSourceWireBytes) limits.MaxExpandedBytes = int64(types.MaxCanvasSourceFileData) limits.MaxFiles = types.MaxCanvasSourceFiles return limits } func canvasManifestScaffold(item *canvas.Canvas, summary string) string { compactID := strings.ReplaceAll(item.ID, "-", "") if len(compactID) > 12 { compactID = compactID[:12] } if compactID == "" { compactID = "app" } id := "canvas-" + compactID title := strings.TrimSpace(item.Title) if title == "" { title = "Canvas" } return fmt.Sprintf("id: %q\napi_version: 2\nversion: %q\ndisplay_name: %q\ndescription: %q\nauthor: %q\nui:\n web_apps:\n - key: main\n title: %q\n entry: index.html\n placements:\n - task-canvas\n - workspace-canvas\ncapabilities:\n api_read:\n - tasks\n - workflows\n api_write:\n - messages\n", id, "1.0.0", title, strings.TrimSpace(summary), "Kandev task agent", title) } func taskSessionID(agent mcphandlers.CanvasAgentContext) string { return agent.SessionID } func canvasOperationError(code, message string, cause error) *mcphandlers.CanvasAuthoringError { if cause != nil && code == "" { code = canvasErrorCodeDefault } if message == "" { message = "canvas operation failed" } return &mcphandlers.CanvasAuthoringError{Code: code, Message: message} } func canvasErrorCode(err error) string { switch { case errors.Is(err, canvas.ErrTaskCanvasLimit), errors.Is(err, canvas.ErrWorkspaceCanvasLimit): return "canvas_limit_exceeded" case errors.Is(err, instances.ErrWorkspaceStorageLimit), errors.Is(err, instances.ErrInstallationStorageLimit): return "canvas_storage_limit_exceeded" case errors.Is(err, instances.ErrInvalidRelease): return canvasInvalidRelease case errors.Is(err, canvas.ErrStalePromotionReview): return "promotion_review_stale" case errors.Is(err, canvas.ErrStaleCanvasEdit): return "canvas_edit_stale" case errors.Is(err, canvas.ErrStaleCanvasPublish): return "canvas_publish_stale" case errors.Is(err, canvas.ErrInvalidLifecycleState): return canvasErrorCodeInvalid case errors.Is(err, canvas.ErrCanvasNotFound): return "canvas_not_found" default: return canvasErrorCodeDefault } } type canvasSourceCountingReader struct { reader io.Reader bytes int64 } func (r *canvasSourceCountingReader) Read(p []byte) (int, error) { n, err := r.reader.Read(p) r.bytes += int64(n) return n, err }