From d74e85883d2e33490964a87b81e97d6497c7606c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 5 Sep 2026 18:44:43 +0000 Subject: [PATCH] feat: add plugin-backed canvases (#3111) --- .agents/skills/e2e/SKILL.md | 17 +- .../skills/e2e/references/resource-safety.md | 23 + .../references/playwright-tests.md | 6 +- .agents/skills/verify/SKILL.md | 8 + AGENTS.md | 2 +- Makefile | 4 +- apps/backend/AGENTS.md | 2 +- .../internal/agent/runtime/agentctl/client.go | 40 + .../internal/agentctl/server/api/server.go | 2 + .../server/api/workspace_canvas_source.go | 529 ++++ .../api/workspace_canvas_source_test.go | 131 + .../internal/agentctl/types/canvas_source.go | 29 + .../internal/auth/httpmw/middleware.go | 1 + apps/backend/internal/backendapp/adapters.go | 190 ++ .../internal/backendapp/canvas_authoring.go | 723 ++++++ .../canvas_authoring_scaffold_test.go | 311 +++ .../internal/backendapp/canvas_edit_test.go | 343 +++ .../internal/backendapp/canvas_routes.go | 838 +++++++ .../backendapp/canvas_runtime_routes.go | 287 +++ apps/backend/internal/backendapp/helpers.go | 10 + apps/backend/internal/backendapp/main.go | 3 + .../backend/internal/backendapp/middleware.go | 12 +- .../internal/backendapp/middleware_test.go | 8 +- .../internal/backendapp/orchestrator.go | 1 + apps/backend/internal/backendapp/services.go | 49 + .../backendapp/services_canvas_test.go | 23 + apps/backend/internal/backendapp/types.go | 4 + apps/backend/internal/canvas/authoring.go | 814 ++++++ .../backend/internal/canvas/authoring_test.go | 561 +++++ apps/backend/internal/canvas/canvas_test.go | 576 +++++ .../internal/canvas/release_projection.go | 65 + apps/backend/internal/canvas/repository.go | 397 +++ apps/backend/internal/canvas/service.go | 859 +++++++ apps/backend/internal/canvas/types.go | 202 ++ .../backend/internal/common/config/catalog.go | 1 + .../internal/common/config/catalog_test.go | 1 + apps/backend/internal/common/config/config.go | 5 + .../internal/common/config/config_test.go | 1 + apps/backend/internal/events/types.go | 13 + .../gateway/websocket/canvas_notifications.go | 124 + .../internal/mcp/canvasskill/bundled.go | 312 +++ .../internal/mcp/canvasskill/bundled_test.go | 166 ++ .../internal/mcp/canvasskill/files/SKILL.md | 86 + .../files/references/browser-api.md | 192 ++ .../files/references/data-and-state.md | 13 + .../files/references/events-and-recovery.md | 13 + .../canvasskill/files/references/manifest.md | 67 + .../canvasskill/files/references/security.md | 14 + .../files/references/ui-patterns.md | 26 + .../canvasskill/files/scaffold/appearance.js | 113 + .../mcp/canvasskill/files/scaffold/index.html | 23 + .../mcp/canvasskill/files/scaffold/script.js | 18 + .../mcp/canvasskill/files/scaffold/styles.css | 61 + apps/backend/internal/mcp/handlers/canvas.go | 337 +++ .../internal/mcp/handlers/canvas_test.go | 135 + .../backend/internal/mcp/handlers/handlers.go | 15 +- apps/backend/internal/mcp/profile/profile.go | 1 + .../internal/mcp/server/canvas_tools.go | 280 +++ .../internal/mcp/server/canvas_tools_test.go | 113 + apps/backend/internal/mcp/server/server.go | 1 + .../office/costs/modelsdev/client_test.go | 117 +- .../orchestrator/executor/executor.go | 8 + .../orchestrator/executor/executor_execute.go | 15 +- apps/backend/internal/orchestrator/service.go | 8 + .../internal/plugins/instances/store.go | 2233 +++++++++++++++++ .../plugins/instances/store_cleanup_test.go | 54 + .../internal/plugins/instances/store_test.go | 462 ++++ .../internal/plugins/manifest/manifest.go | 17 + .../plugins/manifest/manifest_test.go | 62 + .../internal/plugins/manifest/runtime.go | 12 + .../internal/plugins/manifest/validate.go | 79 + apps/backend/internal/plugins/provider.go | 54 + apps/backend/internal/plugins/service.go | 102 +- .../internal/plugins/state/instance_store.go | 333 +++ .../plugins/state/instance_store_test.go | 101 + .../backend/internal/plugins/webapp/events.go | 763 ++++++ .../internal/plugins/webapp/events_test.go | 436 ++++ .../internal/plugins/webapp/goleak_test.go | 11 + .../internal/plugins/webapp/package.go | 634 +++++ .../internal/plugins/webapp/package_test.go | 202 ++ .../backend/internal/plugins/webapp/policy.go | 96 + .../internal/plugins/webapp/policy_test.go | 50 + .../internal/plugins/webapp/runtime.go | 271 ++ .../internal/plugins/webapp/runtime_test.go | 218 ++ .../backend/internal/plugins/webapp/tokens.go | 220 ++ .../internal/plugins/webapp/tokens_test.go | 79 + .../internal/plugins/webapp_cleanup.go | 99 + .../backend/internal/plugins/webapp_events.go | 307 +++ .../internal/plugins/webapp_events_test.go | 39 + .../internal/plugins/webapp_handlers.go | 25 + .../internal/plugins/webapp_protocol.go | 516 ++++ .../internal/plugins/webapp_protocol_data.go | 409 +++ .../internal/plugins/webapp_protocol_json.go | 218 ++ .../internal/plugins/webapp_protocol_state.go | 193 ++ .../internal/plugins/webapp_protocol_test.go | 220 ++ apps/backend/internal/profiles/profiles.yaml | 7 + .../internal/profiles/profiles_test.go | 35 + .../backend/internal/runtimeflags/registry.go | 18 + .../internal/runtimeflags/registry_test.go | 25 + apps/backend/internal/task/service/service.go | 16 + .../task/service/service_resources.go | 8 + .../internal/task/service/service_tasks.go | 9 + apps/backend/pkg/websocket/actions.go | 31 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/web/AGENTS.md | 2 +- .../app-sidebar/app-sidebar-constants.ts | 1 + .../app-sidebar-workspace-actions.tsx | 51 +- .../app-sidebar/app-sidebar.test.tsx | 28 + .../components/app-sidebar/app-sidebar.tsx | 2 + .../sections/canvases-section.test.tsx | 186 ++ .../app-sidebar/sections/canvases-section.tsx | 150 ++ .../settings/settings-menu-branches.test.ts | 14 +- .../settings/settings-menu-branches.ts | 51 +- .../settings/use-settings-menu-branches.ts | 4 +- .../canvas-task-create-launcher.test.tsx | 93 + .../canvas/canvas-task-create-launcher.tsx | 64 + .../components/gitlab/mr-status-chip.test.tsx | 1 + apps/web/components/gitlab/mr-status-chip.tsx | 6 + .../navigation/app-nav-sheet.test.tsx | 1 + .../components/plugins/canvas-page.test.tsx | 57 + apps/web/components/plugins/canvas-page.tsx | 31 + .../plugins/web-app-appearance.test.ts | 52 + .../components/plugins/web-app-appearance.ts | 136 + .../components/plugins/web-app-frame.test.tsx | 95 + apps/web/components/plugins/web-app-frame.tsx | 100 + .../settings/canvas-host-components.test.tsx | 104 + .../settings/canvas-host-components.tsx | 540 ++++ .../settings/canvas-host-picker.tsx | 62 + .../settings/canvas-host-route.test.tsx | 218 ++ .../components/settings/canvas-host-route.tsx | 446 ++++ .../canvas-lifecycle-dialogs.test.tsx | 436 ++++ .../settings/canvas-lifecycle-dialogs.tsx | 581 +++++ .../settings/workspace-canvases-page.test.tsx | 324 +++ .../settings/workspace-canvases-page.tsx | 454 ++++ .../workspace-section-links.test.tsx | 143 ++ .../workspaces/workspace-section-links.tsx | 48 +- .../workspace-settings-shell.test.tsx | 38 +- .../workspaces/workspace-settings-shell.tsx | 6 +- ...ask-create-dialog-effects-executor.test.ts | 27 + .../components/task-create-dialog-effects.ts | 9 +- .../task-create-dialog-form-body.test.tsx | 2 + .../task-create-dialog-form-reset.test.ts | 60 + .../task-create-dialog-form-reset.ts | 4 + .../task-create-dialog-handlers.test.ts | 2 + .../components/task-create-dialog-handlers.ts | 5 +- .../task-create-dialog-state.test.ts | 21 +- .../components/task-create-dialog-state.ts | 8 +- .../components/task-create-dialog-types.ts | 7 + .../components/task-create-dialog.test.tsx | 2 + .../task/chat/message-list-native-scroll.ts | 62 +- .../task/chat/message-list-native.test.tsx | 34 +- .../task/dockview-add-panel-items.test.tsx | 125 +- .../task/dockview-add-panel-items.tsx | 50 +- .../task/dockview-canvas-activation.test.tsx | 307 +++ .../task/dockview-canvas-activation.ts | 232 ++ .../task/dockview-desktop-layout.tsx | 1 + .../task/dockview-panel-content.diff.test.tsx | 21 +- .../task/dockview-panel-content.tsx | 16 + .../task/file-browser-tree-loader.ts | 272 +- .../task/mobile/plugin-panel-picker.tsx | 26 +- .../mobile/session-mobile-bottom-nav.test.tsx | 35 + .../task/mobile/session-mobile-bottom-nav.tsx | 15 +- .../task/mobile/session-mobile-layout.tsx | 11 + .../components/task/new-subtask-form-state.ts | 2 + .../task/task-layout-repository.test.tsx | 4 + apps/web/components/task/task-layout.tsx | 17 +- .../web/components/task/task-page-content.tsx | 5 + apps/web/components/task/task-page-inner.tsx | 7 + apps/web/e2e/README.md | 14 +- apps/web/e2e/scripts/resource-guard.sh | 134 + apps/web/e2e/scripts/resource-guard.test.ts | 97 + apps/web/e2e/scripts/run-e2e.sh | 20 +- apps/web/e2e/scripts/run-e2e.test.ts | 106 +- apps/web/e2e/scripts/run-planned-shard.sh | 4 + .../web/e2e/scripts/run-planned-shard.test.ts | 2 + apps/web/e2e/scripts/run-planned-shard.ts | 1 + apps/web/e2e/scripts/run-raw-e2e.sh | 9 +- apps/web/e2e/tests/canvas/canvas-fixture.ts | 629 +++++ .../tests/canvas/mobile-plugin-canvas.spec.ts | 354 +++ .../e2e/tests/canvas/plugin-canvas.spec.ts | 237 ++ .../tests/chat/mobile-quick-chat-tabs.spec.ts | 18 +- .../e2e/tests/gitlab/mr-status-chip.spec.ts | 16 +- .../plugins/mobile-status-drawer.spec.ts | 6 +- apps/web/e2e/tests/pr/pr-status-badge.spec.ts | 21 +- ...e-create-task-new-local-repository.spec.ts | 45 +- .../terminal/mobile-quick-terminal.spec.ts | 38 +- .../e2e/tests/terminal/quick-terminal.spec.ts | 28 +- .../session/use-session-message-fetch.ts | 85 +- .../domains/session/use-session-messages.ts | 123 +- .../domains/task/use-task-canvases.test.ts | 86 + .../hooks/domains/task/use-task-canvases.ts | 56 + apps/web/lib/api/domains/canvas-api.test.ts | 166 ++ apps/web/lib/api/domains/canvas-api.ts | 261 ++ apps/web/lib/api/domains/canvas-error-copy.ts | 59 + apps/web/lib/canvas-lifecycle.test.ts | 26 + apps/web/lib/canvas-lifecycle.ts | 68 + .../lib/settings/workspace-settings-tabs.ts | 16 + .../state/dockview-env-scoped-components.ts | 1 + .../slices/features/features-contract.test.ts | 4 + apps/web/lib/state/slices/features/types.ts | 1 + .../slices/ui/app-sidebar-actions.test.ts | 12 + .../state/slices/ui/app-sidebar-actions.ts | 1 + apps/web/lib/types/backend.ts | 23 + apps/web/lib/ws/handlers/canvases.test.ts | 48 + apps/web/lib/ws/handlers/canvases.ts | 25 + apps/web/lib/ws/router.ts | 2 + apps/web/package.json | 6 +- apps/web/scripts/vitest-worker-budget.test.ts | 29 + apps/web/scripts/vitest-worker-budget.ts | 41 + apps/web/src/locales/en/canvases.json | 86 + apps/web/src/locales/en/plugins.json | 2 + apps/web/src/locales/pseudo/canvases.json | 86 + apps/web/src/locales/pseudo/plugins.json | 2 + apps/web/src/locales/pt-pt/canvases.json | 86 + apps/web/src/locales/pt-pt/plugins.json | 2 + apps/web/src/locales/zh-cn/canvases.json | 86 + apps/web/src/locales/zh-cn/plugins.json | 2 + apps/web/src/locales/zh-hk/canvases.json | 86 + apps/web/src/locales/zh-hk/plugins.json | 2 + apps/web/src/locales/zh-tw/canvases.json | 86 + apps/web/src/locales/zh-tw/plugins.json | 2 + apps/web/src/spa-routes.canvas.test.ts | 37 + apps/web/src/spa-routes.loading.test.tsx | 7 +- apps/web/src/spa-routes.tsx | 61 +- apps/web/vitest.config.ts | 17 +- docs/copilot-canvas-reference.md | 636 +++++ ...0037-resource-aware-frontend-unit-tests.md | 12 +- ...08-25-server-owned-declarative-canvases.md | 129 + ...26-08-26-plugin-backed-web-app-canvases.md | 152 ++ docs/decisions/INDEX.md | 2 + .../plan.md | 194 ++ .../task-01-workspace-canvas-discovery.md | 106 + .../task-02-guided-canvas-task-launch.md | 107 + .../task-03-first-release-host-activation.md | 94 + .../task-04-canvas-action-guidance.md | 87 + .../task-05-canvas-host-viewport.md | 87 + ...task-06-isolated-application-appearance.md | 98 + .../task-07-authoring-bundle-and-scaffold.md | 107 + .../follow-up-ux-investigation.md | 409 +++ docs/plans/plugin-backed-canvases/plan.md | 327 +++ .../task-00-baseline-transition.md | 103 + .../task-01-canvas-release-gate.md | 87 + ...sk-02-plugin-web-app-package-foundation.md | 112 + .../task-03-isolated-browser-runtime.md | 120 + .../task-04-browser-data-state.md | 98 + .../task-05-live-event-transport.md | 92 + .../task-06-canvas-lifecycle.md | 109 + .../task-07-canvas-host-task-surfaces.md | 112 + .../task-08-agent-canvas-authoring.md | 111 + .../task-09-promotion-release-management.md | 101 + .../task-10-quick-chat-canvas-editing.md | 92 + .../task-11-workspace-mobile-surfaces.md | 124 + .../task-12-public-canvas-documentation.md | 112 + docs/public/canvases.md | 132 + docs/public/configuration.md | 9 + docs/public/coverage.json | 44 + docs/public/feature-status.md | 3 +- docs/public/index.md | 1 + docs/public/meta.json | 1 + docs/public/operations.md | 29 +- docs/public/plugins-authoring.md | 84 +- docs/public/plugins-manifest.md | 81 +- docs/public/plugins.md | 33 +- docs/public/security.md | 49 + docs/public/testing.md | 8 +- docs/specs/canvases/README.md | 36 + .../requirements/agent-authored-web-apps.md | 261 ++ .../requirements/collaborative-canvases.md | 258 ++ .../system-design/agent-authored-web-apps.md | 653 +++++ .../system-design/collaborative-canvases.md | 574 +++++ docs/specs/plugins/README.md | 8 +- .../isolated-web-app-contributions.md | 282 +++ .../isolated-web-app-contributions.md | 619 +++++ e2e.Dockerfile | 9 +- 274 files changed, 34222 insertions(+), 369 deletions(-) create mode 100644 .agents/skills/e2e/references/resource-safety.md create mode 100644 apps/backend/internal/agentctl/server/api/workspace_canvas_source.go create mode 100644 apps/backend/internal/agentctl/server/api/workspace_canvas_source_test.go create mode 100644 apps/backend/internal/agentctl/types/canvas_source.go create mode 100644 apps/backend/internal/backendapp/canvas_authoring.go create mode 100644 apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go create mode 100644 apps/backend/internal/backendapp/canvas_edit_test.go create mode 100644 apps/backend/internal/backendapp/canvas_routes.go create mode 100644 apps/backend/internal/backendapp/canvas_runtime_routes.go create mode 100644 apps/backend/internal/backendapp/services_canvas_test.go create mode 100644 apps/backend/internal/canvas/authoring.go create mode 100644 apps/backend/internal/canvas/authoring_test.go create mode 100644 apps/backend/internal/canvas/canvas_test.go create mode 100644 apps/backend/internal/canvas/release_projection.go create mode 100644 apps/backend/internal/canvas/repository.go create mode 100644 apps/backend/internal/canvas/service.go create mode 100644 apps/backend/internal/canvas/types.go create mode 100644 apps/backend/internal/gateway/websocket/canvas_notifications.go create mode 100644 apps/backend/internal/mcp/canvasskill/bundled.go create mode 100644 apps/backend/internal/mcp/canvasskill/bundled_test.go create mode 100644 apps/backend/internal/mcp/canvasskill/files/SKILL.md create mode 100644 apps/backend/internal/mcp/canvasskill/files/references/browser-api.md create mode 100644 apps/backend/internal/mcp/canvasskill/files/references/data-and-state.md create mode 100644 apps/backend/internal/mcp/canvasskill/files/references/events-and-recovery.md create mode 100644 apps/backend/internal/mcp/canvasskill/files/references/manifest.md create mode 100644 apps/backend/internal/mcp/canvasskill/files/references/security.md create mode 100644 apps/backend/internal/mcp/canvasskill/files/references/ui-patterns.md create mode 100644 apps/backend/internal/mcp/canvasskill/files/scaffold/appearance.js create mode 100644 apps/backend/internal/mcp/canvasskill/files/scaffold/index.html create mode 100644 apps/backend/internal/mcp/canvasskill/files/scaffold/script.js create mode 100644 apps/backend/internal/mcp/canvasskill/files/scaffold/styles.css create mode 100644 apps/backend/internal/mcp/handlers/canvas.go create mode 100644 apps/backend/internal/mcp/handlers/canvas_test.go create mode 100644 apps/backend/internal/mcp/server/canvas_tools.go create mode 100644 apps/backend/internal/mcp/server/canvas_tools_test.go create mode 100644 apps/backend/internal/plugins/instances/store.go create mode 100644 apps/backend/internal/plugins/instances/store_cleanup_test.go create mode 100644 apps/backend/internal/plugins/instances/store_test.go create mode 100644 apps/backend/internal/plugins/state/instance_store.go create mode 100644 apps/backend/internal/plugins/state/instance_store_test.go create mode 100644 apps/backend/internal/plugins/webapp/events.go create mode 100644 apps/backend/internal/plugins/webapp/events_test.go create mode 100644 apps/backend/internal/plugins/webapp/goleak_test.go create mode 100644 apps/backend/internal/plugins/webapp/package.go create mode 100644 apps/backend/internal/plugins/webapp/package_test.go create mode 100644 apps/backend/internal/plugins/webapp/policy.go create mode 100644 apps/backend/internal/plugins/webapp/policy_test.go create mode 100644 apps/backend/internal/plugins/webapp/runtime.go create mode 100644 apps/backend/internal/plugins/webapp/runtime_test.go create mode 100644 apps/backend/internal/plugins/webapp/tokens.go create mode 100644 apps/backend/internal/plugins/webapp/tokens_test.go create mode 100644 apps/backend/internal/plugins/webapp_cleanup.go create mode 100644 apps/backend/internal/plugins/webapp_events.go create mode 100644 apps/backend/internal/plugins/webapp_events_test.go create mode 100644 apps/backend/internal/plugins/webapp_handlers.go create mode 100644 apps/backend/internal/plugins/webapp_protocol.go create mode 100644 apps/backend/internal/plugins/webapp_protocol_data.go create mode 100644 apps/backend/internal/plugins/webapp_protocol_json.go create mode 100644 apps/backend/internal/plugins/webapp_protocol_state.go create mode 100644 apps/backend/internal/plugins/webapp_protocol_test.go create mode 100644 apps/web/components/app-sidebar/sections/canvases-section.test.tsx create mode 100644 apps/web/components/app-sidebar/sections/canvases-section.tsx create mode 100644 apps/web/components/canvas/canvas-task-create-launcher.test.tsx create mode 100644 apps/web/components/canvas/canvas-task-create-launcher.tsx create mode 100644 apps/web/components/plugins/canvas-page.test.tsx create mode 100644 apps/web/components/plugins/canvas-page.tsx create mode 100644 apps/web/components/plugins/web-app-appearance.test.ts create mode 100644 apps/web/components/plugins/web-app-appearance.ts create mode 100644 apps/web/components/plugins/web-app-frame.test.tsx create mode 100644 apps/web/components/plugins/web-app-frame.tsx create mode 100644 apps/web/components/settings/canvas-host-components.test.tsx create mode 100644 apps/web/components/settings/canvas-host-components.tsx create mode 100644 apps/web/components/settings/canvas-host-picker.tsx create mode 100644 apps/web/components/settings/canvas-host-route.test.tsx create mode 100644 apps/web/components/settings/canvas-host-route.tsx create mode 100644 apps/web/components/settings/canvas-lifecycle-dialogs.test.tsx create mode 100644 apps/web/components/settings/canvas-lifecycle-dialogs.tsx create mode 100644 apps/web/components/settings/workspace-canvases-page.test.tsx create mode 100644 apps/web/components/settings/workspace-canvases-page.tsx create mode 100644 apps/web/components/settings/workspaces/workspace-section-links.test.tsx create mode 100644 apps/web/components/task-create-dialog-form-reset.test.ts create mode 100644 apps/web/components/task/dockview-canvas-activation.test.tsx create mode 100644 apps/web/components/task/dockview-canvas-activation.ts create mode 100644 apps/web/e2e/scripts/resource-guard.sh create mode 100644 apps/web/e2e/scripts/resource-guard.test.ts create mode 100644 apps/web/e2e/tests/canvas/canvas-fixture.ts create mode 100644 apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts create mode 100644 apps/web/e2e/tests/canvas/plugin-canvas.spec.ts create mode 100644 apps/web/hooks/domains/task/use-task-canvases.test.ts create mode 100644 apps/web/hooks/domains/task/use-task-canvases.ts create mode 100644 apps/web/lib/api/domains/canvas-api.test.ts create mode 100644 apps/web/lib/api/domains/canvas-api.ts create mode 100644 apps/web/lib/api/domains/canvas-error-copy.ts create mode 100644 apps/web/lib/canvas-lifecycle.test.ts create mode 100644 apps/web/lib/canvas-lifecycle.ts create mode 100644 apps/web/lib/ws/handlers/canvases.test.ts create mode 100644 apps/web/lib/ws/handlers/canvases.ts create mode 100644 apps/web/scripts/vitest-worker-budget.test.ts create mode 100644 apps/web/scripts/vitest-worker-budget.ts create mode 100644 apps/web/src/locales/en/canvases.json create mode 100644 apps/web/src/locales/pseudo/canvases.json create mode 100644 apps/web/src/locales/pt-pt/canvases.json create mode 100644 apps/web/src/locales/zh-cn/canvases.json create mode 100644 apps/web/src/locales/zh-hk/canvases.json create mode 100644 apps/web/src/locales/zh-tw/canvases.json create mode 100644 apps/web/src/spa-routes.canvas.test.ts create mode 100644 docs/copilot-canvas-reference.md create mode 100644 docs/decisions/2026-08-25-server-owned-declarative-canvases.md create mode 100644 docs/decisions/2026-08-26-plugin-backed-web-app-canvases.md create mode 100644 docs/plans/plugin-backed-canvases-ux-follow-up/plan.md create mode 100644 docs/plans/plugin-backed-canvases-ux-follow-up/task-01-workspace-canvas-discovery.md create mode 100644 docs/plans/plugin-backed-canvases-ux-follow-up/task-02-guided-canvas-task-launch.md create mode 100644 docs/plans/plugin-backed-canvases-ux-follow-up/task-03-first-release-host-activation.md create mode 100644 docs/plans/plugin-backed-canvases-ux-follow-up/task-04-canvas-action-guidance.md create mode 100644 docs/plans/plugin-backed-canvases-ux-follow-up/task-05-canvas-host-viewport.md create mode 100644 docs/plans/plugin-backed-canvases-ux-follow-up/task-06-isolated-application-appearance.md create mode 100644 docs/plans/plugin-backed-canvases-ux-follow-up/task-07-authoring-bundle-and-scaffold.md create mode 100644 docs/plans/plugin-backed-canvases/follow-up-ux-investigation.md create mode 100644 docs/plans/plugin-backed-canvases/plan.md create mode 100644 docs/plans/plugin-backed-canvases/task-00-baseline-transition.md create mode 100644 docs/plans/plugin-backed-canvases/task-01-canvas-release-gate.md create mode 100644 docs/plans/plugin-backed-canvases/task-02-plugin-web-app-package-foundation.md create mode 100644 docs/plans/plugin-backed-canvases/task-03-isolated-browser-runtime.md create mode 100644 docs/plans/plugin-backed-canvases/task-04-browser-data-state.md create mode 100644 docs/plans/plugin-backed-canvases/task-05-live-event-transport.md create mode 100644 docs/plans/plugin-backed-canvases/task-06-canvas-lifecycle.md create mode 100644 docs/plans/plugin-backed-canvases/task-07-canvas-host-task-surfaces.md create mode 100644 docs/plans/plugin-backed-canvases/task-08-agent-canvas-authoring.md create mode 100644 docs/plans/plugin-backed-canvases/task-09-promotion-release-management.md create mode 100644 docs/plans/plugin-backed-canvases/task-10-quick-chat-canvas-editing.md create mode 100644 docs/plans/plugin-backed-canvases/task-11-workspace-mobile-surfaces.md create mode 100644 docs/plans/plugin-backed-canvases/task-12-public-canvas-documentation.md create mode 100644 docs/public/canvases.md create mode 100644 docs/specs/canvases/README.md create mode 100644 docs/specs/canvases/requirements/agent-authored-web-apps.md create mode 100644 docs/specs/canvases/requirements/collaborative-canvases.md create mode 100644 docs/specs/canvases/system-design/agent-authored-web-apps.md create mode 100644 docs/specs/canvases/system-design/collaborative-canvases.md create mode 100644 docs/specs/plugins/requirements/isolated-web-app-contributions.md create mode 100644 docs/specs/plugins/system-design/isolated-web-app-contributions.md diff --git a/.agents/skills/e2e/SKILL.md b/.agents/skills/e2e/SKILL.md index 2e61afd586..73d8161421 100644 --- a/.agents/skills/e2e/SKILL.md +++ b/.agents/skills/e2e/SKILL.md @@ -71,7 +71,7 @@ command. Shared `.git` metadata does not include `apps/node_modules`. ### Preferred: `pnpm e2e:run` (managed runner — builds, runs, tears down) -`e2e/scripts/run-e2e.sh` handles the build, the run, and cleanup in one command. Use it instead of stitching the steps together. It auto-selects docker vs host, runs N shards concurrently, enforces strict WS accounting by default (matching CI), and never leaves root-owned artifacts behind. +`e2e/scripts/run-e2e.sh` handles the build, the run, and cleanup in one command. Use it instead of stitching the steps together. It auto-selects docker vs host, runs a resource-bounded number of shards concurrently, enforces one Playwright worker per shard and strict WS accounting by default (matching CI), and never leaves root-owned artifacts behind. ```bash cd apps/web @@ -96,11 +96,12 @@ pnpm e2e:run --project routing tests/office-routing-.spec.ts pnpm e2e:run --project containers tests/docker/.spec.ts ``` -Use `mobile-chrome` only for `mobile-*.spec.ts` files. Confirm Playwright -discovers the intended test count before treating a focused command as evidence. +Use `mobile-chrome` only for `mobile-*.spec.ts` files. Confirm Playwright discovers the intended test count before treating a focused command as evidence. `e2e:run` accepts one `--project`; repeating it selects only the last value, so run desktop and mobile separately when both are required and confirm discovery for each. +See [resource-safety.md](references/resource-safety.md) before any full local test run. + The runner solves the sharp edges hand-rolling would hit: in docker it builds the CGO backend on the **host** and runs it in the runtime image (forward-compatible when the host glibc ≤ the image's — the usual case; it smoke-tests this and only falls back to the build image if the host is newer), builds the Vite web assets on the host, runs them through the Go-served SPA, and keeps Playwright output container-local. See `apps/web/e2e/README.md` → "the managed runner". `--no-build` reuses every production E2E artifact, not only Vite assets and the @@ -136,7 +137,7 @@ Start by matching CI as closely as possible, then add pressure deliberately: -e NODE_OPTIONS=--dns-result-order=ipv4first \ -e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ ghcr.io/kdlbs/kandev-ci:runtime-latest \ - bash -lc 'git config --global --add safe.directory /work 2>/dev/null; npx playwright test --config e2e/playwright.config.ts --project=chromium --project=mobile-chrome --shard=/14 --reporter=list' + bash -lc 'git config --global --add safe.directory /work 2>/dev/null; bash e2e/scripts/run-raw-e2e.sh --project=chromium --project=mobile-chrome --shard=/14 -- --reporter=list --retries=0' ``` 2. If the exact shard passes, constrain container resources and repeat the failing spec/test. GitHub-hosted runners can expose timing bugs that a roomy @@ -148,7 +149,7 @@ Start by matching CI as closely as possible, then add pressure deliberately: -e NODE_OPTIONS=--dns-result-order=ipv4first \ -e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ ghcr.io/kdlbs/kandev-ci:runtime-latest \ - bash -lc 'git config --global --add safe.directory /work 2>/dev/null; npx playwright test --config e2e/playwright.config.ts --project=mobile-chrome e2e/tests/terminal/mobile-terminal-keybar.spec.ts --grep "user presses an OS-keyboard letter while no modifier is active" --repeat-each=30 --reporter=list' + bash -lc 'git config --global --add safe.directory /work 2>/dev/null; bash e2e/scripts/run-raw-e2e.sh --project=mobile-chrome e2e/tests/terminal/mobile-terminal-keybar.spec.ts --grep "user presses an OS-keyboard letter while no modifier is active" --repeat-each=30 --reporter=list --retries=0' ``` 3. Preserve nearby test ordering when a single-test repeat stays green; run the full spec or shard with the same resource limits before declaring a flake non-reproducible. @@ -439,11 +440,11 @@ CI splits host tests across 14 shards (plus 6 container shards); reproduce a spe ```bash # List which tests are in a shard -npx playwright test --config e2e/playwright.config.ts --shard=2/14 --list +pnpm e2e:raw -- --shard=2/14 --list # Run that shard locally (requires production build) make build-backend build-web -cd apps/web && npx playwright test --config e2e/playwright.config.ts --shard=2/14 +cd apps/web && pnpm e2e:raw -- --shard=2/14 ``` ```bash @@ -473,7 +474,7 @@ A test that flakes under parallel/sharded load is one of two things — decide w 1. **Re-run it in a fresh, isolated container** (or at minimum a single fresh worker), `--retries=0`, a few reps: ```bash pnpm e2e:docker --no-build -- --repeat-each=4 --workers=1 --retries=0 tests/path.spec.ts:LINE - # or raw: pnpm exec playwright test --config e2e/playwright.config.ts --project=chromium --repeat-each=4 --workers=1 --retries=0 tests/path.spec.ts:LINE + # or raw: pnpm e2e:raw -- --project=chromium --repeat-each=4 --retries=0 tests/path.spec.ts:LINE ``` (On Apple Silicon, `pnpm e2e:docker` needs Colima + Rosetta — `colima start --vz-rosetta`; default QEMU segfaults the amd64 Go build. See `apps/web/e2e/README.md`.) - **Flakes alone (fails some reps, fast):** intrinsic race — fix it (condition-correct wait, fix the actual race; not a timeout bump). E.g. a `waitForRequest` that times out the full window means the request *never fired* (a click swallowed during hydration) — retry the action with `await expect(async () => { ... }).toPass()`, don't extend the timeout. diff --git a/.agents/skills/e2e/references/resource-safety.md b/.agents/skills/e2e/references/resource-safety.md new file mode 100644 index 0000000000..c7da35dfda --- /dev/null +++ b/.agents/skills/e2e/references/resource-safety.md @@ -0,0 +1,23 @@ +# E2E and Frontend Test Resource Safety + +Keep local browser runs bounded. The managed and raw E2E entry points enforce +one Playwright worker per shard. The guard recognizes `--workers`, +`--workers=N`, `-j N`, `-j=N`, and compact `-jN` forms, so a worker override +cannot bypass the limit by changing CLI spelling. The managed runner allows at +most three local shards, and lowers that limit when available memory is below +12 GiB or 6 GiB. Its available-memory value is the minimum of host +`MemAvailable` and the remaining cgroup v1 or v2 memory limit when the process +runs inside a container. This prevents host memory reporting from authorizing +more shards than the container can hold. + +Start with the default single shard; use two or three only when the host has +capacity for separate Go backends, SPA processes, Chromium instances, and mock +agents. If a command is rejected, reduce its shard or worker count. Do not +silence the guard during ordinary verification. + +Do not run a local full Vitest suite with `VITEST_MAX_WORKERS=100%`, another +all-worker value, or several overlapping full-suite processes. Local Vitest +configuration clamps unsafe worker overrides to its 20-percent budget. Set +`KANDEV_E2E_ALLOW_UNSAFE_PARALLELISM=1` or +`KANDEV_ALLOW_UNSAFE_TEST_PARALLELISM=1` only for a deliberate, monitored +resource experiment, and record the resource limit and result. diff --git a/.agents/skills/playwright-cli/references/playwright-tests.md b/.agents/skills/playwright-cli/references/playwright-tests.md index 47627c2a6a..d23e2f4068 100644 --- a/.agents/skills/playwright-cli/references/playwright-tests.md +++ b/.agents/skills/playwright-cli/references/playwright-tests.md @@ -1,10 +1,10 @@ # Running Playwright Tests -To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable. +For Kandev E2E tests, use the guarded `e2e:raw` or `e2e:run` package script. These runners keep Playwright at one worker and reject unsafe overrides. To avoid opening the interactive HTML report, use `PLAYWRIGHT_HTML_OPEN=never`. ```bash # Run all tests -PLAYWRIGHT_HTML_OPEN=never npx playwright test +cd apps/web && PLAYWRIGHT_HTML_OPEN=never pnpm e2e:raw # Run all tests through a custom npm script PLAYWRIGHT_HTML_OPEN=never npm run special-test-command @@ -20,7 +20,7 @@ Once instructions containing a session name are printed, use `playwright-cli` to ```bash # Run the test -PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli +cd apps/web && PLAYWRIGHT_HTML_OPEN=never pnpm e2e:raw -- --debug=cli # ... # ... debugging instructions for "tw-abcdef" session ... # ... diff --git a/.agents/skills/verify/SKILL.md b/.agents/skills/verify/SKILL.md index d3ed3b5f12..003e90df08 100644 --- a/.agents/skills/verify/SKILL.md +++ b/.agents/skills/verify/SKILL.md @@ -35,6 +35,14 @@ verification report. delivery are waiting on mandatory verification; do not imply that Codex or GitHub cannot create PRs or ask whether to proceed unverified. +## Resource-safe frontend verification + +Before a broad web test or E2E run, read +[the E2E resource-safety reference](../e2e/references/resource-safety.md). +The local Vitest configuration clamps unsafe worker overrides, and the E2E +wrappers cap local shards and workers. Do not bypass those limits or overlap +full suites unless the task is a deliberate, monitored resource experiment. + ## Verification Procedure Resolve the PR base and verification scope base, then collect diff --git a/AGENTS.md b/AGENTS.md index b78eebec0b..2b1ff76c2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ apps/ - **Web-only scripts** live in `apps/web/package.json`; run them from `apps/web` (for example `pnpm run i18n:check` or `pnpm run i18n:ratchet`) or use `pnpm --filter @kandev/web ...`, not from `apps/`. - **Desktop**: Tauri shell (`cd apps && pnpm --filter @kandev/desktop build|e2e`; Rust tests from `apps/desktop/src-tauri`) - **UI**: Shadcn components via `@kandev/ui` -- **E2E**: Playwright (`cd apps/web && pnpm e2e:raw`). The `containers` project (gated on `KANDEV_E2E_CONTAINERS=1`, formerly `docker`) covers Docker, SSH, and Kind-backed Kubernetes executor scenarios — anything that needs a real Docker daemon on the host lives there. See `apps/web/e2e/README.md`. +- **E2E**: Playwright (`cd apps/web && pnpm e2e:run` or the guarded `pnpm e2e:raw`). Local runners enforce one worker per shard and a memory-aware shard budget; do not pass all-worker overrides or overlap full suites. The `containers` project (gated on `KANDEV_E2E_CONTAINERS=1`, formerly `docker`) covers Docker, SSH, and Kind-backed Kubernetes executor scenarios — anything that needs a real Docker daemon on the host lives there. See `apps/web/e2e/README.md`. - **GitHub repo**: `https://github.com/kdlbs/kandev` - **Container image**: `ghcr.io/kdlbs/kandev` (GitHub Container Registry) diff --git a/Makefile b/Makefile index 5cf25a1450..057564bf74 100644 --- a/Makefile +++ b/Makefile @@ -143,7 +143,7 @@ help: @echo " test-backend Run backend tests" @echo " test-web Run web app tests" @echo " test-cli Run CLI tests" - @echo " test-e2e Run E2E tests (headless, parallel)" + @echo " test-e2e Run E2E tests (headless, resource-bounded)" @echo " test-e2e-headed Run E2E tests with visible browser" @echo " test-e2e-ui Run E2E tests in Playwright UI mode" @echo " test-e2e-ci Run E2E tests in Docker with CI-like Linux + resource limits" @@ -579,7 +579,7 @@ test-scripts: .PHONY: test-e2e test-e2e: build-backend build-backend-linux-helpers build-web-e2e build-e2e-plugin-package - @printf "$(CYAN)Running E2E tests (headless, parallel, managed runner)...$(RESET)\n" + @printf "$(CYAN)Running E2E tests (headless, resource-bounded, managed runner)...$(RESET)\n" @cd $(WEB_DIR) && status=0; for project in routing auth chromium mobile-chrome containers; do \ printf "$(CYAN)-- project: $$project --$(RESET)\n"; \ e2e/scripts/run-e2e.sh --host --no-build --no-strict --shards 1 --project "$$project" -- --output="e2e/test-results-$$project" || status=1; \ diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index be8fe571ce..514ce6545d 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -30,6 +30,7 @@ apps/backend/ │ │ ├── remoteauth/ # Remote auth catalog and method IDs for remote executors/UI │ │ └── planinjection/ # Bounds a task plan document before session-handover/dynamic-continuation injection │ ├── auth/ # Opt-in auth, per-user scoping, middleware, API, store +│ ├── canvas/ # Agent-authored plugin web-app canvas lifecycle and governance │ ├── agentctl/ │ │ └── server/ # agentctl HTTP server │ │ ├── acp/ # ACP protocol implementation @@ -291,7 +292,6 @@ When a PR fixup touches backend code, run the CI-style changed-file linter local golangci-lint run ./... --new-from-rev="" --timeout=5m ``` ## Further scoped notes - - `internal/launcher/` — native launcher owning every entrypoint (`dev`, `start`, `run`, `service`); `dev` runs `make -C apps/backend dev` with Vite as a supervised child, state under `/.kandev-dev/`. The root `make dev` prebuilds only the copied launcher; the backend dev target builds the native agentctl and a linux/amd64 helper when the host is not Linux/amd64 (`docs/plans/go-dev-launcher/`). - `internal/agentctl/AGENTS.md` — agentctl server route groups, adapter model, ACP protocol - `internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping diff --git a/apps/backend/internal/agent/runtime/agentctl/client.go b/apps/backend/internal/agent/runtime/agentctl/client.go index fb2ddd467c..1a10ee98ee 100644 --- a/apps/backend/internal/agent/runtime/agentctl/client.go +++ b/apps/backend/internal/agent/runtime/agentctl/client.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" "sync" @@ -482,6 +483,45 @@ func (c *Client) SetPluginTools(ctx context.Context, snapshot plugintools.Snapsh return nil } +// StreamCanvasSource requests the assigned canvas source root from agentctl. +// The response is a bounded tar stream and must be closed by the caller. The +// route is deliberately kept in the agentctl client so every executor type +// uses the same authenticated transfer contract. +func (c *Client) StreamCanvasSource(ctx context.Context, root string) (io.ReadCloser, error) { + ctx, span := tracing.TraceHTTPRequest(ctx, "POST", types.CanvasSourceTransferPath, c.executionID) + defer span.End() + + body, err := json.Marshal(types.CanvasSourceTransferRequest{Root: root}) + if err != nil { + tracing.TraceHTTPResponse(span, 0, err) + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+types.CanvasSourceTransferPath, bytes.NewReader(body)) + if err != nil { + tracing.TraceHTTPResponse(span, 0, err) + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + tracing.TraceHTTPResponse(span, 0, err) + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + _ = resp.Body.Close() + if readErr != nil { + tracing.TraceHTTPResponse(span, resp.StatusCode, readErr) + return nil, fmt.Errorf("canvas source request failed with status %d: %w", resp.StatusCode, readErr) + } + httpErr := fmt.Errorf("canvas source request failed with status %d: %s", resp.StatusCode, string(responseBody)) + tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr) + return nil, httpErr + } + tracing.TraceHTTPResponse(span, resp.StatusCode, nil) + return resp.Body, nil +} + // Start starts the agent process and returns the full command that was executed. func (c *Client) Start(ctx context.Context) (string, error) { ctx, span := tracing.TraceHTTPRequest(ctx, "POST", "/api/v1/start", c.executionID) diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 39a037568b..3fd3be1e46 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -16,6 +16,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/agentctl/server/utility" + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/mcpmode" @@ -151,6 +152,7 @@ func (s *Server) setupRoutes() { // Docker, Sprites — to seed the workspace with gitignored config // after the in-container clone). api.POST("/workspace/copy-files", s.handleWorkspaceCopyFiles) + api.POST(agentctltypes.CanvasSourceTransferPath[len("/api/v1"):], s.handleCanvasSourceTransfer) api.POST("/attachments/materialize", s.handleMaterializeAttachment) api.POST("/workspace/diagnostics/:id", s.handleWorkspaceDiagnostics) api.POST("/workspace/materialize-repository", s.handleWorkspaceMaterializeRepository) diff --git a/apps/backend/internal/agentctl/server/api/workspace_canvas_source.go b/apps/backend/internal/agentctl/server/api/workspace_canvas_source.go new file mode 100644 index 0000000000..aadea52e3a --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/workspace_canvas_source.go @@ -0,0 +1,529 @@ +package api + +import ( + "archive/tar" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + pathpkg "path" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +const ( + // CanvasSourceTransferPath is exported from this package too so agentctl + // clients do not need to duplicate the route string. + CanvasSourceTransferPath = agentctltypes.CanvasSourceTransferPath + CanvasSourceContentType = agentctltypes.CanvasSourceContentType +) + +type canvasSourceEntry struct { + absolutePath string + relativePath string + info os.FileInfo +} + +type canvasSourceHTTPError struct { + status int + code string + message string + detail error +} + +func (e *canvasSourceHTTPError) Error() string { + if e.detail == nil { + return e.message + } + return fmt.Sprintf("%s: %v", e.message, e.detail) +} + +func newCanvasSourceError(code, message string, detail error) *canvasSourceHTTPError { + return &canvasSourceHTTPError{ + status: http.StatusBadRequest, + code: code, + message: message, + detail: detail, + } +} + +func newCanvasSourceLimitError(detail error) *canvasSourceHTTPError { + return &canvasSourceHTTPError{ + status: http.StatusRequestEntityTooLarge, + code: "source_limit_exceeded", + message: "canvas source exceeds the transfer limit", + detail: detail, + } +} + +// handleCanvasSourceTransfer streams a bounded tar archive for one assigned +// workspace-relative source root. The route is behind agentctl's bearer-token +// middleware and instance guard in NewServer. +func (s *Server) handleCanvasSourceTransfer(c *gin.Context) { + request, err := decodeCanvasSourceRequest(c) + if err != nil { + writeCanvasSourceError(c, newCanvasSourceError("invalid_request", "invalid canvas source request", err)) + return + } + + entries, err := collectCanvasSourceEntries(s.procMgr.WorkDir(), request.Root) + if err != nil { + var sourceErr *canvasSourceHTTPError + if errors.As(err, &sourceErr) { + writeCanvasSourceError(c, sourceErr) + return + } + unexpected := newCanvasSourceError("source_unavailable", "canvas source is unavailable", err) + unexpected.status = http.StatusInternalServerError + writeCanvasSourceError(c, unexpected) + return + } + + c.Header("Content-Type", CanvasSourceContentType) + c.Header("Content-Disposition", `attachment; filename="canvas-source.tar"`) + c.Header("Cache-Control", "no-store") + c.Header("X-Content-Type-Options", "nosniff") + c.Status(http.StatusOK) + + if err := streamCanvasSource(c.Request.Context(), c.Writer, entries); err != nil { + s.logger.Warn("canvas source transfer stopped", + zap.String("root", request.Root), + zap.Error(err)) + } +} + +func decodeCanvasSourceRequest(c *gin.Context) (agentctltypes.CanvasSourceTransferRequest, error) { + var request agentctltypes.CanvasSourceTransferRequest + limited := http.MaxBytesReader(c.Writer, c.Request.Body, agentctltypes.MaxCanvasSourceRequestBytes) + decoder := json.NewDecoder(limited) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + return request, err + } + var extra interface{} + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return request, errors.New("request must contain one JSON object") + } + return request, err + } + if strings.TrimSpace(request.Root) == "" { + return request, errors.New("root is required") + } + return request, nil +} + +func writeCanvasSourceError(c *gin.Context, sourceErr *canvasSourceHTTPError) { + if sourceErr == nil { + sourceErr = newCanvasSourceError("source_unavailable", "canvas source is unavailable", nil) + } + c.JSON(sourceErr.status, agentctltypes.CanvasSourceTransferError{ + Code: sourceErr.code, + Message: sourceErr.message, + }) +} + +func collectCanvasSourceEntries(workDir, requestedRoot string) ([]canvasSourceEntry, error) { + root, err := resolveCanvasSourceRoot(workDir, requestedRoot) + if err != nil { + return nil, err + } + collector := &canvasSourceCollector{root: root, entries: make([]canvasSourceEntry, 0)} + err = filepath.WalkDir(root, collector.visit) + if err != nil { + return nil, err + } + + wireBytes, err := estimateCanvasSourceWireBytes(collector.entries) + if err != nil { + return nil, err + } + if wireBytes > int64(agentctltypes.MaxCanvasSourceWireBytes) { + return nil, newCanvasSourceLimitError(errors.New("tar stream exceeds the wire limit")) + } + return collector.entries, nil +} + +type canvasSourceCollector struct { + root string + entries []canvasSourceEntry + totalData int64 + regularFiles int +} + +func (c *canvasSourceCollector) visit(currentPath string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return newCanvasSourceError("source_unavailable", "canvas source is unavailable", walkErr) + } + if currentPath == c.root { + return nil + } + info, err := canvasSourceEntryInfo(currentPath, entry) + if err != nil { + return err + } + relativePath, err := filepath.Rel(c.root, currentPath) + if err != nil || relativePath == "." { + return newCanvasSourceError("invalid_source_path", "canvas source contains an invalid path", err) + } + relativePath = filepath.ToSlash(relativePath) + if err := validateCanvasSourceRelativePath(relativePath); err != nil { + return newCanvasSourceError("invalid_source_path", "canvas source contains an invalid path", err) + } + // The marker is created by create_canvas_kandev to establish the assigned + // directory on every executor. It is host bookkeeping, not application + // source, and must never make an otherwise valid package fail the static + // package extension allowlist. + if relativePath == ".canvas-root" { + return nil + } + if len([]byte(relativePath)) > 240 { + return newCanvasSourceLimitError(errors.New("source path is too long")) + } + return c.add(canvasSourceEntry{absolutePath: currentPath, relativePath: relativePath, info: info}) +} + +func canvasSourceEntryInfo(currentPath string, entry fs.DirEntry) (os.FileInfo, error) { + if entry.Type()&os.ModeSymlink != 0 { + return nil, newCanvasSourceError("source_contains_link", "canvas source cannot contain symlinks", errors.New(currentPath)) + } + info, err := os.Lstat(currentPath) + if err != nil { + return nil, newCanvasSourceError("source_unavailable", "canvas source is unavailable", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, newCanvasSourceError("source_contains_link", "canvas source cannot contain symlinks", errors.New(currentPath)) + } + if !info.IsDir() && !info.Mode().IsRegular() { + return nil, newCanvasSourceError("source_contains_unsupported_entry", "canvas source contains an unsupported filesystem entry", errors.New(currentPath)) + } + return info, nil +} + +func (c *canvasSourceCollector) add(entry canvasSourceEntry) error { + if entry.info.Mode().IsRegular() { + if entry.info.Size() < 0 || entry.info.Size() > int64(agentctltypes.MaxCanvasSourceFileData) || + c.totalData > int64(agentctltypes.MaxCanvasSourceFileData)-entry.info.Size() { + return newCanvasSourceLimitError(errors.New("source file data exceeds the limit")) + } + c.totalData += entry.info.Size() + c.regularFiles++ + } + c.entries = append(c.entries, entry) + if len(c.entries) > agentctltypes.MaxCanvasSourceFiles*4 { + return newCanvasSourceLimitError(errors.New("source contains too many entries")) + } + if c.regularFiles > agentctltypes.MaxCanvasSourceFiles { + return newCanvasSourceLimitError(errors.New("source contains too many files")) + } + return nil +} + +func resolveCanvasSourceRoot(workDir, requestedRoot string) (string, error) { + cleanRoot, err := cleanCanvasSourceRoot(requestedRoot) + if err != nil { + return "", err + } + workspace, err := canonicalCanvasWorkspace(workDir) + if err != nil { + return "", err + } + relativeRoot := filepath.FromSlash(cleanRoot) + if err := ensureNoCanvasSourceSymlinkComponents(workspace, relativeRoot); err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", newCanvasSourceError("source_root_not_found", "source root does not exist", err) + } + return "", newCanvasSourceError("source_contains_link", "source roots and entries cannot contain symlinks", err) + } + root := filepath.Join(workspace, relativeRoot) + if err := ensureCanvasSourceContainment(workspace, root); err != nil { + return "", err + } + if err := validateCanvasSourceRootDirectory(root); err != nil { + return "", err + } + return root, nil +} + +func cleanCanvasSourceRoot(requestedRoot string) (string, error) { + if strings.IndexByte(requestedRoot, 0) >= 0 || filepath.IsAbs(requestedRoot) || pathpkg.IsAbs(filepath.ToSlash(requestedRoot)) || filepath.VolumeName(requestedRoot) != "" || strings.Contains(requestedRoot, `\`) { + return "", newCanvasSourceError("invalid_source_root", "root must be a workspace-relative directory", nil) + } + cleanRoot := pathpkg.Clean(requestedRoot) + if cleanRoot == "." || cleanRoot == ".." || strings.HasPrefix(cleanRoot, "../") { + return "", newCanvasSourceError("invalid_source_root", "root must remain inside the agent workspace", nil) + } + return cleanRoot, nil +} + +func canonicalCanvasWorkspace(workDir string) (string, error) { + workspace, err := filepath.Abs(workDir) + if err != nil { + return "", newCanvasSourceError("invalid_workspace", "agent workspace is unavailable", err) + } + workspace, err = filepath.EvalSymlinks(workspace) + if err != nil { + return "", newCanvasSourceError("invalid_workspace", "agent workspace is unavailable", err) + } + return workspace, nil +} + +func ensureCanvasSourceContainment(workspace, root string) error { + relativeCheck, err := filepath.Rel(workspace, root) + if err != nil || relativeCheck == ".." || strings.HasPrefix(relativeCheck, ".."+string(filepath.Separator)) { + return newCanvasSourceError("invalid_source_root", "root must remain inside the agent workspace", err) + } + return nil +} + +func validateCanvasSourceRootDirectory(root string) error { + info, err := os.Lstat(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return newCanvasSourceError("source_root_not_found", "source root does not exist", err) + } + return newCanvasSourceError("source_unavailable", "source root is unavailable", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return newCanvasSourceError("source_contains_link", "source roots and entries cannot contain symlinks", nil) + } + if !info.IsDir() { + return newCanvasSourceError("invalid_source_root", "root must name a directory", nil) + } + return nil +} + +func ensureNoCanvasSourceSymlinkComponents(base, relative string) error { + current := base + for _, component := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) { + if component == "" || component == "." { + continue + } + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return errors.New(current) + } + } + return nil +} + +func validateCanvasSourceRelativePath(relativePath string) error { + if relativePath == "" || relativePath == "." || pathpkg.IsAbs(relativePath) { + return errors.New("path is not relative") + } + clean := pathpkg.Clean(relativePath) + if clean != relativePath || clean == ".." || strings.HasPrefix(clean, "../") { + return errors.New("path contains traversal") + } + return nil +} + +func estimateCanvasSourceWireBytes(entries []canvasSourceEntry) (int64, error) { + var total int64 + for _, entry := range entries { + header, err := tar.FileInfoHeader(entry.info, "") + if err != nil { + return 0, newCanvasSourceError("source_unavailable", "canvas source is unavailable", err) + } + header.Name = canvasSourceTarName(entry.info, entry.relativePath) + if entry.info.Mode().IsRegular() { + header.Size = 0 + } + count := &canvasSourceCountingWriter{} + writer := tar.NewWriter(count) + if err := writer.WriteHeader(header); err != nil { + return 0, newCanvasSourceError("invalid_source", "canvas source cannot be archived", err) + } + if err := writer.Close(); err != nil { + return 0, newCanvasSourceError("invalid_source", "canvas source cannot be archived", err) + } + if count.bytes < 1024 { + return 0, newCanvasSourceError("invalid_source", "canvas source cannot be archived", errors.New("invalid tar header")) + } + headerBytes := count.bytes - 1024 + dataBytes := int64(0) + if entry.info.Mode().IsRegular() { + dataBytes = ((entry.info.Size() + 511) / 512) * 512 + } + if total > int64(agentctltypes.MaxCanvasSourceWireBytes)-headerBytes-dataBytes { + return 0, newCanvasSourceLimitError(errors.New("tar stream exceeds the wire limit")) + } + total += headerBytes + dataBytes + } + if total > int64(agentctltypes.MaxCanvasSourceWireBytes)-1024 { + return 0, newCanvasSourceLimitError(errors.New("tar stream exceeds the wire limit")) + } + return total + 1024, nil +} + +func streamCanvasSource(ctx context.Context, output io.Writer, entries []canvasSourceEntry) error { + limited := &canvasSourceLimitedWriter{writer: output, limit: int64(agentctltypes.MaxCanvasSourceWireBytes)} + tarWriter := tar.NewWriter(limited) + var totalData int64 + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return err + } + if entry.info.IsDir() { + if err := writeCanvasSourceHeader(tarWriter, entry.info, entry.relativePath); err != nil { + return err + } + continue + } + + file, info, err := openCanvasSourceFile(entry) + if err != nil { + return err + } + header, err := tar.FileInfoHeader(info, "") + if err != nil { + _ = file.Close() + return err + } + header.Name = canvasSourceTarName(info, entry.relativePath) + header.Size = info.Size() + if err := tarWriter.WriteHeader(header); err != nil { + _ = file.Close() + return err + } + if err := copyCanvasSourceFile(ctx, tarWriter, file, info.Size(), &totalData); err != nil { + _ = file.Close() + return err + } + if current, statErr := file.Stat(); statErr != nil { + _ = file.Close() + return statErr + } else if current.Size() != info.Size() { + _ = file.Close() + return errors.New("canvas source changed during transfer") + } + if err := file.Close(); err != nil { + return err + } + } + if err := tarWriter.Close(); err != nil { + return err + } + if flusher, ok := output.(http.Flusher); ok { + flusher.Flush() + } + return nil +} + +func writeCanvasSourceHeader(writer *tar.Writer, info os.FileInfo, relativePath string) error { + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = canvasSourceTarName(info, relativePath) + return writer.WriteHeader(header) +} + +func canvasSourceTarName(info os.FileInfo, relativePath string) string { + if info.IsDir() && !strings.HasSuffix(relativePath, "/") { + return relativePath + "/" + } + return relativePath +} + +func openCanvasSourceFile(entry canvasSourceEntry) (*os.File, os.FileInfo, error) { + info, err := os.Lstat(entry.absolutePath) + if err != nil { + return nil, nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || !os.SameFile(info, entry.info) || info.Size() != entry.info.Size() { + return nil, nil, errors.New("canvas source changed or contains an unsupported entry") + } + file, err := os.Open(entry.absolutePath) + if err != nil { + return nil, nil, err + } + openedInfo, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, nil, err + } + if !openedInfo.Mode().IsRegular() || !os.SameFile(openedInfo, info) { + _ = file.Close() + return nil, nil, errors.New("canvas source changed during transfer") + } + return file, openedInfo, nil +} + +func copyCanvasSourceFile(ctx context.Context, writer io.Writer, file *os.File, size int64, totalData *int64) error { + buffer := make([]byte, 32*1024) + var copied int64 + for copied < size { + if err := ctx.Err(); err != nil { + return err + } + want := int64(len(buffer)) + if remaining := size - copied; remaining < want { + want = remaining + } + read, readErr := file.Read(buffer[:want]) + if read > 0 { + if *totalData > int64(agentctltypes.MaxCanvasSourceFileData)-int64(read) { + return errors.New("canvas source data limit exceeded") + } + written, writeErr := writer.Write(buffer[:read]) + if writeErr != nil { + return writeErr + } + if written != read { + return io.ErrShortWrite + } + copied += int64(read) + *totalData += int64(read) + } + if readErr != nil { + if errors.Is(readErr, io.EOF) && copied == size { + break + } + if errors.Is(readErr, io.EOF) { + return io.ErrUnexpectedEOF + } + return readErr + } + if read == 0 { + return io.ErrUnexpectedEOF + } + } + return nil +} + +type canvasSourceCountingWriter struct { + bytes int64 +} + +func (w *canvasSourceCountingWriter) Write(data []byte) (int, error) { + w.bytes += int64(len(data)) + return len(data), nil +} + +type canvasSourceLimitedWriter struct { + writer io.Writer + limit int64 + used int64 +} + +func (w *canvasSourceLimitedWriter) Write(data []byte) (int, error) { + if int64(len(data)) > w.limit-w.used { + return 0, errors.New("canvas source wire limit exceeded") + } + written, err := w.writer.Write(data) + w.used += int64(written) + return written, err +} diff --git a/apps/backend/internal/agentctl/server/api/workspace_canvas_source_test.go b/apps/backend/internal/agentctl/server/api/workspace_canvas_source_test.go new file mode 100644 index 0000000000..a11f735bf3 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/workspace_canvas_source_test.go @@ -0,0 +1,131 @@ +package api + +import ( + "archive/tar" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + "github.com/stretchr/testify/require" +) + +func newCanvasSourceTestServer(t *testing.T) (*Server, string) { + t.Helper() + workDir := t.TempDir() + log := newTestLogger() + cfg := &config.InstanceConfig{ + Port: 0, + WorkDir: workDir, + AuthToken: "canvas-secret", + InstanceID: "instance-1", + } + return NewServer(cfg, process.NewManager(cfg, log), nil, nil, log), workDir +} + +func canvasSourceRequest(t *testing.T, server *Server, root string, auth bool) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(agentctltypes.CanvasSourceTransferRequest{Root: root}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, agentctltypes.CanvasSourceTransferPath, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(InstanceIDHeader, "instance-1") + if auth { + req.Header.Set("Authorization", "Bearer canvas-secret") + } + response := httptest.NewRecorder() + server.router.ServeHTTP(response, req) + return response +} + +func readCanvasTar(t *testing.T, data []byte) map[string]string { + t.Helper() + reader := tar.NewReader(bytes.NewReader(data)) + entries := make(map[string]string) + for { + header, err := reader.Next() + if err == io.EOF { + return entries + } + require.NoError(t, err) + content, err := io.ReadAll(reader) + require.NoError(t, err) + entries[header.Name] = string(content) + } +} + +func TestCanvasSourceTransfer_RequiresBearerAuthentication(t *testing.T) { + server, workDir := newCanvasSourceTestServer(t) + require.NoError(t, os.Mkdir(filepath.Join(workDir, "source"), 0o755)) + + response := canvasSourceRequest(t, server, "source", false) + require.Equal(t, http.StatusUnauthorized, response.Code) +} + +func TestCanvasSourceTransfer_StreamsWorkspaceRelativeRoot(t *testing.T) { + server, workDir := newCanvasSourceTestServer(t) + require.NoError(t, os.MkdirAll(filepath.Join(workDir, "source", "assets"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "source", ".canvas-root"), nil, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "source", "index.html"), []byte("
ok
"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "source", "assets", "app.js"), []byte("console.log(1)"), 0o644)) + + response := canvasSourceRequest(t, server, "source", true) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.Equal(t, agentctltypes.CanvasSourceContentType, response.Header().Get("Content-Type")) + require.Equal(t, map[string]string{ + "index.html": "
ok
", + "assets/": "", + "assets/app.js": "console.log(1)", + }, readCanvasTar(t, response.Body.Bytes())) +} + +func TestCanvasSourceTransfer_RejectsTraversalAndSymlinks(t *testing.T) { + server, workDir := newCanvasSourceTestServer(t) + require.NoError(t, os.Mkdir(filepath.Join(workDir, "source"), 0o755)) + outside := filepath.Join(workDir, "outside.txt") + require.NoError(t, os.WriteFile(outside, []byte("secret"), 0o600)) + + for _, root := range []string{"../", filepath.Join(string(os.PathSeparator), "tmp", "outside")} { + response := canvasSourceRequest(t, server, root, true) + require.Equal(t, http.StatusBadRequest, response.Code, "root %q", root) + } + + require.NoError(t, os.Symlink(outside, filepath.Join(workDir, "source", "linked.txt"))) + response := canvasSourceRequest(t, server, "source", true) + require.Equal(t, http.StatusBadRequest, response.Code) + require.NotContains(t, response.Body.String(), "secret") +} + +func TestCanvasSourceTransfer_RejectsFileCountLimit(t *testing.T) { + server, workDir := newCanvasSourceTestServer(t) + root := filepath.Join(workDir, "source") + require.NoError(t, os.Mkdir(root, 0o755)) + for i := 0; i < agentctltypes.MaxCanvasSourceFiles+1; i++ { + name := filepath.Join(root, fmt.Sprintf("file-%04d.txt", i)) + require.NoError(t, os.WriteFile(name, nil, 0o644)) + } + + response := canvasSourceRequest(t, server, "source", true) + require.Equal(t, http.StatusRequestEntityTooLarge, response.Code) +} + +func TestCanvasSourceTransfer_RejectsFileDataLimit(t *testing.T) { + server, workDir := newCanvasSourceTestServer(t) + root := filepath.Join(workDir, "source") + require.NoError(t, os.Mkdir(root, 0o755)) + file, err := os.Create(filepath.Join(root, "large.bin")) + require.NoError(t, err) + require.NoError(t, file.Truncate(int64(agentctltypes.MaxCanvasSourceFileData+1))) + require.NoError(t, file.Close()) + + response := canvasSourceRequest(t, server, "source", true) + require.Equal(t, http.StatusRequestEntityTooLarge, response.Code) +} diff --git a/apps/backend/internal/agentctl/types/canvas_source.go b/apps/backend/internal/agentctl/types/canvas_source.go new file mode 100644 index 0000000000..b678f213cd --- /dev/null +++ b/apps/backend/internal/agentctl/types/canvas_source.go @@ -0,0 +1,29 @@ +package types + +const ( + // CanvasSourceTransferPath is the authenticated agentctl route used by the + // backend to stream an assigned canvas source root into release validation. + CanvasSourceTransferPath = "/api/v1/workspace/canvas-source" + CanvasSourceContentType = "application/x-tar" + + // These limits are shared by the agentctl server and the backend transfer + // client so local, Docker, and remote executors use one bounded contract. + MaxCanvasSourceFiles = 512 + MaxCanvasSourceFileData = 25 * 1024 * 1024 + MaxCanvasSourceWireBytes = 30 * 1024 * 1024 + MaxCanvasSourceRequestBytes = 64 * 1024 +) + +// CanvasSourceTransferRequest selects one workspace-relative source root. The +// server resolves and validates the root against its own agent workspace. +type CanvasSourceTransferRequest struct { + Root string `json:"root"` +} + +// CanvasSourceTransferError is returned as JSON before a transfer starts when +// the request is invalid or exceeds a source bound. A partially streamed tar +// is never represented as a successful JSON response. +type CanvasSourceTransferError struct { + Code string `json:"code"` + Message string `json:"message"` +} diff --git a/apps/backend/internal/auth/httpmw/middleware.go b/apps/backend/internal/auth/httpmw/middleware.go index 0548a13acd..62ba2215d4 100644 --- a/apps/backend/internal/auth/httpmw/middleware.go +++ b/apps/backend/internal/auth/httpmw/middleware.go @@ -367,6 +367,7 @@ func isPublicPath(method, path string) bool { func isDeferredPath(c *gin.Context, path string) bool { switch { case path == "/ws", + strings.HasPrefix(path, "/api/v1/plugins/web-apps/runtime/"), strings.HasPrefix(path, "/terminal/"), strings.HasPrefix(path, "/lsp/"), strings.HasPrefix(path, "/vscode/"), diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 7875f4b030..563fab46e7 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "sync" "time" @@ -24,9 +25,198 @@ import ( "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/worktree/copyfiles" "github.com/kandev/kandev/pkg/api/v1" ) +// canvasAgentCtlClient is the small agentctl surface shared by canvas +// authoring and browser-initiated canvas editing. The concrete runtime client +// stays behind this adapter so higher-level services do not couple to the +// lifecycle or agentctl implementation packages. +type canvasAgentCtlClient interface { + CreateFile(context.Context, string, string) (*streams.FileCreateResponse, error) + ApplyFileDiff(context.Context, string, string, string, string, *string) (*streams.FileUpdateResponse, error) + DeleteFile(context.Context, string, string) (*streams.FileDeleteResponse, error) + RenameFile(context.Context, string, string, string) (*streams.FileRenameResponse, error) + StreamCanvasSource(context.Context, string) (io.ReadCloser, error) + CopyFiles(context.Context, string, []copyfiles.Entry) (canvasCopyFilesResult, error) +} + +type canvasCopyFilesResult struct { + Warnings []string + Present bool +} + +type canvasEditAgentCtl interface { + CopyFiles(context.Context, string, []copyfiles.Entry) (canvasCopyFilesResult, error) +} + +type canvasAgentExecution struct { + TaskID string + SessionID string + client canvasAgentCtlClient +} + +func (e *canvasAgentExecution) GetAgentCtlClient() canvasAgentCtlClient { + if e == nil { + return nil + } + return e.client +} + +type canvasExecutionResolver interface { + ResolveCanvasExecution(string) (*canvasAgentExecution, error) +} + +type canvasEditExecutionResolver interface { + ResolveAgentCtl(string) (canvasEditAgentCtl, error) +} + +// lifecycleCanvasExecutionResolver is the approved low-level adapter from +// the lifecycle manager to the canvas services. Keep direct runtime imports +// here, at the boundary, instead of spreading them through feature code. +type lifecycleCanvasExecutionResolver struct { + manager *lifecycle.Manager +} + +func (r lifecycleCanvasExecutionResolver) ResolveCanvasExecution(executionID string) (*canvasAgentExecution, error) { + if r.manager == nil || executionID == "" { + return nil, errors.New("agent execution is unavailable") + } + execution, ok := r.manager.GetExecution(executionID) + if !ok || execution == nil { + return nil, errors.New("agent execution is unavailable") + } + client, release := execution.AcquireAgentCtlClient() + if client == nil { + release() + return nil, errors.New("agent execution is unavailable") + } + release() + return &canvasAgentExecution{ + TaskID: execution.TaskID, SessionID: execution.SessionID, + client: canvasAgentCtlAdapter{acquire: execution.AcquireAgentCtlClient}, + }, nil +} + +func (r lifecycleCanvasExecutionResolver) ResolveAgentCtl(executionID string) (canvasEditAgentCtl, error) { + execution, err := r.ResolveCanvasExecution(executionID) + if err != nil { + return nil, err + } + return execution.GetAgentCtlClient(), nil +} + +type canvasAgentCtlAdapter struct { + client *client.Client + acquire func() (*client.Client, func()) +} + +func (a canvasAgentCtlAdapter) acquireClient() (*client.Client, func(), error) { + if a.acquire != nil { + client, release := a.acquire() + if client == nil { + release() + return nil, func() {}, errors.New("agentctl client is unavailable") + } + return client, release, nil + } + if a.client == nil { + return nil, func() {}, errors.New("agentctl client is unavailable") + } + return a.client, func() {}, nil +} + +func (a canvasAgentCtlAdapter) CreateFile(ctx context.Context, path, repo string) (*streams.FileCreateResponse, error) { + client, release, err := a.acquireClient() + if err != nil { + return nil, err + } + defer release() + return client.CreateFile(ctx, path, repo) +} + +func (a canvasAgentCtlAdapter) ApplyFileDiff(ctx context.Context, path, diff, originalHash, repo string, desiredContent *string) (*streams.FileUpdateResponse, error) { + client, release, err := a.acquireClient() + if err != nil { + return nil, err + } + defer release() + return client.ApplyFileDiff(ctx, path, diff, originalHash, repo, desiredContent) +} + +func (a canvasAgentCtlAdapter) DeleteFile(ctx context.Context, path, repo string) (*streams.FileDeleteResponse, error) { + client, release, err := a.acquireClient() + if err != nil { + return nil, err + } + defer release() + return client.DeleteFile(ctx, path, repo) +} + +func (a canvasAgentCtlAdapter) RenameFile(ctx context.Context, oldPath, newPath, repo string) (*streams.FileRenameResponse, error) { + client, release, err := a.acquireClient() + if err != nil { + return nil, err + } + defer release() + return client.RenameFile(ctx, oldPath, newPath, repo) +} + +func (a canvasAgentCtlAdapter) StreamCanvasSource(ctx context.Context, root string) (io.ReadCloser, error) { + client, release, err := a.acquireClient() + if err != nil { + return nil, err + } + stream, err := client.StreamCanvasSource(ctx, root) + if err != nil { + release() + return nil, err + } + if stream == nil { + release() + return nil, errors.New("agentctl returned an empty source stream") + } + return &canvasSourceReadCloser{ReadCloser: stream, release: release}, nil +} + +func (a canvasAgentCtlAdapter) CopyFiles(ctx context.Context, repo string, entries []copyfiles.Entry) (canvasCopyFilesResult, error) { + client, release, err := a.acquireClient() + if err != nil { + return canvasCopyFilesResult{}, err + } + defer release() + response, err := client.CopyFiles(ctx, repo, entries) + if response == nil { + return canvasCopyFilesResult{}, err + } + return canvasCopyFilesResult{Warnings: append([]string(nil), response.Warnings...), Present: true}, err +} + +type canvasSourceReadCloser struct { + io.ReadCloser + release func() + once sync.Once +} + +func (r *canvasSourceReadCloser) releaseClient() { + r.once.Do(r.release) +} + +func (r *canvasSourceReadCloser) Read(p []byte) (int, error) { + n, err := r.ReadCloser.Read(p) + if err != nil { + r.releaseClient() + } + return n, err +} + +func (r *canvasSourceReadCloser) Close() error { + err := r.ReadCloser.Close() + r.releaseClient() + return err +} + // taskGetterRepo is the minimal interface needed by the scheduler adapter. type taskGetterRepo interface { GetTask(ctx context.Context, id string) (*models.Task, error) diff --git a/apps/backend/internal/backendapp/canvas_authoring.go b/apps/backend/internal/backendapp/canvas_authoring.go new file mode 100644 index 0000000000..d055555f86 --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_authoring.go @@ -0,0 +1,723 @@ +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/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 := s.workspaceOwner(ctx, task.WorkspaceID) + created, err := s.canvases.CreateCanvas(ctx, canvas.CreateCanvasRequest{ + WorkspaceID: task.WorkspaceID, TaskID: task.ID, + OriginTaskID: task.ID, Title: request.Title, + CreatedBySessionID: taskSessionID(request.Agent), + }) + 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, + "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 := instances.PublishAuthority{ + InstanceID: item.PluginInstanceID, ScopeKind: item.ScopeKind, + WorkspaceID: item.WorkspaceID, TaskID: item.TaskID, + Status: item.Status, ActiveReleaseID: item.ActiveReleaseID, + GrantGeneration: item.GrantGeneration, + } + expectedBaseReleaseID := "" + if editSession { + expectedBaseReleaseID = target.ReleaseID + } + return s.publishCanvasSource(ctx, execution, task, item, request, expectedAuthority, expectedBaseReleaseID) +} + +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) + } + // 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: s.workspaceOwner(ctx, task.WorkspaceID), + 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 { + if s.tasks != nil { + if workspace, err := s.tasks.GetWorkspace(ctx, workspaceID); err == nil && workspace != nil && workspace.OwnerID != "" { + return workspace.OwnerID + } + } + return userstore.DefaultUserID +} + +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 +} diff --git a/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go b/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go new file mode 100644 index 0000000000..91b2244c76 --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_authoring_scaffold_test.go @@ -0,0 +1,311 @@ +package backendapp + +import ( + "context" + "encoding/json" + "errors" + "io" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/canvas" + "github.com/kandev/kandev/internal/mcp/canvasskill" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/worktree/copyfiles" + "github.com/stretchr/testify/require" +) + +func TestCanvasScaffoldFiles_AreExactAndPublishable(t *testing.T) { + item := &canvas.Canvas{ + ID: "1234567890abcdef", + Title: "Release dashboard", + WorkspaceID: "workspace-1", + } + + files, err := canvasScaffoldFiles(item, "Shows deployment health.") + require.NoError(t, err) + require.Equal(t, canvasskill.ScaffoldInventory(), scaffoldFilePaths(files)) + + parsed, err := manifest.Parse(files[0].Content) + require.NoError(t, err) + require.NoError(t, parsed.Validate()) + require.Equal(t, "canvas-1234567890ab", parsed.ID) + require.Equal(t, "index.html", parsed.UI.WebApps[0].Entry) + + byPath := make(map[string]string, len(files)) + for _, file := range files { + byPath[file.Path] = string(file.Content) + } + require.Contains(t, byPath["index.html"], "./appearance.js") + require.Contains(t, byPath["index.html"], "./script.js") + require.Contains(t, byPath["appearance.js"], "window.parent") + require.Contains(t, byPath["appearance.js"], "kandev.web_app.appearance") + require.Contains(t, byPath["appearance.js"], "colorScheme") + require.Contains(t, byPath["styles.css"], "--background") + require.Contains(t, byPath["styles.css"], "color-scheme: light") + require.NotContains(t, byPath["styles.css"], "#9eb4ff") + require.Contains(t, byPath["script.js"], "./_kandev/v1/context") +} + +func TestCanvasCoreBundle_ContainsOneReadContract(t *testing.T) { + home := t.TempDir() + require.NoError(t, canvasskill.EnsureMaterialized(home)) + + bundle, err := canvasCoreBundle(home) + require.NoError(t, err) + require.Equal(t, canvasskill.CoreInventory(), bundle["core_inventory"]) + require.Equal(t, canvasskill.Inventory(), bundle["inventory"]) + require.Equal(t, canvasskill.ScaffoldInventory(), bundle["scaffold_inventory"]) + require.Equal(t, canvasskill.Version, bundle["version"]) + require.Contains(t, bundle["content"], "kandev.web_app.appearance") + + core, ok := bundle["core"].(map[string]any) + require.True(t, ok) + require.Equal(t, canvasskill.CoreInventory(), core["inventory"]) + files, ok := core["files"].([]map[string]string) + require.True(t, ok) + require.Len(t, files, len(canvasskill.CoreInventory())) + require.Equal(t, "2", canvasskill.Version) +} + +func TestCanvasCreateResponseCarriesCurrentAuthoringVersion(t *testing.T) { + item := &canvas.Canvas{ID: "canvas-1", Title: "Canvas", WorkspaceID: "workspace-1"} + response := canvasCreateResponse(item, canvasSourceRoot(item.ID), "owner-1", []canvasScaffoldFile{{ + Path: "manifest.yaml", Content: []byte("id: canvas-1\n"), + }}) + + skill, ok := response["skill"].(map[string]any) + require.True(t, ok) + require.Equal(t, canvasskill.Version, skill["version"]) + require.Equal(t, "2", skill["version"]) +} + +func TestCanvasCreateResponseShapeIsExecutorPortable(t *testing.T) { + item := &canvas.Canvas{ID: "canvas-portable", Title: "Canvas", WorkspaceID: "workspace-1"} + scaffold := []canvasScaffoldFile{{ + Path: "manifest.yaml", Content: []byte("id: canvas-portable\n"), + }} + var baseline []byte + for _, executor := range []string{"local", "docker", "ssh"} { + t.Run(executor, func(t *testing.T) { + encoded, err := json.Marshal(canvasCreateResponse( + item, + canvasSourceRoot(item.ID), + "owner-1", + scaffold, + )) + require.NoError(t, err) + if baseline == nil { + baseline = encoded + return + } + require.JSONEq(t, string(baseline), string(encoded)) + }) + } +} + +func TestMaterializeCanvasScaffold_UsesAuthenticatedFileWrites(t *testing.T) { + client := &recordingCanvasAgentCtlClient{} + files := []canvasScaffoldFile{{Path: "index.html", Content: []byte("
ok
\n")}} + + require.NoError(t, materializeCanvasScaffold(context.Background(), client, ".kandev/canvases/canvas-1", files)) + require.Equal(t, []string{".kandev/canvases/canvas-1/index.html"}, client.created) + require.Len(t, client.updated, 1) + require.Equal(t, ".kandev/canvases/canvas-1/index.html", client.updated[0].path) + require.Equal(t, "
ok
\n", client.updated[0].content) + require.Contains(t, client.updated[0].diff, "+
ok
") +} + +func TestCanvasScaffoldDiffUsesFullInsertionRange(t *testing.T) { + diff := canvasScaffoldDiff(".kandev/canvases/canvas-1/script.js", []byte("line one\nline two\nline three\n")) + require.Contains(t, diff, "@@ -0,0 +1,3 @@") + require.Contains(t, diff, "+line one\n+line two\n+line three\n") +} + +type recordingCanvasAgentCtlClient struct { + created []string + updated []recordedCanvasFileUpdate + files map[string]string +} + +type recordedCanvasFileUpdate struct { + path string + content string + diff string +} + +func (c *recordingCanvasAgentCtlClient) CreateFile(_ context.Context, path, _ string) (*streams.FileCreateResponse, error) { + c.created = append(c.created, path) + if c.files == nil { + c.files = make(map[string]string) + } + c.files[path] = "" + return &streams.FileCreateResponse{Path: path, Success: true}, nil +} + +func (c *recordingCanvasAgentCtlClient) ApplyFileDiff( + _ context.Context, + path, diff, _, _ string, + desiredContent *string, +) (*streams.FileUpdateResponse, error) { + content := "" + if desiredContent != nil { + content = *desiredContent + } + c.updated = append(c.updated, recordedCanvasFileUpdate{path: path, content: content, diff: diff}) + if c.files == nil { + c.files = make(map[string]string) + } + c.files[path] = content + return &streams.FileUpdateResponse{Path: path, Success: true}, nil +} + +func (c *recordingCanvasAgentCtlClient) DeleteFile(_ context.Context, path, _ string) (*streams.FileDeleteResponse, error) { + for file := range c.files { + if file == path || strings.HasPrefix(file, filepath.ToSlash(filepath.Join(path, ""))+"/") { + delete(c.files, file) + } + } + return &streams.FileDeleteResponse{Path: path, Success: true}, nil +} + +func (c *recordingCanvasAgentCtlClient) RenameFile(_ context.Context, oldPath, newPath, _ string) (*streams.FileRenameResponse, error) { + if c.files == nil { + c.files = make(map[string]string) + } + oldPrefix := filepath.ToSlash(filepath.Join(oldPath, "")) + for file, content := range c.files { + if file != oldPath && !strings.HasPrefix(file, oldPrefix) { + continue + } + relative := strings.TrimPrefix(file, oldPath) + delete(c.files, file) + c.files[newPath+relative] = content + } + return &streams.FileRenameResponse{OldPath: oldPath, NewPath: newPath, Success: true}, nil +} + +func (c *recordingCanvasAgentCtlClient) StreamCanvasSource(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("")), nil +} + +func (c *recordingCanvasAgentCtlClient) CopyFiles(context.Context, string, []copyfiles.Entry) (canvasCopyFilesResult, error) { + return canvasCopyFilesResult{}, nil +} + +func scaffoldFilePaths(files []canvasScaffoldFile) []string { + paths := make([]string, 0, len(files)) + for _, file := range files { + paths = append(paths, file.Path) + } + return paths +} + +func TestMaterializeCanvasScaffoldAtomicallyRollsBackEveryBoundary(t *testing.T) { + files := []canvasScaffoldFile{ + {Path: "index.html", Content: []byte("
ok
\n")}, + {Path: "styles.css", Content: []byte("main {}\n")}, + } + + for _, testCase := range []struct { + name string + fail func(*atomicCanvasAgentCtlClient) + }{ + {name: "marker create", fail: func(client *atomicCanvasAgentCtlClient) { client.failCreateAt = 1 }}, + {name: "scaffold create", fail: func(client *atomicCanvasAgentCtlClient) { client.failCreateAt = 2 }}, + {name: "scaffold write", fail: func(client *atomicCanvasAgentCtlClient) { client.failApplyAt = 1 }}, + {name: "final rename", fail: func(client *atomicCanvasAgentCtlClient) { client.failRename = true }}, + } { + t.Run(testCase.name, func(t *testing.T) { + client := &atomicCanvasAgentCtlClient{files: make(map[string]string)} + testCase.fail(client) + + err := materializeCanvasScaffoldAtomically(context.Background(), client, "canvas-atomic", files) + require.Error(t, err) + require.Empty(t, client.files, "failed canvas creation must not leave source files") + require.ElementsMatch(t, []string{ + canvasStagingRoot("canvas-atomic"), + canvasSourceRoot("canvas-atomic"), + }, client.deleted) + }) + } +} + +func TestMaterializeCanvasScaffoldAtomicallyRenamesOnlyAfterAllWrites(t *testing.T) { + client := &atomicCanvasAgentCtlClient{files: make(map[string]string)} + files := []canvasScaffoldFile{{Path: "index.html", Content: []byte("ok\n")}} + + require.NoError(t, materializeCanvasScaffoldAtomically(context.Background(), client, "canvas-atomic", files)) + require.Empty(t, client.deleted) + require.Contains(t, client.files, filepath.ToSlash(filepath.Join(canvasSourceRoot("canvas-atomic"), ".canvas-root"))) + require.Contains(t, client.files, filepath.ToSlash(filepath.Join(canvasSourceRoot("canvas-atomic"), "index.html"))) + for path := range client.files { + require.NotContains(t, path, ".staging") + } +} + +type atomicCanvasAgentCtlClient struct { + files map[string]string + deleted []string + createCount int + applyCount int + failCreateAt int + failApplyAt int + failRename bool +} + +func (c *atomicCanvasAgentCtlClient) CreateFile(_ context.Context, path, _ string) (*streams.FileCreateResponse, error) { + c.createCount++ + if c.createCount == c.failCreateAt { + return nil, errors.New("create failed") + } + c.files[path] = "" + return &streams.FileCreateResponse{Path: path, Success: true}, nil +} + +func (c *atomicCanvasAgentCtlClient) ApplyFileDiff(_ context.Context, path, _, _, _ string, desiredContent *string) (*streams.FileUpdateResponse, error) { + c.applyCount++ + if c.applyCount == c.failApplyAt { + return nil, errors.New("write failed") + } + if desiredContent != nil { + c.files[path] = *desiredContent + } + return &streams.FileUpdateResponse{Path: path, Success: true}, nil +} + +func (c *atomicCanvasAgentCtlClient) DeleteFile(_ context.Context, path, _ string) (*streams.FileDeleteResponse, error) { + c.deleted = append(c.deleted, path) + for file := range c.files { + if file == path || strings.HasPrefix(file, filepath.ToSlash(filepath.Join(path, ""))+"/") { + delete(c.files, file) + } + } + return &streams.FileDeleteResponse{Path: path, Success: true}, nil +} + +func (c *atomicCanvasAgentCtlClient) RenameFile(_ context.Context, oldPath, newPath, _ string) (*streams.FileRenameResponse, error) { + if c.failRename { + return nil, errors.New("rename failed") + } + oldPrefix := filepath.ToSlash(filepath.Join(oldPath, "")) + for file, content := range c.files { + if file != oldPath && !strings.HasPrefix(file, oldPrefix) { + continue + } + relative := strings.TrimPrefix(file, oldPath) + delete(c.files, file) + c.files[newPath+relative] = content + } + return &streams.FileRenameResponse{OldPath: oldPath, NewPath: newPath, Success: true}, nil +} + +func (c *atomicCanvasAgentCtlClient) StreamCanvasSource(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("")), nil +} + +func (c *atomicCanvasAgentCtlClient) CopyFiles(context.Context, string, []copyfiles.Entry) (canvasCopyFilesResult, error) { + return canvasCopyFilesResult{}, nil +} diff --git a/apps/backend/internal/backendapp/canvas_edit_test.go b/apps/backend/internal/backendapp/canvas_edit_test.go new file mode 100644 index 0000000000..8fd9604371 --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_edit_test.go @@ -0,0 +1,343 @@ +package backendapp + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/canvas" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/webapp" + "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/worktree/copyfiles" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func TestCanvasEditTargetDoesNotAuthorizeAnotherCanvas(t *testing.T) { + target := canvasEditSessionTarget{ + Origin: canvasEditOrigin, + TaskID: "task-edit", + CanvasID: "canvas-a", + ReleaseID: "release-a", + } + + if !canvasEditTargetMatches(target, "task-edit", "canvas-a") { + t.Fatal("expected the trusted target to authorize its canvas") + } + if canvasEditTargetMatches(target, "task-edit", "canvas-b") { + t.Fatal("trusted edit target authorized a different canvas") + } + if canvasEditTargetMatches(target, "other-task", "canvas-a") { + t.Fatal("trusted edit target authorized a different task") + } +} + +func TestTaskCanvasAuthorizationUsesTaskIdentityNotCreatorSession(t *testing.T) { + task := &models.Task{ID: "task-current", WorkspaceID: "workspace-a"} + item := &canvas.Canvas{ + ID: "canvas-task", + WorkspaceID: task.WorkspaceID, + TaskID: task.ID, + ScopeKind: canvas.ScopeTask, + CreatedBySessionID: "replacement-session-source", + } + if !taskCanvasMatchesTask(item, task) { + t.Fatal("same-task replacement session was denied by creator-session provenance") + } + item.TaskID = "other-task" + if taskCanvasMatchesTask(item, task) { + t.Fatal("canvas from another task was authorized") + } + item.TaskID = task.ID + item.WorkspaceID = "other-workspace" + if taskCanvasMatchesTask(item, task) { + t.Fatal("canvas from another workspace was authorized") + } +} + +func TestCanvasEditSourceEntriesUseAssignedCanvasRoot(t *testing.T) { + entries := canvasEditSourceEntries("canvas-a", map[string][]byte{ + "manifest.yaml": []byte("manifest"), + "index.html": []byte("index"), + }) + + got := make(map[string][]byte, len(entries)) + for _, entry := range entries { + got[entry.RelPath] = entry.Content + if entry.Mode.Perm() != 0o600 { + t.Errorf("entry %q mode = %o, want 600", entry.RelPath, entry.Mode.Perm()) + } + } + if string(got[".kandev/canvases/canvas-a/manifest.yaml"]) != "manifest" { + t.Fatalf("manifest was not rooted in the assigned canvas directory: %#v", got) + } + if string(got[".kandev/canvases/canvas-a/index.html"]) != "index" { + t.Fatalf("index was not rooted in the assigned canvas directory: %#v", got) + } +} + +func TestRuntimeBindingPermissionsIntersectCurrentGrants(t *testing.T) { + declared := &manifest.Manifest{Capabilities: manifest.Capabilities{ + APIRead: []string{"tasks", "workflows"}, + APIWrite: []string{"messages"}, + State: true, + }} + grants := []instances.Grant{ + {PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeWorkspace}, + {PermissionKind: "api_write", Resource: "messages", ScopeCeiling: instances.ScopeTask}, + } + + got := runtimeGrantedPermissions(declared, instances.ScopeWorkspace, grants) + if len(got) != 1 || got[0] != "api_read:tasks" { + t.Fatalf("runtime permissions = %#v, want only declared permissions covered by grants", got) + } +} + +func TestRuntimeNetworkOriginsUseSelectedWebAppDeclaration(t *testing.T) { + app := manifest.WebApp{NetworkOrigins: []string{ + "https://api.example.com", + "https://unused.example.com", + }} + grants := []instances.Grant{ + {PermissionKind: "network", NetworkOrigin: "https://api.example.com", ScopeCeiling: instances.ScopeTask}, + {PermissionKind: "network", NetworkOrigin: "https://unrelated.example.com", ScopeCeiling: instances.ScopeTask}, + } + got := runtimeNetworkOrigins(app, instances.ScopeTask, grants) + if len(got) != 1 || got[0] != "https://api.example.com" { + t.Fatalf("runtime network origins = %#v, want only the declared granted origin", got) + } +} + +func TestCanvasEditLaunchFailureDeletesEphemeralTask(t *testing.T) { + tasks, canvasReader, releases, artifacts := newCanvasEditTestDependencies() + launcher := &fakeCanvasEditLauncher{launchErr: errors.New("agent unavailable")} + service := newCanvasEditService( + tasks, canvasReader, releases, artifacts, launcher, + &fakeCanvasEditSessionStore{}, &fakeCanvasEditExecutionResolver{}, + ) + + if _, err := service.start(context.Background(), "canvas-a", "change the title"); err == nil { + t.Fatal("expected launch failure") + } + if len(tasks.deleted) != 1 || tasks.deleted[0] != "task-edit" { + t.Fatalf("deleted tasks = %#v, want the newly-created ephemeral task", tasks.deleted) + } +} + +func TestCanvasEditMaterializesSourceAndDispatchesPrompt(t *testing.T) { + tasks, canvasReader, releases, artifacts := newCanvasEditTestDependencies() + launcher := &fakeCanvasEditLauncher{ + launchResponse: &orchestrator.LaunchSessionResponse{ + Success: true, + TaskID: "task-edit", + SessionID: "session-edit", + AgentExecutionID: "execution-edit", + }, + } + sessions := &fakeCanvasEditSessionStore{} + copier := &fakeCanvasEditAgentCtl{} + resolver := &fakeCanvasEditExecutionResolver{client: copier} + service := newCanvasEditService(tasks, canvasReader, releases, artifacts, launcher, sessions, resolver) + + result, err := service.start(context.Background(), "canvas-a", "change the title") + if err != nil { + t.Fatalf("start: %v", err) + } + if result.TaskID != "task-edit" || result.SessionID != "session-edit" || result.CanvasID != "canvas-a" { + t.Fatalf("result = %#v, want task/session/canvas IDs", result) + } + if tasks.createRequest == nil || !tasks.createRequest.IsEphemeral || tasks.createRequest.Origin != canvasEditOrigin { + t.Fatalf("create request = %#v, want ephemeral canvas-edit task", tasks.createRequest) + } + if tasks.createRequest.Metadata["origin"] != canvasEditOrigin || tasks.createRequest.Metadata["canvas_id"] != "canvas-a" { + t.Fatalf("task metadata = %#v, want canvas edit origin and canvas", tasks.createRequest.Metadata) + } + if launcher.launchRequest == nil { + t.Fatal("launch request was not recorded") + } + if launcher.launchRequest.AgentProfileID != "profile-default" || launcher.launchRequest.ExecutorID != "executor-default" { + t.Fatalf("launch defaults = profile %q executor %q", launcher.launchRequest.AgentProfileID, launcher.launchRequest.ExecutorID) + } + if launcher.prompt == "" || !containsCanvasEditText(launcher.prompt, "change the title") { + t.Fatalf("prompt = %q, want requested edit", launcher.prompt) + } + if !containsCanvasEditText(launcher.prompt, "api_read:tasks") || !containsCanvasEditText(launcher.prompt, "scope workspace") { + t.Fatalf("prompt = %q, want the effective grant projection", launcher.prompt) + } + target, ok := sessions.values[canvasEditSessionTargetMetadataKey].(canvasEditSessionTarget) + if !ok || !canvasEditTargetMatches(target, "task-edit", "canvas-a") { + t.Fatalf("trusted target metadata = %#v", sessions.values[canvasEditSessionTargetMetadataKey]) + } + if len(copier.entries) != 2 { + t.Fatalf("copied entries = %#v, want active release source", copier.entries) + } + for _, entry := range copier.entries { + if entry.RelPath != ".kandev/canvases/canvas-a/index.html" && entry.RelPath != ".kandev/canvases/canvas-a/manifest.yaml" { + t.Fatalf("copied entry %q escaped assigned canvas root", entry.RelPath) + } + } +} + +func TestCanvasEditMaterializationFailureDeletesEphemeralTask(t *testing.T) { + tasks, canvasReader, releases, artifacts := newCanvasEditTestDependencies() + launcher := &fakeCanvasEditLauncher{ + launchResponse: &orchestrator.LaunchSessionResponse{ + Success: true, + TaskID: "task-edit", + SessionID: "session-edit", + AgentExecutionID: "execution-edit", + }, + } + resolver := &fakeCanvasEditExecutionResolver{client: &fakeCanvasEditAgentCtl{err: errors.New("copy failed")}} + service := newCanvasEditService( + tasks, canvasReader, releases, artifacts, launcher, + &fakeCanvasEditSessionStore{}, resolver, + ) + + if _, err := service.start(context.Background(), "canvas-a", "change the title"); err == nil { + t.Fatal("expected source materialization failure") + } + if len(tasks.deleted) != 1 || tasks.deleted[0] != "task-edit" { + t.Fatalf("deleted tasks = %#v, want the newly-created ephemeral task", tasks.deleted) + } +} + +func containsCanvasEditText(value, want string) bool { + return len(value) >= len(want) && (value == want || len(value) > len(want) && stringContains(value, want)) +} + +func stringContains(value, want string) bool { + for i := 0; i+len(want) <= len(value); i++ { + if value[i:i+len(want)] == want { + return true + } + } + return false +} + +func newCanvasEditTestDependencies() (*fakeCanvasEditTaskStore, *fakeCanvasEditCanvasStore, *fakeCanvasEditReleaseStore, *fakeCanvasEditArtifactStore) { + return &fakeCanvasEditTaskStore{ + workspace: &models.Workspace{ + ID: "workspace-a", + DefaultAgentProfileID: stringPointer("profile-default"), + DefaultExecutorID: stringPointer("executor-default"), + }, + }, &fakeCanvasEditCanvasStore{canvas: &canvas.Canvas{ + ID: "canvas-a", + PluginInstanceID: "instance-a", + WorkspaceID: "workspace-a", + ScopeKind: canvas.ScopeWorkspace, + Status: instances.StatusActive, + ActiveReleaseID: "release-a", + ActiveReleaseStatus: instances.ValidationValid, + EffectiveGrants: []canvas.GrantProjection{{ + PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeWorkspace, + }}, + }}, &fakeCanvasEditReleaseStore{release: instances.Release{ + ID: "release-a", + InstanceID: "instance-a", + PackageDigest: "digest-a", + ArtifactPath: "releases/digest-a", + ValidationStatus: instances.ValidationValid, + }}, &fakeCanvasEditArtifactStore{files: map[string][]byte{ + "manifest.yaml": []byte("manifest"), + "index.html": []byte("index"), + }} +} + +func stringPointer(value string) *string { return &value } + +type fakeCanvasEditTaskStore struct { + workspace *models.Workspace + createRequest *taskservice.CreateTaskRequest + deleted []string +} + +func (f *fakeCanvasEditTaskStore) AuthorizeWorkspaceAccess(context.Context, string) error { return nil } + +func (f *fakeCanvasEditTaskStore) GetWorkspace(context.Context, string) (*models.Workspace, error) { + return f.workspace, nil +} + +func (f *fakeCanvasEditTaskStore) CreateTask(_ context.Context, request *taskservice.CreateTaskRequest) (taskservice.CreateTaskResult, error) { + f.createRequest = request + return taskservice.CreateTaskResult{ + Task: &models.Task{ID: "task-edit", WorkspaceID: "workspace-a"}, + Outcome: taskservice.CreateTaskOutcomeCreated, + }, nil +} + +func (f *fakeCanvasEditTaskStore) DeleteTask(_ context.Context, taskID string) error { + f.deleted = append(f.deleted, taskID) + return nil +} + +type fakeCanvasEditCanvasStore struct{ canvas *canvas.Canvas } + +func (f *fakeCanvasEditCanvasStore) Get(context.Context, string) (*canvas.Canvas, error) { + return f.canvas, nil +} + +type fakeCanvasEditReleaseStore struct{ release instances.Release } + +func (f *fakeCanvasEditReleaseStore) GetRelease(context.Context, string) (instances.Release, error) { + return f.release, nil +} + +type fakeCanvasEditArtifactStore struct{ files map[string][]byte } + +func (f *fakeCanvasEditArtifactStore) ReadFiles(webapp.Artifact) (map[string][]byte, error) { + return f.files, nil +} + +type fakeCanvasEditLauncher struct { + launchErr error + launchResponse *orchestrator.LaunchSessionResponse + launchRequest *orchestrator.LaunchSessionRequest + prompt string +} + +func (f *fakeCanvasEditLauncher) LaunchSession(_ context.Context, request *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) { + f.launchRequest = request + return f.launchResponse, f.launchErr +} + +func (f *fakeCanvasEditLauncher) PromptTask(_ context.Context, _, _, prompt, _ string, _ bool, _ []v1.MessageAttachment, _ bool) (*orchestrator.PromptResult, error) { + f.prompt = prompt + return &orchestrator.PromptResult{}, nil +} + +type fakeCanvasEditSessionStore struct{ values map[string]interface{} } + +func (f *fakeCanvasEditSessionStore) SetSessionMetadataKey(_ context.Context, _, key string, value interface{}) error { + if f.values == nil { + f.values = make(map[string]interface{}) + } + f.values[key] = value + return nil +} + +type fakeCanvasEditExecutionResolver struct{ client canvasEditAgentCtl } + +func (f *fakeCanvasEditExecutionResolver) ResolveAgentCtl(string) (canvasEditAgentCtl, error) { + if f.client == nil { + return nil, errors.New("agentctl unavailable") + } + return f.client, nil +} + +type fakeCanvasEditAgentCtl struct { + entries []copyfiles.Entry + err error +} + +func (f *fakeCanvasEditAgentCtl) CopyFiles(_ context.Context, _ string, entries []copyfiles.Entry) (canvasCopyFilesResult, error) { + f.entries = append([]copyfiles.Entry(nil), entries...) + if f.err != nil { + return canvasCopyFilesResult{}, f.err + } + return canvasCopyFilesResult{Present: true}, nil +} diff --git a/apps/backend/internal/backendapp/canvas_routes.go b/apps/backend/internal/backendapp/canvas_routes.go new file mode 100644 index 0000000000..7e1e64f8f9 --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_routes.go @@ -0,0 +1,838 @@ +package backendapp + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + + canvasservice "github.com/kandev/kandev/internal/canvas" + "github.com/kandev/kandev/internal/common/constants" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/plugins" + "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/webapp" + "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/worktree/copyfiles" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// canvasHTTPHandler is the owner-authorized browser API for canvas metadata +// and host operations. Source transfer and publishing stay on the agent MCP +// path; browsers receive only metadata and short-lived runtime capabilities. +type canvasHTTPHandler struct { + canvases *canvasservice.Service + plugins *plugins.Service + tasks *taskservice.Service + editor *canvasEditService +} + +type canvasEditTaskStore interface { + AuthorizeWorkspaceAccess(context.Context, string) error + GetWorkspace(context.Context, string) (*models.Workspace, error) + CreateTask(context.Context, *taskservice.CreateTaskRequest) (taskservice.CreateTaskResult, error) + DeleteTask(context.Context, string) error +} + +type canvasEditCanvasStore interface { + Get(context.Context, string) (*canvasservice.Canvas, error) +} + +type canvasEditReleaseStore interface { + GetRelease(context.Context, string) (instances.Release, error) +} + +type canvasEditArtifactStore interface { + ReadFiles(webapp.Artifact) (map[string][]byte, error) +} + +type canvasEditSessionStore interface { + SetSessionMetadataKey(context.Context, string, string, interface{}) error +} + +type canvasEditSessionLauncher interface { + LaunchSession(context.Context, *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) + PromptTask(context.Context, string, string, string, string, bool, []v1.MessageAttachment, bool) (*orchestrator.PromptResult, error) +} + +type canvasEditResult struct { + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + CanvasID string `json:"canvas_id"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` +} + +type canvasEditError struct { + status int + code string + message string + cause error +} + +func (e *canvasEditError) Error() string { + if e == nil { + return "canvas edit failed" + } + if e.cause == nil { + return e.message + } + return fmt.Sprintf("%s: %v", e.message, e.cause) +} + +func newCanvasEditError(status int, code, message string, cause error) *canvasEditError { + return &canvasEditError{status: status, code: code, message: message, cause: cause} +} + +type canvasEditService struct { + tasks canvasEditTaskStore + canvases canvasEditCanvasStore + releases canvasEditReleaseStore + artifacts canvasEditArtifactStore + launcher canvasEditSessionLauncher + sessions canvasEditSessionStore + executions canvasEditExecutionResolver +} + +func newCanvasEditService( + tasks canvasEditTaskStore, + canvases canvasEditCanvasStore, + releases canvasEditReleaseStore, + artifacts canvasEditArtifactStore, + launcher canvasEditSessionLauncher, + sessions canvasEditSessionStore, + executions canvasEditExecutionResolver, +) *canvasEditService { + return &canvasEditService{ + tasks: tasks, canvases: canvases, releases: releases, + artifacts: artifacts, launcher: launcher, sessions: sessions, + executions: executions, + } +} + +func (s *canvasEditService) start(ctx context.Context, canvasID, requestedPrompt string) (canvasEditResult, error) { + if s == nil || s.tasks == nil || s.canvases == nil || s.releases == nil || + s.artifacts == nil || s.launcher == nil || s.sessions == nil || s.executions == nil { + return canvasEditResult{}, newCanvasEditError(http.StatusServiceUnavailable, "canvas_edit_unavailable", "canvas editing is unavailable", nil) + } + item, entries, err := s.loadEditableCanvas(ctx, canvasID) + if err != nil { + return canvasEditResult{}, err + } + agentProfileID, executorID, err := s.editWorkspaceDefaults(ctx, item.WorkspaceID) + if err != nil { + return canvasEditResult{}, err + } + task, err := s.createEditTask(ctx, item, agentProfileID, executorID, requestedPrompt) + if err != nil { + return canvasEditResult{}, err + } + launchResponse, err := s.launchEditSession(ctx, task, item, agentProfileID, executorID, entries, requestedPrompt) + if err != nil { + return s.rollbackEditTask(task.ID, err) + } + return canvasEditResult{ + TaskID: task.ID, SessionID: launchResponse.SessionID, + CanvasID: item.ID, AgentExecutionID: launchResponse.AgentExecutionID, + }, nil +} + +func (s *canvasEditService) rollbackEditTask(taskID string, err error) (canvasEditResult, error) { + var editErr *canvasEditError + if !errors.As(err, &editErr) || editErr == nil { + editErr = newCanvasEditError(http.StatusInternalServerError, "canvas_edit_failed", "canvas edit session could not be started", err) + } + if cleanupErr := s.rollbackTask(taskID); cleanupErr != nil { + editErr.cause = errors.Join(editErr.cause, fmt.Errorf("rollback canvas edit task: %w", cleanupErr)) + } + return canvasEditResult{}, editErr +} + +func (s *canvasEditService) loadEditableCanvas(ctx context.Context, canvasID string) (*canvasservice.Canvas, []copyfiles.Entry, error) { + item, err := s.canvases.Get(ctx, strings.TrimSpace(canvasID)) + if err != nil || item == nil { + return nil, nil, newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas was not found", nil) + } + if err := s.tasks.AuthorizeWorkspaceAccess(ctx, item.WorkspaceID); err != nil { + return nil, nil, newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas was not found", nil) + } + if item.ScopeKind != canvasservice.ScopeWorkspace || item.TaskID != "" { + return nil, nil, newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas was not found", nil) + } + if item.Status != instances.StatusActive || item.ActiveReleaseID == "" { + return nil, nil, newCanvasEditError(http.StatusConflict, "pending_first_release", "canvas has no active release to edit", nil) + } + if item.ActiveReleaseStatus != instances.ValidationValid { + return nil, nil, newCanvasEditError(http.StatusConflict, "canvas_not_editable", "canvas active release is not valid", nil) + } + release, err := s.releases.GetRelease(ctx, item.ActiveReleaseID) + if err != nil || release.InstanceID != item.PluginInstanceID || release.ValidationStatus != instances.ValidationValid { + return nil, nil, newCanvasEditError(http.StatusConflict, "canvas_not_editable", "canvas active release is unavailable", err) + } + files, err := s.artifacts.ReadFiles(webapp.Artifact{Digest: release.PackageDigest, RelativePath: release.ArtifactPath, Bytes: release.ArtifactBytes, Available: true}) + if err != nil { + return nil, nil, newCanvasEditError(http.StatusConflict, "source_unavailable", "canvas active release source is unavailable", err) + } + entries := canvasEditSourceEntries(item.ID, files) + if len(entries) == 0 { + return nil, nil, newCanvasEditError(http.StatusConflict, "source_unavailable", "canvas active release source is empty", nil) + } + return item, entries, nil +} + +func (s *canvasEditService) editWorkspaceDefaults(ctx context.Context, workspaceID string) (string, string, error) { + workspace, err := s.tasks.GetWorkspace(ctx, workspaceID) + if err != nil || workspace == nil { + return "", "", newCanvasEditError(http.StatusNotFound, "canvas_not_found", "canvas workspace was not found", nil) + } + agentProfileID := workspaceDefaultID(workspace.DefaultAgentProfileID) + if agentProfileID == "" { + return "", "", newCanvasEditError(http.StatusBadRequest, "agent_profile_required", "workspace has no default agent profile configured", nil) + } + return agentProfileID, workspaceDefaultID(workspace.DefaultExecutorID), nil +} + +func (s *canvasEditService) createEditTask(ctx context.Context, item *canvasservice.Canvas, agentProfileID, executorID, requestedPrompt string) (*models.Task, error) { + metadata := map[string]interface{}{ + "origin": canvasEditOrigin, + "canvas_id": item.ID, + "canvas_release_id": item.ActiveReleaseID, + models.MetaKeyAgentProfileID: agentProfileID, + } + if executorID != "" { + metadata[models.MetaKeyExecutorID] = executorID + } + result, err := s.tasks.CreateTask(ctx, &taskservice.CreateTaskRequest{ + WorkspaceID: item.WorkspaceID, + Title: "Edit canvas: " + item.Title, + Description: strings.TrimSpace(requestedPrompt), + IsEphemeral: true, + Origin: canvasEditOrigin, + Metadata: metadata, + }) + if err == nil && result.Task != nil && result.Outcome == taskservice.CreateTaskOutcomeCreated { + return result.Task, nil + } + if err == nil { + err = errors.New("canvas edit task was not created") + } + return nil, newCanvasEditError(http.StatusInternalServerError, "task_create_failed", "canvas edit task could not be created", err) +} + +func (s *canvasEditService) launchEditSession(ctx context.Context, task *models.Task, item *canvasservice.Canvas, agentProfileID, executorID string, entries []copyfiles.Entry, requestedPrompt string) (*orchestrator.LaunchSessionResponse, error) { + launchResponse, err := s.launcher.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{ + TaskID: task.ID, Intent: orchestrator.IntentStart, + AgentProfileID: agentProfileID, ExecutorID: executorID, + }) + if err != nil || launchResponse == nil || launchResponse.SessionID == "" || launchResponse.AgentExecutionID == "" { + if err == nil { + err = errors.New("canvas edit session launch returned no execution") + } + return nil, newCanvasEditError(http.StatusInternalServerError, "session_start_failed", "canvas edit session could not be started", err) + } + target := canvasEditSessionTarget{Origin: canvasEditOrigin, TaskID: task.ID, CanvasID: item.ID, ReleaseID: item.ActiveReleaseID} + if err := s.sessions.SetSessionMetadataKey(ctx, launchResponse.SessionID, canvasEditSessionTargetMetadataKey, target); err != nil { + return nil, newCanvasEditError(http.StatusInternalServerError, "session_target_failed", "canvas edit session target could not be recorded", err) + } + if err := s.materializeEditSource(ctx, launchResponse.AgentExecutionID, entries); err != nil { + return nil, newCanvasEditError(http.StatusInternalServerError, "source_materialization_failed", "canvas source could not be materialized", err) + } + prompt := canvasEditPrompt(*item, promptSourceRoot(item.ID), requestedPrompt) + if _, err := s.launcher.PromptTask(ctx, task.ID, launchResponse.SessionID, prompt, "", false, nil, false); err != nil { + return nil, newCanvasEditError(http.StatusInternalServerError, "prompt_dispatch_failed", "canvas edit instructions could not be sent", err) + } + return launchResponse, nil +} + +func (s *canvasEditService) materializeEditSource(ctx context.Context, executionID string, entries []copyfiles.Entry) error { + copier, err := s.executions.ResolveAgentCtl(executionID) + if err != nil || copier == nil { + if err == nil { + err = errors.New("agentctl client is unavailable") + } + return err + } + response, err := copier.CopyFiles(ctx, "", entries) + if err != nil { + return err + } + if !response.Present { + return errors.New("copy-files returned no response") + } + if len(response.Warnings) > 0 { + return fmt.Errorf("copy-files returned warnings: %s", strings.Join(response.Warnings, "; ")) + } + return nil +} + +func (s *canvasEditService) rollbackTask(taskID string) error { + ctx, cancel := context.WithTimeout(context.Background(), constants.TaskDeleteTimeout) + defer cancel() + return s.tasks.DeleteTask(ctx, taskID) +} + +func workspaceDefaultID(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + +func canvasEditSourceEntries(canvasID string, files map[string][]byte) []copyfiles.Entry { + paths := make([]string, 0, len(files)) + for name := range files { + paths = append(paths, name) + } + sort.Strings(paths) + entries := make([]copyfiles.Entry, 0, len(paths)) + for _, name := range paths { + entries = append(entries, copyfiles.Entry{ + RelPath: filepath.ToSlash(filepath.Join(canvasSourceRoot(canvasID), filepath.FromSlash(name))), + Mode: os.FileMode(0o600), + Content: files[name], + }) + } + return entries +} + +func promptSourceRoot(canvasID string) string { + return canvasSourceRoot(canvasID) +} + +func canvasEditPrompt(item canvasservice.Canvas, sourceRoot, requestedPrompt string) string { + requestedPrompt = strings.TrimSpace(requestedPrompt) + if requestedPrompt == "" { + requestedPrompt = "Review the current canvas and make a useful improvement." + } + grants := "none" + if len(item.EffectiveGrants) > 0 { + values := make([]string, 0, len(item.EffectiveGrants)) + for _, grant := range item.EffectiveGrants { + resource := grant.Resource + if grant.PermissionKind == "network" { + resource = grant.NetworkOrigin + } + values = append(values, grant.PermissionKind+":"+resource+" (scope "+grant.ScopeCeiling+")") + } + grants = strings.Join(values, ", ") + } + return fmt.Sprintf( + "Edit canvas %q. If the core authoring bundle is not already available, read it once with read_canvas_authoring_skill_kandev without a path. Inspect the current source in %s, use only these effective grants: %s, apply the requested change, validate the result, and publish it with publish_canvas_kandev using canvas_id %s and source_path %s. Requested change: %s", + item.Title, sourceRoot, grants, item.ID, sourceRoot, requestedPrompt, + ) +} + +// registerCanvasRoutes mounts the feature-gated canvas host API. This helper +// is called only from registerRoutes when features.canvases is enabled. +func registerCanvasRoutes(p routeParams) { + if p.router == nil || p.services == nil || p.services.Canvas == nil || p.services.Plugins == nil { + return + } + h := &canvasHTTPHandler{ + canvases: p.services.Canvas, + plugins: p.services.Plugins, + tasks: p.taskSvc, + } + if p.taskSvc != nil && p.taskRepo != nil && p.orchestratorSvc != nil && p.lifecycleMgr != nil { + instanceStore := p.services.Plugins.Instances() + artifactStore := p.services.Plugins.WebArtifacts() + if instanceStore != nil && artifactStore != nil { + h.editor = newCanvasEditService( + p.taskSvc, p.services.Canvas, instanceStore, artifactStore, + p.orchestratorSvc, p.taskRepo, + lifecycleCanvasExecutionResolver{manager: p.lifecycleMgr}, + ) + } + } + // Keep the canonical resource-shaped paths alongside the short aliases. + // The aliases make the API convenient for direct hosts while preserving a + // predictable path for the web client. + p.router.GET("/api/v1/tasks/:id/canvases", h.listTask) + p.router.GET("/api/v1/canvases/task/:taskID", h.listTask) + p.router.GET("/api/v1/canvases/tasks/:taskID", h.listTask) + p.router.GET("/api/v1/workspaces/:id/canvases", h.listWorkspace) + p.router.GET("/api/v1/canvases/workspace/:workspaceID", h.listWorkspace) + p.router.GET("/api/v1/canvases/workspaces/:workspaceID", h.listWorkspace) + p.router.GET("/api/v1/canvases/:canvasID", h.get) + p.router.GET("/api/v1/canvases/:canvasID/releases", h.releases) + p.router.GET("/api/v1/canvases/:canvasID/promotion-preview", h.promotionPreview) + p.router.POST("/api/v1/canvases/:canvasID/promotion", h.promote) + p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/approve", h.approveRelease) + p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/reject", h.rejectRelease) + p.router.POST("/api/v1/canvases/:canvasID/rollback", h.rollback) + p.router.POST("/api/v1/canvases/:canvasID/edit", h.edit) + p.router.POST("/api/v1/canvases/:canvasID/runtime", h.runtime) + p.router.GET("/api/v1/canvases/:canvasID/runtime", h.runtime) + p.router.POST("/api/v1/canvases/:canvasID/archive", h.archive) + p.router.POST("/api/v1/canvases/:canvasID/restore", h.restore) + p.router.POST("/api/v1/canvases/:canvasID/remove", h.remove) + p.router.DELETE("/api/v1/canvases/:canvasID", h.remove) +} + +type canvasHTTPResponse struct { + ID string `json:"id"` + PluginInstanceID string `json:"plugin_instance_id"` + PluginID string `json:"plugin_id"` + WorkspaceID string `json:"workspace_id"` + TaskID string `json:"task_id,omitempty"` + OriginTaskID string `json:"origin_task_id,omitempty"` + ScopeKind string `json:"scope_kind"` + 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"` + Status string `json:"status"` + ActiveReleaseID string `json:"active_release_id,omitempty"` + ActiveReleaseStatus string `json:"active_release_status,omitempty"` + ActiveReleaseError string `json:"active_release_error,omitempty"` + GrantGeneration int64 `json:"grant_generation,omitempty"` + EffectiveGrants []canvasservice.GrantProjection `json:"effective_grants,omitempty"` + ActiveRelease *canvasReleaseResponse `json:"active_release,omitempty"` + PendingRelease *canvasReleaseResponse `json:"pending_release,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type canvasReleaseResponse struct { + ID string `json:"id"` + PackageDigest string `json:"package_digest,omitempty"` + ValidationStatus string `json:"validation_status"` + ValidationError string `json:"validation_error,omitempty"` + Permissions *canvasservice.PermissionSummary `json:"permissions,omitempty"` + MissingPermissions []string `json:"missing_permissions,omitempty"` + PermissionDigest string `json:"permission_digest,omitempty"` + SourceActorKind string `json:"source_actor_kind,omitempty"` + SourceUserID string `json:"source_user_id,omitempty"` + SourceTaskID string `json:"source_task_id,omitempty"` + SourceSessionID string `json:"source_session_id,omitempty"` + ProtocolVersion int `json:"protocol_version,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func canvasResponse(value canvasservice.Canvas) canvasHTTPResponse { + result := canvasHTTPResponse{ + ID: value.ID, + PluginInstanceID: value.PluginInstanceID, + PluginID: value.PluginID, + WorkspaceID: value.WorkspaceID, + TaskID: value.TaskID, + OriginTaskID: value.OriginTaskID, + ScopeKind: value.ScopeKind, + Title: value.Title, + CreatedBySessionID: value.CreatedBySessionID, + PromotedByUserID: value.PromotedByUserID, + PromotedAt: value.PromotedAt, + Status: value.Status, + ActiveReleaseID: value.ActiveReleaseID, + ActiveReleaseStatus: value.ActiveReleaseStatus, + ActiveReleaseError: value.ActiveReleaseError, + GrantGeneration: value.GrantGeneration, + EffectiveGrants: value.EffectiveGrants, + CreatedAt: value.CreatedAt, + UpdatedAt: value.UpdatedAt, + } + if value.ActiveRelease != nil { + result.ActiveRelease = releaseResponseFromMetadata(value.ActiveRelease) + } + if value.PendingRelease != nil { + result.PendingRelease = releaseResponseFromMetadata(value.PendingRelease) + } + return result +} + +func (h *canvasHTTPHandler) listTask(c *gin.Context) { + taskID := strings.TrimSpace(canvasRouteParam(c, "id", "taskID")) + taskSvc := h.tasks + if taskSvc == nil { + writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) + return + } + task, err := taskSvc.GetTask(c.Request.Context(), taskID) + if err != nil || task == nil { + writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) + return + } + if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), task.WorkspaceID); err != nil { + writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) + return + } + includeArchived, ok := canvasIncludeArchived(c) + if !ok { + return + } + canvases, err := h.canvases.ListForTask(c.Request.Context(), task.WorkspaceID, taskID, includeArchived) + if err != nil { + h.writeError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, map[string]interface{}{"canvases": mapCanvasResponses(canvases)}) +} + +func (h *canvasHTTPHandler) listWorkspace(c *gin.Context) { + workspaceID := strings.TrimSpace(canvasRouteParam(c, "id", "workspaceID")) + taskSvc := h.tasks + if taskSvc == nil { + writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) + return + } + if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), workspaceID); err != nil { + writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) + return + } + includeArchived, ok := canvasIncludeArchived(c) + if !ok { + return + } + canvases, err := h.canvases.ListWorkspaceCanvases(c.Request.Context(), workspaceID, includeArchived) + if err != nil { + h.writeError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, map[string]interface{}{"canvases": mapCanvasResponses(canvases)}) +} + +func (h *canvasHTTPHandler) get(c *gin.Context) { + canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if !h.authorizeWorkspace(c, canvas.WorkspaceID) { + return + } + writeCanvasJSON(c, http.StatusOK, canvasResponse(*canvas)) +} + +func (h *canvasHTTPHandler) releases(c *gin.Context) { + canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if !h.authorizeWorkspace(c, canvas.WorkspaceID) { + return + } + instanceStore := h.plugins.Instances() + instance, err := instanceStore.Get(c.Request.Context(), canvas.PluginInstanceID) + if err != nil { + h.writeError(c, err) + return + } + grants, err := instanceStore.ListGrants(c.Request.Context(), canvas.PluginInstanceID) + if err != nil { + h.writeError(c, err) + return + } + list, err := instanceStore.ListReleases(c.Request.Context(), canvas.PluginInstanceID) + if err != nil { + h.writeError(c, err) + return + } + result := make([]canvasReleaseResponse, 0, len(list)) + for _, release := range list { + result = append(result, releaseResponse(release, instance.ScopeKind, grants)) + } + writeCanvasJSON(c, http.StatusOK, map[string]interface{}{"releases": result}) +} + +func releaseResponse(release instances.Release, scope string, grants []instances.Grant) canvasReleaseResponse { + permissions := canvasservice.ReleasePermissionSummary(release) + return canvasReleaseResponse{ + ID: release.ID, PackageDigest: release.PackageDigest, + ValidationStatus: release.ValidationStatus, ValidationError: release.ValidationError, + Permissions: &permissions, MissingPermissions: canvasservice.MissingPermissionKeys(permissions, scope, grants), + PermissionDigest: canvasservice.PermissionDigest(release), SourceActorKind: release.SourceActorKind, + SourceUserID: release.SourceUserID, SourceTaskID: release.SourceTaskID, + SourceSessionID: release.SourceSessionID, ProtocolVersion: release.ProtocolVersion, + CreatedAt: release.CreatedAt, + } +} + +func releaseResponseFromMetadata(value *canvasservice.ReleaseMetadata) *canvasReleaseResponse { + if value == nil { + return nil + } + return &canvasReleaseResponse{ + ID: value.ID, PackageDigest: value.PackageDigest, ValidationStatus: value.ValidationStatus, + ValidationError: value.ValidationError, Permissions: value.Permissions, + MissingPermissions: value.MissingPermissions, PermissionDigest: value.PermissionDigest, + SourceActorKind: value.SourceActorKind, SourceUserID: value.SourceUserID, + SourceTaskID: value.SourceTaskID, SourceSessionID: value.SourceSessionID, + ProtocolVersion: value.ProtocolVersion, CreatedAt: value.CreatedAt, + } +} + +func (h *canvasHTTPHandler) promotionPreview(c *gin.Context) { + item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if item == nil || !h.authorizeWorkspace(c, item.WorkspaceID) { + return + } + preview, err := h.canvases.PromotionPreview(c.Request.Context(), item.ID) + if err != nil { + h.writeError(c, err) + return + } + if preview == nil || preview.Canvas == nil { + return + } + 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, + }) +} + +func releaseResponseFromCanvas(value *canvasservice.Canvas) *canvasReleaseResponse { + if value == nil || value.ActiveRelease == nil { + return nil + } + return releaseResponseFromMetadata(value.ActiveRelease) +} + +type canvasPromotionRequest struct { + ExpectedReleaseID string `json:"expected_release_id"` + ExpectedPermissionDigest string `json:"expected_permission_digest"` + ExpectedGrantGeneration *int64 `json:"expected_grant_generation"` +} + +func (h *canvasHTTPHandler) promote(c *gin.Context) { + canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if !h.authorizeWorkspace(c, canvas.WorkspaceID) { + return + } + var request canvasPromotionRequest + if c.Request.Body == nil || c.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.ExpectedReleaseID) == "" || strings.TrimSpace(request.ExpectedPermissionDigest) == "" || request.ExpectedGrantGeneration == nil || *request.ExpectedGrantGeneration < 0 { + writeCanvasError(c, http.StatusBadRequest, "promotion_review_required", nil) + return + } + updated, err := h.canvases.PromoteCanvasReviewed(c.Request.Context(), canvas.ID, canvasRuntimeUser(c), request.ExpectedReleaseID, request.ExpectedPermissionDigest, *request.ExpectedGrantGeneration) + if err != nil { + h.writeError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) +} + +func (h *canvasHTTPHandler) approveRelease(c *gin.Context) { + h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, userID string) (*canvasservice.Canvas, error) { + return h.canvases.ApproveRelease(ctx, canvasID, releaseID, userID) + }) +} + +func (h *canvasHTTPHandler) rejectRelease(c *gin.Context) { + h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, _ string) (*canvasservice.Canvas, error) { + return h.canvases.RejectRelease(ctx, canvasID, releaseID) + }) +} + +func (h *canvasHTTPHandler) releaseMutation(c *gin.Context, mutation func(context.Context, string, string, string) (*canvasservice.Canvas, error)) { + canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if !h.authorizeWorkspace(c, canvas.WorkspaceID) { + return + } + updated, err := mutation(c.Request.Context(), canvas.ID, c.Param("releaseID"), canvasRuntimeUser(c)) + if err != nil { + h.writeError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) +} + +func (h *canvasHTTPHandler) rollback(c *gin.Context) { + canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if !h.authorizeWorkspace(c, canvas.WorkspaceID) { + return + } + var body struct { + ReleaseID string `json:"release_id"` + } + if c.Request.Body != nil { + if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { + writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) + return + } + } + updated, err := h.canvases.RollbackRelease(c.Request.Context(), canvas.ID, body.ReleaseID) + if err != nil { + h.writeError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) +} + +func (h *canvasHTTPHandler) edit(c *gin.Context) { + if h.editor == nil { + writeCanvasError(c, http.StatusServiceUnavailable, "canvas_edit_unavailable", nil) + return + } + var request struct { + Prompt string `json:"prompt,omitempty"` + } + if c.Request.Body != nil { + if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) { + writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) + return + } + } + result, err := h.editor.start(c.Request.Context(), c.Param("canvasID"), request.Prompt) + if err != nil { + var editErr *canvasEditError + if errors.As(err, &editErr) && editErr != nil { + writeCanvasError(c, editErr.status, editErr.code, nil) + return + } + writeCanvasError(c, http.StatusInternalServerError, "canvas_edit_failed", nil) + return + } + writeCanvasJSON(c, http.StatusOK, result) +} + +type canvasRuntimeRequest struct { + WebAppKey string `json:"web_app_key"` + Placement string `json:"placement"` +} + +func (h *canvasHTTPHandler) runtime(c *gin.Context) { + canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if !h.authorizeWorkspace(c, canvas.WorkspaceID) { + return + } + if canvas.Status != instances.StatusActive || canvas.ActiveReleaseID == "" { + writeCanvasError(c, http.StatusConflict, "pending_first_release", map[string]interface{}{"status": canvas.Status}) + return + } + if canvas.ActiveReleaseStatus != instances.ValidationValid { + writeCanvasError(c, http.StatusConflict, canvasHostError(canvas.ActiveReleaseStatus), map[string]interface{}{"status": canvas.ActiveReleaseStatus, "error": canvas.ActiveReleaseError}) + return + } + var request canvasRuntimeRequest + if c.Request.Method == http.MethodPost { + if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, http.ErrNotSupported) && !errors.Is(err, io.EOF) { + writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) + return + } + } + path, release, app, binding, err := h.runtimeBinding(c, *canvas, request) + if err != nil { + h.writeError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, map[string]interface{}{ + "runtime_url": path, + "release_id": release.ID, + "web_app_key": app.Key, + "placement": appPlacement(app, request.Placement, canvas.ScopeKind), + "expires_in_seconds": int(webapp.RuntimeTokenTTL / time.Second), + "canvas": canvasResponse(*canvas), + "binding": map[string]interface{}{"scope_kind": binding.ScopeKind, "grant_generation": binding.GrantGeneration}, + }) +} + +func (h *canvasHTTPHandler) archive(c *gin.Context) { + h.mutate(c, func(ctx context.Context, id string) (*canvasservice.Canvas, error) { + return h.canvases.Archive(ctx, id) + }) +} + +func (h *canvasHTTPHandler) restore(c *gin.Context) { + h.mutate(c, func(ctx context.Context, id string) (*canvasservice.Canvas, error) { + return h.canvases.Restore(ctx, id) + }) +} + +func (h *canvasHTTPHandler) mutate(c *gin.Context, mutation func(context.Context, string) (*canvasservice.Canvas, error)) { + // Keeping mutation routing in one place prevents archive and restore from + // accidentally gaining different authorization behavior. + canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if !h.authorizeWorkspace(c, canvas.WorkspaceID) { + return + } + updated, err := mutation(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) +} + +func (h *canvasHTTPHandler) remove(c *gin.Context) { + canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if !h.authorizeWorkspace(c, canvas.WorkspaceID) { + return + } + if err := h.canvases.Remove(c.Request.Context(), c.Param("canvasID")); err != nil { + h.writeError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *canvasHTTPHandler) authorizeWorkspace(c *gin.Context, workspaceID string) bool { + taskSvc := h.tasks + if taskSvc == nil { + writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) + return false + } + if err := taskSvc.AuthorizeWorkspaceAccess(c.Request.Context(), workspaceID); err != nil { + writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) + return false + } + return true +} + +func canvasRouteParam(c *gin.Context, names ...string) string { + for _, name := range names { + if value := c.Param(name); value != "" { + return value + } + } + return "" +} diff --git a/apps/backend/internal/backendapp/canvas_runtime_routes.go b/apps/backend/internal/backendapp/canvas_runtime_routes.go new file mode 100644 index 0000000000..f9f6385ae7 --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_runtime_routes.go @@ -0,0 +1,287 @@ +package backendapp + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/auth/authn" + canvasservice "github.com/kandev/kandev/internal/canvas" + "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/webapp" + userstore "github.com/kandev/kandev/internal/user/store" +) + +var errCanvasWebAppNotDeclared = errors.New("canvas web application is not declared") + +func (h *canvasHTTPHandler) runtimeBinding(c *gin.Context, canvas canvasservice.Canvas, request canvasRuntimeRequest) (string, instances.Release, manifest.WebApp, webapp.CapabilityBinding, error) { + instance, release, appManifest, err := h.loadRuntimeRelease(c, canvas) + if err != nil { + return "", instances.Release{}, manifest.WebApp{}, webapp.CapabilityBinding{}, err + } + app, placement, err := selectRuntimeWebApp(appManifest, request, canvas.ScopeKind) + if err != nil { + return "", instances.Release{}, manifest.WebApp{}, webapp.CapabilityBinding{}, err + } + grants, err := h.plugins.Instances().ListGrants(c.Request.Context(), instance.ID) + if err != nil { + return "", instances.Release{}, manifest.WebApp{}, webapp.CapabilityBinding{}, err + } + binding := webapp.CapabilityBinding{ + PluginID: instance.PluginID, + UserID: canvasRuntimeUser(c), + InstanceID: instance.ID, + ReleaseID: release.ID, + WebAppKey: app.Key, + Placement: placement, + ScopeKind: instance.ScopeKind, + WorkspaceID: instance.WorkspaceID, + TaskID: instance.TaskID, + SessionID: instance.SessionID, + RepositoryID: instance.RepositoryID, + GrantGeneration: instance.GrantGeneration, + Permissions: runtimeGrantedPermissions(appManifest, instance.ScopeKind, grants), + Artifact: webapp.Artifact{ + Digest: release.PackageDigest, + RelativePath: release.ArtifactPath, + Bytes: release.ArtifactBytes, + Available: true, + }, + Entry: app.Entry, + NetworkOrigins: runtimeNetworkOrigins(app, instance.ScopeKind, grants), + } + runtime := h.plugins.WebRuntime() + if runtime == nil { + return "", instances.Release{}, manifest.WebApp{}, webapp.CapabilityBinding{}, errors.New("canvas runtime is not configured") + } + path, err := runtime.IssueCapabilityPath(binding, webapp.RuntimeTokenTTL) + if err != nil { + return "", instances.Release{}, manifest.WebApp{}, webapp.CapabilityBinding{}, err + } + return path, release, app, binding, nil +} + +func (h *canvasHTTPHandler) loadRuntimeRelease(c *gin.Context, canvas canvasservice.Canvas) (instances.Instance, instances.Release, *manifest.Manifest, error) { + store := h.plugins.Instances() + if store == nil { + return instances.Instance{}, instances.Release{}, nil, errors.New("canvas runtime is not configured") + } + instance, err := store.Get(c.Request.Context(), canvas.PluginInstanceID) + if err != nil { + return instances.Instance{}, instances.Release{}, nil, err + } + release, err := store.GetRelease(c.Request.Context(), instance.ActiveReleaseID) + if err != nil { + return instances.Instance{}, instances.Release{}, nil, err + } + appManifest, err := manifest.Parse(release.ManifestJSON) + if err != nil { + return instances.Instance{}, instances.Release{}, nil, err + } + return instance, release, appManifest, nil +} + +func selectRuntimeWebApp(appManifest *manifest.Manifest, request canvasRuntimeRequest, scope string) (manifest.WebApp, string, error) { + placement := request.Placement + if placement == "" { + placement = appPlacement(manifest.WebApp{}, "", scope) + } + var app manifest.WebApp + for _, candidate := range appManifest.UI.WebApps { + if request.WebAppKey != "" && candidate.Key != request.WebAppKey { + continue + } + if placement != "" && !containsString(candidate.Placements, placement) { + continue + } + app = candidate + break + } + if app.Key == "" { + return manifest.WebApp{}, "", errCanvasWebAppNotDeclared + } + return app, appPlacement(app, request.Placement, scope), nil +} + +func runtimeNetworkOrigins(app manifest.WebApp, scope string, grants []instances.Grant) []string { + declared, err := webapp.NormalizeNetworkOrigins(app.NetworkOrigins) + if err != nil || len(declared) == 0 { + return nil + } + origins := make([]string, 0, len(grants)) + for _, origin := range declared { + for _, grant := range grants { + if grant.NetworkOrigin == origin && runtimeGrantScopeCovers(grant.ScopeCeiling, scope) && !containsString(origins, grant.NetworkOrigin) { + origins = append(origins, grant.NetworkOrigin) + } + } + } + return origins +} + +func (h *canvasHTTPHandler) writeError(c *gin.Context, err error) { + status := http.StatusInternalServerError + code := "canvas_error" + switch { + case errors.Is(err, canvasservice.ErrCanvasNotFound), errors.Is(err, instances.ErrNotFound): + status, code = http.StatusNotFound, "canvas_not_found" + case errors.Is(err, canvasservice.ErrTaskCanvasLimit), errors.Is(err, canvasservice.ErrWorkspaceCanvasLimit), errors.Is(err, instances.ErrTaskCanvasLimit), errors.Is(err, instances.ErrWorkspaceCanvasLimit): + status, code = http.StatusConflict, "canvas_limit_exceeded" + case errors.Is(err, instances.ErrWorkspaceStorageLimit), errors.Is(err, instances.ErrInstallationStorageLimit): + status, code = http.StatusConflict, "canvas_storage_limit_exceeded" + case errors.Is(err, instances.ErrInvalidRelease): + status, code = http.StatusConflict, "invalid_release" + case errors.Is(err, instances.ErrStaleCanvasPublish), errors.Is(err, canvasservice.ErrStaleCanvasPublish): + status, code = http.StatusConflict, "canvas_publish_stale" + case errors.Is(err, instances.ErrInvalidLifecycleState), errors.Is(err, canvasservice.ErrInvalidLifecycleState): + status, code = http.StatusBadRequest, canvasErrorCodeInvalid + case errors.Is(err, canvasservice.ErrStalePromotionReview): + status, code = http.StatusConflict, "promotion_review_stale" + case errors.Is(err, canvasservice.ErrStaleCanvasEdit): + status, code = http.StatusConflict, "canvas_edit_stale" + case errors.Is(err, canvasservice.ErrInvalidCanvas), errors.Is(err, canvasservice.ErrInvalidCanvasState): + status, code = http.StatusBadRequest, canvasErrorCodeInvalid + case errors.Is(err, errCanvasWebAppNotDeclared): + status, code = http.StatusBadRequest, "web_app_not_declared" + default: + } + writeCanvasError(c, status, code, nil) +} + +func mapCanvasResponses(values []canvasservice.Canvas) []canvasHTTPResponse { + result := make([]canvasHTTPResponse, 0, len(values)) + for _, value := range values { + result = append(result, canvasResponse(value)) + } + return result +} + +func canvasIncludeArchived(c *gin.Context) (bool, bool) { + value := c.Query("include_archived") + if value == "" { + return false, true + } + parsed, err := strconv.ParseBool(value) + if err != nil { + writeCanvasError(c, http.StatusBadRequest, "invalid_include_archived", nil) + return false, false + } + return parsed, true +} + +func canvasRuntimeUser(c *gin.Context) string { + if identity, ok := authn.FromGin(c); ok && identity.UserID != "" { + return identity.UserID + } + return userstore.DefaultUserID +} + +func appPlacement(app manifest.WebApp, requested, scope string) string { + if requested != "" { + return requested + } + wanted := manifest.WebAppPlacementWorkspace + if scope == instances.ScopeTask { + wanted = manifest.WebAppPlacementTask + } + if containsString(app.Placements, wanted) { + return wanted + } + if len(app.Placements) > 0 { + return app.Placements[0] + } + return wanted +} + +func declaredPermissions(appManifest *manifest.Manifest) []string { + if appManifest == nil { + return nil + } + permissions := make([]string, 0, len(appManifest.Capabilities.APIRead)+len(appManifest.Capabilities.APIWrite)+len(appManifest.Capabilities.Events)+1) + for _, resource := range appManifest.Capabilities.APIRead { + permissions = appendUnique(permissions, "api_read:"+resource) + } + for _, resource := range appManifest.Capabilities.APIWrite { + permissions = appendUnique(permissions, "api_write:"+resource) + } + for _, event := range appManifest.Capabilities.Events { + permissions = appendUnique(permissions, "events:"+event) + } + if appManifest.Capabilities.State { + permissions = appendUnique(permissions, "state") + } + return permissions +} + +func runtimeGrantedPermissions(appManifest *manifest.Manifest, scope string, grants []instances.Grant) []string { + declared := declaredPermissions(appManifest) + permissions := make([]string, 0, len(declared)) + for _, permission := range declared { + parts := strings.SplitN(permission, ":", 2) + kind, resource := parts[0], "" + if len(parts) == 2 { + resource = parts[1] + } + for _, grant := range grants { + if grant.PermissionKind == kind && grant.Resource == resource && runtimeGrantScopeCovers(grant.ScopeCeiling, scope) { + permissions = append(permissions, permission) + break + } + } + } + return permissions +} + +func runtimeGrantScopeCovers(ceiling, scope string) bool { + if ceiling == instances.ScopeInstance { + return true + } + return ceiling == scope +} + +func appendUnique(values []string, value string) []string { + if value == "" || containsString(values, value) { + return values + } + return append(values, value) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func canvasHostError(status string) string { + switch status { + case instances.ValidationPendingPermission: + return "permission_review_required" + case instances.ValidationInvalid: + return "invalid_release" + case instances.ValidationUnavailable: + return "runtime_unavailable" + default: + return "canvas_release_unavailable" + } +} + +func writeCanvasJSON(c *gin.Context, status int, value interface{}) { + c.Header("Cache-Control", "no-store") + c.Header("X-Content-Type-Options", "nosniff") + c.JSON(status, value) +} + +func writeCanvasError(c *gin.Context, status int, code string, details map[string]interface{}) { + body := gin.H{"error": code, "error_code": code} + for key, value := range details { + body[key] = value + } + writeCanvasJSON(c, status, body) +} diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 4a06a5caf2..ec94ec978c 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1476,6 +1476,10 @@ func registerSecondaryRoutes( p.services.Plugins.SetAuthLoginBridge(pluginSSOBridge{auth: p.authSvc}) } plugins.RegisterRoutes(p.router, p.services.Plugins, p.services.Plugins.Deliverer(), p.log) + if p.features.Canvases { + plugins.RegisterWebAppRuntimeRoutes(p.router, p.services.Plugins.WebRuntime()) + registerCanvasRoutes(p) + } p.log.Debug("Registered Plugins handlers (HTTP)") } @@ -1815,6 +1819,12 @@ func registerMCPAndDebugRoutes( clarificationStore, clarificationCanceller, p.msgCreator, p.taskRepo, p.taskRepo, p.eventBus, planService, walkthroughService, p.orchestratorSvc, p.orchestratorSvc.GetMessageQueue(), p.log, ) mcpHandlers.SetPluginService(p.services.Plugins) + if p.features.Canvases && p.services != nil && p.services.Canvas != nil && p.services.Plugins != nil { + mcpHandlers.SetCanvasAuthoringService(newCanvasAuthoringService( + p.services.Canvas, p.services.Plugins, p.taskSvc, + lifecycleCanvasExecutionResolver{manager: p.lifecycleMgr}, p.homeDir, p.log, + )) + } mcpHandlers.SetRemoteContributionService(newRemoteContributionCoordinator(p.services.GitHub, p.services.GitLab)) // Wire config-mode dependencies for agent-native configuration mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc) diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 54ca98ccb4..45a991bf33 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -952,6 +952,9 @@ func startGatewayAndServe( } gateways.RegisterSessionStreamNotifications(ctx, eventBus, gateway.Hub, log) + if cfg.Features.Canvases { + gateways.RegisterCanvasNotifications(ctx, eventBus, gateway.Hub, log) + } gateway.Hub.SetSessionDataProvider(buildSessionDataProvider(repos.Task, lifecycleMgr, orchestratorSvc, log)) gateway.Hub.SetSessionGitDataProvider(buildSessionGitDataProvider(repos.Task, lifecycleMgr, log)) log.Info("Session data provider configured for session subscriptions (git status from snapshots)") diff --git a/apps/backend/internal/backendapp/middleware.go b/apps/backend/internal/backendapp/middleware.go index 55c6194fdb..533a9f1564 100644 --- a/apps/backend/internal/backendapp/middleware.go +++ b/apps/backend/internal/backendapp/middleware.go @@ -2,6 +2,7 @@ package backendapp import ( "net/http" + "strings" "github.com/gin-gonic/gin" @@ -12,7 +13,8 @@ import ( func corsMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if origin := c.Request.Header.Get("Origin"); origin != "" { - if !httpmw.AllowedOrigin(origin, c.Request.Host) { + opaqueRuntime := origin == "null" && strings.HasPrefix(c.Request.URL.Path, "/api/v1/plugins/web-apps/runtime/") + if !opaqueRuntime && !httpmw.AllowedOrigin(origin, c.Request.Host) { // Disallowed cross-origin browser request: reject every method // (including GET/HEAD) with 403. Letting one reach the handler // yields nothing legitimate — a response the browser could read @@ -32,12 +34,16 @@ func corsMiddleware() gin.HandlerFunc { c.Header("Access-Control-Allow-Origin", origin) c.Header("Vary", "Origin") - c.Header("Access-Control-Allow-Credentials", "true") + if opaqueRuntime { + c.Header("Access-Control-Allow-Credentials", "false") + } else { + c.Header("Access-Control-Allow-Credentials", "true") + } } else { c.Header("Access-Control-Allow-Origin", "*") } c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Kandev-Interim-Settings-Interlock, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, If-Match, Last-Event-ID, X-Kandev-Interim-Settings-Interlock, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol") c.Header("Access-Control-Expose-Headers", "WWW-Authenticate") if c.Request.Method == "OPTIONS" { diff --git a/apps/backend/internal/backendapp/middleware_test.go b/apps/backend/internal/backendapp/middleware_test.go index 27a085d26d..cae3c7d257 100644 --- a/apps/backend/internal/backendapp/middleware_test.go +++ b/apps/backend/internal/backendapp/middleware_test.go @@ -67,7 +67,7 @@ func TestCORSMiddlewareAllowsInterimSettingsInterlockHeader(t *testing.T) { req.Host = "localhost:38429" req.Header.Set("Origin", "http://localhost:37429") req.Header.Set("Access-Control-Request-Method", http.MethodPost) - req.Header.Set("Access-Control-Request-Headers", "X-Kandev-Interim-Settings-Interlock") + req.Header.Set("Access-Control-Request-Headers", "X-Kandev-Interim-Settings-Interlock, If-Match") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) @@ -77,6 +77,12 @@ func TestCORSMiddlewareAllowsInterimSettingsInterlockHeader(t *testing.T) { if !strings.Contains(rec.Header().Get("Access-Control-Allow-Headers"), "X-Kandev-Interim-Settings-Interlock") { t.Fatalf("Access-Control-Allow-Headers = %q, want interim settings interlock header", rec.Header().Get("Access-Control-Allow-Headers")) } + if !strings.Contains(rec.Header().Get("Access-Control-Allow-Headers"), "If-Match") { + t.Fatalf("Access-Control-Allow-Headers = %q, want If-Match", rec.Header().Get("Access-Control-Allow-Headers")) + } + if !strings.Contains(rec.Header().Get("Access-Control-Allow-Headers"), "Last-Event-ID") { + t.Fatalf("Access-Control-Allow-Headers = %q, want Last-Event-ID", rec.Header().Get("Access-Control-Allow-Headers")) + } } func TestCORSMiddlewareAllowsLoopbackAliasOriginForCredentialedRequests(t *testing.T) { diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index 02ae800cb0..e1f9326731 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -126,6 +126,7 @@ func provideOrchestrator( } orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, secretStore, msgQueue, log) + orchestratorSvc.SetCanvasesEnabled(cfg != nil && cfg.Features.Canvases) orchestratorSvc.SetAgentProfileRecentUseRecorder(userSvc) if gitCredentialBroker != nil { orchestratorSvc.SetGitHubCredentialBroker(gitCredentialBroker, githubCredentialBrokerEndpoint(cfg)) diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 963b3e4591..ea9cbcedcc 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -24,6 +24,7 @@ import ( analyticsservice "github.com/kandev/kandev/internal/analytics/service" "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/azuredevops" + canvasservice "github.com/kandev/kandev/internal/canvas" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" @@ -34,6 +35,7 @@ import ( "github.com/kandev/kandev/internal/integrations/secretadapter" "github.com/kandev/kandev/internal/jira" "github.com/kandev/kandev/internal/linear" + "github.com/kandev/kandev/internal/mcp/canvasskill" "github.com/kandev/kandev/internal/mentions" "github.com/kandev/kandev/internal/plugins" promptservice "github.com/kandev/kandev/internal/prompts/service" @@ -65,6 +67,11 @@ const ( ) func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, dbPool *db.Pool, eventBus bus.EventBus, agentRegistry *registry.Registry, version string) (*Services, *agentsettingscontroller.Controller, error) { + if cfg.Features.Canvases { + if err := canvasskill.EnsureMaterialized(cfg.ResolvedHomeDir()); err != nil { + return nil, nil, fmt.Errorf("materialize canvas authoring skill: %w", err) + } + } // Load custom TUI agents from DB into registry before discovery loadCustomTUIAgents(context.Background(), repos, agentRegistry, log) @@ -270,6 +277,10 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories } taskSvc.SetRepositorySelectionResolver(pluginRepositorySelectionResolver{inspector: pluginsSvc}) } + canvasSvc, err := initCanvasService(cfg.Features.Canvases, dbPool, eventBus, pluginsSvc, taskSvc, log) + if err != nil { + return nil, nil, fmt.Errorf("initialize canvas service: %w", err) + } gitCredentialBroker := newGitCredentialBroker(githubSvc, pluginsSvc, repos.Task, cfg.GitHubCredentialBroker.ReissueSigningKey) if pluginsSvc != nil { pluginsSvc.SetGitCredentialLeaseRevoker(gitCredentialBroker.RevokeProvider) @@ -354,6 +365,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Share: shareHTTP, Automation: automationComponents, Plugins: pluginsSvc, + Canvas: canvasSvc, GitCredentials: gitCredentialBroker, // Office is constructed later in initOfficeServices once all // of its dependencies (config loader, task integrations, etc.) are available. @@ -376,6 +388,43 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories return services, agentSettingsController, nil } +func initCanvasService(enabled bool, dbPool *db.Pool, eventBus bus.EventBus, pluginsSvc *plugins.Service, taskSvc *taskservice.Service, log *logger.Logger) (*canvasservice.Service, error) { + if !enabled || pluginsSvc == nil { + if enabled && pluginsSvc == nil { + return nil, errors.New("canvas: plugin service is required") + } + return nil, nil + } + if taskSvc == nil { + return nil, errors.New("canvas: task service is required") + } + canvasRepo, err := canvasservice.NewRepository(dbPool) + if err != nil { + return nil, fmt.Errorf("canvas repository: %w", err) + } + canvasSvc := canvasservice.NewService(canvasRepo, pluginsSvc.Instances()) + canvasSvc.SetInstanceStateCleanup(func(ctx context.Context, instanceID string) error { + instanceState := pluginsSvc.InstanceState() + if instanceState == nil { + return nil + } + return instanceState.DeleteInstance(ctx, instanceID) + }) + if err := canvasSvc.Reconcile(context.Background()); err != nil { + return nil, fmt.Errorf("reconcile canvas lifecycle: %w", err) + } + canvasSvc.SetEventPublisher(func(ctx context.Context, event canvasservice.LifecycleEvent) { + if eventBus == nil || event.Type == "" { + return + } + if err := eventBus.Publish(ctx, event.Type, bus.NewEvent(event.Type, "canvas", event)); err != nil { + log.Warn("failed to publish canvas lifecycle event", zap.String("event", event.Type), zap.Error(err)) + } + }) + taskSvc.SetCanvasCleanup(canvasSvc) + return canvasSvc, nil +} + func reserveBuiltinMentionIdentities( pluginService *plugins.Service, providers []mentions.MentionProvider, ) { diff --git a/apps/backend/internal/backendapp/services_canvas_test.go b/apps/backend/internal/backendapp/services_canvas_test.go new file mode 100644 index 0000000000..eeaac096d8 --- /dev/null +++ b/apps/backend/internal/backendapp/services_canvas_test.go @@ -0,0 +1,23 @@ +package backendapp + +import "testing" + +func TestInitCanvasServiceFailsClosedWhenEnabledWithoutPlugins(t *testing.T) { + service, err := initCanvasService(true, nil, nil, nil, nil, nil) + if service != nil { + t.Fatal("expected no canvas service when initialization fails") + } + if err == nil { + t.Fatal("expected enabled canvas initialization to fail without the plugin service") + } +} + +func TestInitCanvasServiceSkipsDisabledFeature(t *testing.T) { + service, err := initCanvasService(false, nil, nil, nil, nil, nil) + if err != nil { + t.Fatalf("disabled canvas initialization: %v", err) + } + if service != nil { + t.Fatal("expected disabled canvas initialization to return no service") + } +} diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index d420c747a9..58dd5a9554 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -13,6 +13,7 @@ import ( authstore "github.com/kandev/kandev/internal/auth/store" "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/azuredevops" + canvasservice "github.com/kandev/kandev/internal/canvas" editorservice "github.com/kandev/kandev/internal/editors/service" editorstore "github.com/kandev/kandev/internal/editors/store" "github.com/kandev/kandev/internal/gitcredentials" @@ -119,6 +120,9 @@ type Services struct { // registry, event delivery, health monitoring). Always constructed // (non-nil) when initialization succeeds. Plugins *plugins.Service + // Canvas is the gated lifecycle service for agent-authored plugin web + // applications. It is nil while features.canvases is disabled. + Canvas *canvasservice.Service // GitCredentials is the shared provider-neutral lease broker used by the // GitHub HTTP endpoint and task executor helper leases. GitCredentials *gitcredentials.Broker diff --git a/apps/backend/internal/canvas/authoring.go b/apps/backend/internal/canvas/authoring.go new file mode 100644 index 0000000000..5ecbe411fb --- /dev/null +++ b/apps/backend/internal/canvas/authoring.go @@ -0,0 +1,814 @@ +package canvas + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + plugininstances "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/webapp" +) + +// PermissionSummary is the review-safe projection of a canvas manifest. It +// contains permission names and exact origins, never package source or state. +type PermissionSummary struct { + Reads []string `json:"reads,omitempty"` + Writes []string `json:"writes,omitempty"` + Events []string `json:"events,omitempty"` + SharedState bool `json:"shared_state"` + ExternalOrigins []string `json:"external_origins,omitempty"` +} + +// PublishRequest contains a package already read from the trusted execution +// source stream. The request has no user-controlled scope identifiers. +type PublishRequest struct { + CanvasID string + Package *webapp.Package + Artifact webapp.Artifact + // ExpectedAuthority is captured by the trusted authoring boundary before + // source transfer. Durable persistence must still match it atomically. + ExpectedAuthority plugininstances.PublishAuthority + // ExpectedBaseReleaseID is set for a trusted edit session. The release + // publish transaction rejects the package if the active release changed + // after the editor materialized its source. + ExpectedBaseReleaseID string + SourceActorKind string + SourceUserID string + SourceTaskID string + SourceSessionID string +} + +// PublishResult describes the release outcome without exposing package files. +type PublishResult struct { + Canvas *Canvas + Release plugininstances.Release + Activated bool + PermissionRequired bool + // ReleasePersisted tells the source owner that the artifact is now owned + // by durable release metadata, even when a post-persistence projection or + // retention step returned an error. + ReleasePersisted bool +} + +// PromotionPreview is shown to a human before a task canvas becomes a +// workspace canvas. +type PromotionPreview struct { + Canvas *Canvas + SourceActorKind string `json:"source_actor_kind"` + SourceUserID string `json:"source_user_id,omitempty"` + SourceTaskID string `json:"source_task_id,omitempty"` + SourceSessionID string `json:"source_session_id,omitempty"` + Permissions PermissionSummary `json:"permissions"` + ActiveReleaseID string `json:"active_release_id"` + PermissionDigest string `json:"permission_digest"` + GrantGeneration int64 `json:"grant_generation"` + CurrentScope string `json:"current_scope"` + TargetScope string `json:"target_scope"` + Placement string `json:"placement"` +} + +// authoringInstanceStore is the release/governance extension implemented by +// the durable plugin instance store. Keeping it optional preserves the small +// lifecycle fake used by the basic canvas service tests. +type authoringInstanceStore interface { + PluginInstanceStore + SetPluginID(context.Context, string, string) error + CreateRelease(context.Context, plugininstances.Release) error + ListReleases(context.Context, string) ([]plugininstances.Release, error) + ActivateRelease(context.Context, string, string) error + SetReleaseValidation(context.Context, string, string, string) error + ListGrants(context.Context, string) ([]plugininstances.Grant, error) + PromoteScopeAndGrants(context.Context, string, string, string, []plugininstances.Grant) error + ApproveRelease(context.Context, string, string, string, []plugininstances.Grant) error +} + +type transactionalAuthoringStore interface { + authoringInstanceStore + WithTransaction(context.Context, func(*sqlx.Tx) error) error + CreateReleaseTx(context.Context, *sqlx.Tx, plugininstances.Release) error + SetPluginIDTx(context.Context, *sqlx.Tx, string, string) error + ActivateReleaseTx(context.Context, *sqlx.Tx, string, string) error +} + +type reviewedPromotionStore interface { + PromoteScopeAndGrantsReviewedTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant, string, string, int64) error +} + +type conditionalReleaseStore interface { + CreateReleaseIfAuthorityTx(context.Context, *sqlx.Tx, string, plugininstances.PublishAuthority, plugininstances.Release) error +} + +type legacyConditionalReleaseStore interface { + CreateReleaseIfActiveReleaseTx(context.Context, *sqlx.Tx, string, string, plugininstances.Release) error +} + +// releaseRetentionStore is optional so the authoring service remains +// compatible with the narrow fakes used by lifecycle tests. The durable +// plugin instance store implements it to remove superseded release ownership +// after a publish. +type releaseRetentionStore interface { + PruneReleases(context.Context, string) error +} + +type releaseRejectionStore interface { + RejectReleaseAndPrune(context.Context, string, string) error +} + +// PublishPackage stores one validated package as an immutable release. A +// failed validation, quota check, or activation leaves the current active +// pointer untouched. +func (s *Service) PublishPackage(ctx context.Context, request PublishRequest) (*PublishResult, error) { + if err := s.ready(); err != nil { + return nil, err + } + store, ok := s.instances.(authoringInstanceStore) + if !ok { + return nil, ErrCanvasNotConfigured + } + instance, release, activated, 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) + if err != nil { + if persisted { + return &PublishResult{Release: release, Activated: activated, PermissionRequired: !activated, ReleasePersisted: true}, err + } + return nil, err + } + result := &PublishResult{Release: release, Activated: activated, PermissionRequired: !activated, ReleasePersisted: true} + if err := prunePublishedReleases(ctx, store, instance.ID); err != nil { + return result, err + } + resultCanvas, err := s.Get(ctx, request.CanvasID) + if err != nil { + return result, err + } + result.Canvas = resultCanvas + eventType := EventReleasePermissionRequired + if activated { + eventType = EventReleaseActivated + } + s.mu.Lock() + publisher := s.publisher + s.mu.Unlock() + publishEvent(ctx, publisher, lifecycleEvent(eventType, *resultCanvas)) + 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) + } + _, instance, err := s.load(ctx, request.CanvasID) + if err != nil { + return plugininstances.Instance{}, plugininstances.Release{}, false, 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) + } + permissions := ManifestPermissions(request.Package.Manifest) + grants, err := store.ListGrants(ctx, instance.ID) + if err != nil { + return plugininstances.Instance{}, plugininstances.Release{}, false, err + } + activated := permissionsFit(permissions, instance.ScopeKind, grants) + 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, + 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), + ManifestJSON: manifestJSON, DeclaredPermissionsJSON: permissionsJSON, + ArtifactPath: request.Artifact.RelativePath, ArtifactBytes: request.Artifact.Bytes, + ProtocolVersion: 1, ValidationStatus: validationStatus, ValidationError: validationError, + CreatedAt: s.nowUTC(), + } + return instance, release, activated, nil +} + +func persistPublishedRelease(ctx context.Context, store authoringInstanceStore, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority, expectedBaseReleaseID string) (bool, error) { + if !expectedAuthority.IsZero() { + if expectedBaseReleaseID != "" && expectedBaseReleaseID != expectedAuthority.ActiveReleaseID { + return false, ErrStaleCanvasEdit + } + transactional, ok := store.(transactionalAuthoringStore) + conditional, supportsConditional := store.(conditionalReleaseStore) + if !ok || !supportsConditional { + return false, ErrStaleCanvasPublish + } + err := persistAuthorityRelease(ctx, transactional, conditional, instanceID, release, activated, expectedAuthority) + return err == nil, err + } + if expectedBaseReleaseID != "" { + transactional, ok := store.(transactionalAuthoringStore) + conditional, supportsConditional := store.(legacyConditionalReleaseStore) + if !ok || !supportsConditional { + return false, ErrStaleCanvasEdit + } + err := persistConditionalRelease(ctx, transactional, conditional, instanceID, release, activated, expectedBaseReleaseID) + return err == nil, err + } + if !activated { + if err := store.CreateRelease(ctx, release); err != nil { + return false, err + } + return true, nil + } + transactional, ok := store.(transactionalAuthoringStore) + if ok { + err := persistActivatedRelease(ctx, transactional, instanceID, release) + return err == nil, err + } + 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 { + return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error { + if err := conditional.CreateReleaseIfAuthorityTx(ctx, tx, instanceID, expectedAuthority, release); err != nil { + return err + } + if !activated { + return nil + } + if err := transactional.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil { + return err + } + return transactional.ActivateReleaseTx(ctx, tx, instanceID, release.ID) + }) +} + +func persistConditionalRelease(ctx context.Context, transactional transactionalAuthoringStore, conditional legacyConditionalReleaseStore, instanceID string, release plugininstances.Release, activated bool, expectedBaseReleaseID string) error { + return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error { + if err := conditional.CreateReleaseIfActiveReleaseTx(ctx, tx, instanceID, expectedBaseReleaseID, release); err != nil { + return err + } + if !activated { + return nil + } + if err := transactional.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil { + return err + } + return transactional.ActivateReleaseTx(ctx, tx, instanceID, release.ID) + }) +} + +func persistActivatedRelease(ctx context.Context, store transactionalAuthoringStore, instanceID string, release plugininstances.Release) error { + return store.WithTransaction(ctx, func(tx *sqlx.Tx) error { + if err := store.CreateReleaseTx(ctx, tx, release); err != nil { + return err + } + if err := store.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil { + return err + } + return store.ActivateReleaseTx(ctx, tx, instanceID, release.ID) + }) +} + +func persistActivatedReleaseFallback(ctx context.Context, store authoringInstanceStore, instanceID string, release plugininstances.Release) (bool, error) { + if err := store.CreateRelease(ctx, release); err != nil { + return false, err + } + if err := store.SetPluginID(ctx, instanceID, release.PluginID); err != nil { + return true, err + } + if err := store.ActivateRelease(ctx, instanceID, release.ID); err != nil { + return true, err + } + return true, nil +} + +// Releases returns release history for a canvas after checking its durable +// metadata binding. +func (s *Service) Releases(ctx context.Context, canvasID string) ([]plugininstances.Release, error) { + if err := s.ready(); err != nil { + return nil, err + } + store, ok := s.instances.(authoringInstanceStore) + if !ok { + return nil, ErrCanvasNotConfigured + } + _, instance, err := s.load(ctx, canvasID) + if err != nil { + return nil, err + } + return store.ListReleases(ctx, instance.ID) +} + +// PromotionPreview builds the human review projection for a task-scoped +// canvas. It does not mutate scope, grants, or release state. +func (s *Service) PromotionPreview(ctx context.Context, canvasID string) (*PromotionPreview, error) { + if err := s.ready(); err != nil { + return nil, err + } + store, ok := s.instances.(authoringInstanceStore) + if !ok { + return nil, ErrCanvasNotConfigured + } + canvas, err := s.Get(ctx, canvasID) + if err != nil { + return nil, err + } + if canvas.ScopeKind != ScopeTask || canvas.ActiveReleaseID == "" || canvas.ActiveReleaseStatus != ValidationValid { + return nil, fmt.Errorf("%w: canvas must have a valid task-scoped release", ErrInvalidCanvasState) + } + release, err := store.GetRelease(ctx, canvas.ActiveReleaseID) + if err != nil { + return nil, err + } + m, err := manifestFromRelease(release) + if err != nil { + return nil, err + } + return &PromotionPreview{ + Canvas: canvas, + SourceActorKind: release.SourceActorKind, + SourceUserID: release.SourceUserID, + SourceTaskID: release.SourceTaskID, + SourceSessionID: release.SourceSessionID, + Permissions: ManifestPermissions(m), + ActiveReleaseID: release.ID, + PermissionDigest: PermissionDigest(release), + GrantGeneration: canvas.GrantGeneration, + CurrentScope: ScopeTask, + TargetScope: ScopeWorkspace, + Placement: manifest.WebAppPlacementWorkspace, + }, nil +} + +// PromoteCanvas atomically approves the active declaration and changes the +// instance scope with the canvas metadata provenance. The durable store +// transaction keeps both lifecycle authorities consistent after a crash. +func (s *Service) PromoteCanvas(ctx context.Context, canvasID, userID string) (*Canvas, error) { + return s.PromoteCanvasReviewed(ctx, canvasID, userID, "", "", 0) +} + +// PromoteCanvasReviewed atomically applies a promotion only when the release +// and permission projection still match the human review response. +func (s *Service) PromoteCanvasReviewed(ctx context.Context, canvasID, userID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) (*Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + store, ok := s.instances.(authoringInstanceStore) + if !ok { + return nil, ErrCanvasNotConfigured + } + if strings.TrimSpace(userID) == "" { + return nil, fmt.Errorf("%w: approving user is required", ErrInvalidCanvas) + } + preview, err := s.PromotionPreview(ctx, canvasID) + if err != nil { + return nil, err + } + grants := grantsForManifest(preview.Permissions, userID, ScopeWorkspace) + promotedAt := s.nowUTC() + if err := s.promoteInstanceForReview(ctx, store, preview, canvasID, userID, grants, promotedAt, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil { + return nil, err + } + updated, err := s.Get(ctx, canvasID) + if err != nil { + return nil, err + } + s.mu.Lock() + publisher := s.publisher + s.mu.Unlock() + publishEvent(ctx, publisher, lifecycleEvent(EventPromoted, *updated)) + return updated, nil +} + +func (s *Service) promoteInstanceForReview(ctx context.Context, store authoringInstanceStore, preview *PromotionPreview, canvasID, userID string, grants []plugininstances.Grant, promotedAt time.Time, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { + reviewed := expectedReleaseID != "" || expectedPermissionDigest != "" || expectedGrantGeneration > 0 + transactional, isTransactional := store.(transactionalPluginInstanceStore) + if isTransactional { + err := transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error { + if err := promoteInstanceTx(ctx, tx, transactional, preview, userID, grants, reviewed, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil { + return err + } + return s.repo.PromoteTx(ctx, tx, canvasID, userID, promotedAt) + }) + return err + } + if reviewed { + return ErrStalePromotionReview + } + if err := store.PromoteScopeAndGrants(ctx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants); err != nil { + return err + } + if err := s.repo.Promote(ctx, canvasID, userID, promotedAt); err != nil { + return errors.Join(ErrCanvasMetadataBroken, err) + } + return nil +} + +func promoteInstanceTx(ctx context.Context, tx *sqlx.Tx, store transactionalPluginInstanceStore, preview *PromotionPreview, userID string, grants []plugininstances.Grant, reviewed bool, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { + if reviewed { + reviewedStore, ok := store.(reviewedPromotionStore) + if !ok { + return ErrStalePromotionReview + } + return reviewedStore.PromoteScopeAndGrantsReviewedTx(ctx, tx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration) + } + return store.PromoteScopeAndGrantsTx(ctx, tx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants) +} + +// ApproveRelease grants the requested permissions and activates one pending +// release without changing the canvas scope. +func (s *Service) ApproveRelease(ctx context.Context, canvasID, releaseID, userID string) (*Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + store, ok := s.instances.(authoringInstanceStore) + if !ok { + return nil, ErrCanvasNotConfigured + } + canvas, err := s.Get(ctx, canvasID) + if err != nil { + return nil, err + } + if err := releaseMutationStateError(canvas.Status); err != nil { + return nil, err + } + release, err := store.GetRelease(ctx, releaseID) + if err != nil { + return nil, err + } + if release.InstanceID != canvas.PluginInstanceID { + return nil, plugininstances.ErrInvalidRelease + } + m, err := manifestFromRelease(release) + if err != nil { + return nil, err + } + if err := store.ApproveRelease(ctx, canvas.PluginInstanceID, releaseID, userID, grantsForManifest(ManifestPermissions(m), userID, canvas.ScopeKind)); err != nil { + return nil, err + } + if err := prunePublishedReleases(ctx, store, canvas.PluginInstanceID); err != nil { + return nil, err + } + updated, err := s.Get(ctx, canvasID) + if err != nil { + return nil, err + } + s.mu.Lock() + publisher := s.publisher + s.mu.Unlock() + publishEvent(ctx, publisher, lifecycleEvent(EventReleaseActivated, *updated)) + return updated, nil +} + +// RejectRelease keeps the active release untouched and records a safe +// rejection diagnostic on the pending release. +func (s *Service) RejectRelease(ctx context.Context, canvasID, releaseID string) (*Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + store, ok := s.instances.(authoringInstanceStore) + if !ok { + return nil, ErrCanvasNotConfigured + } + canvas, err := s.Get(ctx, canvasID) + if err != nil { + return nil, err + } + if err := releaseMutationStateError(canvas.Status); err != nil { + return nil, err + } + release, err := store.GetRelease(ctx, releaseID) + if err != nil { + return nil, err + } + if release.InstanceID != canvas.PluginInstanceID || release.ValidationStatus != ValidationPendingPermission { + return nil, plugininstances.ErrInvalidRelease + } + if rejection, ok := store.(releaseRejectionStore); ok { + if err := rejection.RejectReleaseAndPrune(ctx, canvas.PluginInstanceID, releaseID); err != nil { + return nil, err + } + } else { + if err := store.SetReleaseValidation(ctx, releaseID, plugininstances.ValidationInvalid, "rejected_by_user"); err != nil { + return nil, err + } + if err := prunePublishedReleases(ctx, store, canvas.PluginInstanceID); err != nil { + return nil, err + } + } + return s.Get(ctx, canvasID) +} + +// RollbackRelease selects a retained valid release and rechecks its required +// permissions against current grants before moving the active pointer. +func (s *Service) RollbackRelease(ctx context.Context, canvasID, releaseID string) (*Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + store, ok := s.instances.(authoringInstanceStore) + if !ok { + return nil, ErrCanvasNotConfigured + } + canvas, err := s.Get(ctx, canvasID) + if err != nil { + return nil, err + } + if err := releaseMutationStateError(canvas.Status); err != nil { + return nil, err + } + instance, err := store.Get(ctx, canvas.PluginInstanceID) + if err != nil { + return nil, err + } + releases, err := store.ListReleases(ctx, instance.ID) + if err != nil { + return nil, err + } + release, err := selectRollbackRelease(ctx, store, instance, releases, releaseID) + if err != nil { + return nil, err + } + m, err := manifestFromRelease(release) + if err != nil { + return nil, err + } + grants, err := store.ListGrants(ctx, instance.ID) + if err != nil { + return nil, err + } + if !permissionsFit(ManifestPermissions(m), instance.ScopeKind, grants) { + return nil, fmt.Errorf("%w: rollback requires permission review", plugininstances.ErrInvalidRelease) + } + if err := store.ActivateRelease(ctx, instance.ID, release.ID); err != nil { + return nil, err + } + if err := prunePublishedReleases(ctx, store, instance.ID); err != nil { + return nil, err + } + updated, err := s.Get(ctx, canvasID) + if err != nil { + return nil, err + } + s.mu.Lock() + publisher := s.publisher + s.mu.Unlock() + publishEvent(ctx, publisher, lifecycleEvent(EventReleaseActivated, *updated)) + return updated, nil +} + +func selectRollbackRelease(ctx context.Context, store authoringInstanceStore, instance plugininstances.Instance, releases []plugininstances.Release, releaseID string) (plugininstances.Release, error) { + if releaseID == "" { + for _, candidate := range releases { + if candidate.ID != instance.ActiveReleaseID && candidate.ValidationStatus == ValidationValid { + releaseID = candidate.ID + break + } + } + } + if releaseID == "" { + return plugininstances.Release{}, fmt.Errorf("%w: no retained valid release", plugininstances.ErrInvalidRelease) + } + release, err := store.GetRelease(ctx, releaseID) + if err != nil { + return plugininstances.Release{}, err + } + if release.InstanceID != instance.ID || release.ID == instance.ActiveReleaseID || release.ValidationStatus != ValidationValid { + return plugininstances.Release{}, plugininstances.ErrInvalidRelease + } + return release, nil +} + +func prunePublishedReleases(ctx context.Context, store authoringInstanceStore, instanceID string) error { + retention, ok := store.(releaseRetentionStore) + if !ok { + return nil + } + return retention.PruneReleases(ctx, instanceID) +} + +func releaseMutationStateError(status string) error { + if status == StatusArchived || status == StatusDisabled { + return fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, status) + } + return nil +} + +// ManifestPermissions converts the manifest into the review/runtime shape. +func ManifestPermissions(m *manifest.Manifest) PermissionSummary { + if m == nil { + return PermissionSummary{} + } + origins := nonEmptyStrings([]string{m.BaseURL}) + seenOrigins := make(map[string]struct{}, len(origins)) + for _, origin := range origins { + seenOrigins[origin] = struct{}{} + } + for _, app := range m.UI.WebApps { + normalized, err := webapp.NormalizeNetworkOrigins(app.NetworkOrigins) + if err != nil { + continue + } + for _, origin := range normalized { + if _, exists := seenOrigins[origin]; exists { + continue + } + seenOrigins[origin] = struct{}{} + origins = append(origins, origin) + } + } + return PermissionSummary{ + Reads: append([]string(nil), m.Capabilities.APIRead...), + Writes: append([]string(nil), m.Capabilities.APIWrite...), + Events: append([]string(nil), m.Capabilities.Events...), + SharedState: m.Capabilities.State, + ExternalOrigins: origins, + } +} + +func manifestFromRelease(release plugininstances.Release) (*manifest.Manifest, error) { + var m manifest.Manifest + if err := json.Unmarshal(release.ManifestJSON, &m); err != nil { + return nil, fmt.Errorf("parse release manifest: %w", err) + } + return &m, nil +} + +// ReleasePermissionSummary returns the review-safe declaration persisted with +// a release. Older rows used a flat permission-key array, which remains +// readable so a release review never falls back to an empty declaration. +func ReleasePermissionSummary(release plugininstances.Release) PermissionSummary { + var summary PermissionSummary + if err := json.Unmarshal(release.DeclaredPermissionsJSON, &summary); err == nil { + return summary + } + var legacy []string + if err := json.Unmarshal(release.DeclaredPermissionsJSON, &legacy); err != nil { + return summary + } + for _, permission := range legacy { + parts := strings.SplitN(permission, ":", 2) + if len(parts) != 2 { + continue + } + switch parts[0] { + case permissionKindAPIRead: + summary.Reads = append(summary.Reads, parts[1]) + case permissionKindAPIWrite: + summary.Writes = append(summary.Writes, parts[1]) + case permissionKindEvents: + summary.Events = append(summary.Events, parts[1]) + case permissionKindNetwork: + summary.ExternalOrigins = append(summary.ExternalOrigins, parts[1]) + case permissionKindState: + summary.SharedState = true + } + } + return summary +} + +// PermissionDigest returns the digest of the exact persisted declaration. It +// is safe to show to a reviewer and lets confirmation reject a changed +// declaration without exposing its manifest or source package. +func PermissionDigest(release plugininstances.Release) string { + digest := sha256.Sum256([]byte(strings.TrimSpace(string(release.DeclaredPermissionsJSON)))) + return fmt.Sprintf("%x", digest[:]) +} + +// MissingPermissionKeys reports declaration keys not covered by the current +// grants at the release scope. The result is sorted for stable API output. +func MissingPermissionKeys(summary PermissionSummary, scope string, grants []plugininstances.Grant) []string { + missing := make([]string, 0) + for _, permission := range permissionKeys(summary) { + if !grantCovers(permission, scope, grants) { + missing = append(missing, permission) + } + } + sort.Strings(missing) + return missing +} + +func permissionKeys(summary PermissionSummary) []string { + keys := make([]string, 0, len(summary.Reads)+len(summary.Writes)+len(summary.Events)+len(summary.ExternalOrigins)+1) + for _, value := range summary.Reads { + keys = append(keys, permissionKindAPIRead+":"+value) + } + for _, value := range summary.Writes { + keys = append(keys, permissionKindAPIWrite+":"+value) + } + for _, value := range summary.Events { + keys = append(keys, permissionKindEvents+":"+value) + } + for _, value := range summary.ExternalOrigins { + keys = append(keys, permissionKindNetwork+":"+value) + } + if summary.SharedState { + keys = append(keys, permissionKindState) + } + return keys +} + +func permissionsFit(summary PermissionSummary, scope string, grants []plugininstances.Grant) bool { + if len(permissionKeys(summary)) == 0 { + return true + } + for _, required := range permissionKeys(summary) { + if !grantCovers(required, scope, grants) { + return false + } + } + return true +} + +func grantCovers(permission, scope string, grants []plugininstances.Grant) bool { + parts := strings.SplitN(permission, ":", 2) + kind, resource := parts[0], "" + if len(parts) == 2 { + resource = parts[1] + } + for _, grant := range grants { + if kind == permissionKindNetwork { + if grant.PermissionKind == kind && grant.NetworkOrigin == resource && grantScopeCovers(grant.ScopeCeiling, scope) { + return true + } + continue + } + if grant.PermissionKind == kind && grant.Resource == resource && grantScopeCovers(grant.ScopeCeiling, scope) { + return true + } + } + return false +} + +func grantScopeCovers(ceiling, scope string) bool { + return ceiling == plugininstances.ScopeInstance || ceiling == scope +} + +func grantsForManifest(summary PermissionSummary, userID, scope string) []plugininstances.Grant { + grantScope := scope + if grantScope == "" { + grantScope = ScopeWorkspace + } + now := time.Now().UTC() + grants := make([]plugininstances.Grant, 0, len(permissionKeys(summary))) + for _, permission := range permissionKeys(summary) { + parts := strings.SplitN(permission, ":", 2) + resource := "" + networkOrigin := "" + if len(parts) == 2 { + resource = parts[1] + if parts[0] == permissionKindNetwork { + networkOrigin = resource + resource = "" + } + } + grants = append(grants, plugininstances.Grant{ + PermissionKind: parts[0], Resource: resource, NetworkOrigin: networkOrigin, ScopeCeiling: grantScope, + ApprovedBy: userID, ApprovedAt: now, + }) + } + return grants +} + +func nonEmptyStrings(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if strings.TrimSpace(value) != "" && !containsString(result, value) { + result = append(result, value) + } + } + return result +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/apps/backend/internal/canvas/authoring_test.go b/apps/backend/internal/canvas/authoring_test.go new file mode 100644 index 0000000000..326625b503 --- /dev/null +++ b/apps/backend/internal/canvas/authoring_test.go @@ -0,0 +1,561 @@ +package canvas + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + plugininstances "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/webapp" +) + +func TestPublishPackageFirstReleaseRequiresMatchingGrants(t *testing.T) { + tests := []struct { + name string + reads []string + wantActivated bool + wantStatus string + }{ + { + name: "zero permissions activate", + wantActivated: true, + wantStatus: plugininstances.ValidationValid, + }, + { + name: "declared permissions require grants", + reads: []string{"tasks"}, + wantActivated: false, + wantStatus: plugininstances.ValidationPendingPermission, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "First release", + }) + + result := publishTestPackage(t, service, canvas.ID, "first-release", tt.reads) + if result.Activated != tt.wantActivated { + t.Fatalf("activated = %t, want %t", result.Activated, tt.wantActivated) + } + if result.Release.ValidationStatus != tt.wantStatus { + t.Fatalf("release status = %q, want %q", result.Release.ValidationStatus, tt.wantStatus) + } + + instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + if tt.wantActivated { + if instance.ActiveReleaseID != result.Release.ID { + t.Fatalf("active release = %q, want %q", instance.ActiveReleaseID, result.Release.ID) + } + } else if instance.ActiveReleaseID != "" { + t.Fatalf("active release = %q, want no active release", instance.ActiveReleaseID) + } + }) + } +} + +func TestFirstTaskReleaseCanBeReviewedAndApproved(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "First approval", + }) + published := publishTestPackage(t, service, canvas.ID, "first-approved", []string{"tasks"}) + if published.Activated || !published.PermissionRequired { + t.Fatalf("first release result = %+v, want pending permission review", published) + } + before, err := service.Get(context.Background(), canvas.ID) + if err != nil { + t.Fatalf("get pending canvas: %v", err) + } + if before.PendingRelease == nil || before.PendingRelease.Permissions == nil || len(before.PendingRelease.Permissions.Reads) != 1 || len(before.PendingRelease.MissingPermissions) != 1 { + t.Fatalf("pending release review projection = %+v, want declared and missing tasks permission", before.PendingRelease) + } + if before.PendingRelease.Permissions.Reads[0] != "tasks" || before.PendingRelease.MissingPermissions[0] != "api_read:tasks" { + t.Fatalf("pending permission projection = %+v, want api_read:tasks", before.PendingRelease) + } + + approved, err := service.ApproveRelease(context.Background(), canvas.ID, published.Release.ID, "user-1") + if err != nil { + t.Fatalf("approve first task release: %v", err) + } + if approved.ActiveReleaseID != published.Release.ID || approved.ActiveReleaseStatus != plugininstances.ValidationValid { + t.Fatalf("approved canvas = %+v, want active valid release", approved) + } + instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("get approved instance: %v", err) + } + if instance.PluginID != published.Release.PluginID { + t.Fatalf("approved plugin id = %q, want release plugin id %q", instance.PluginID, published.Release.PluginID) + } + grants, err := instanceStore.ListGrants(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("list approved grants: %v", err) + } + if len(grants) != 1 || grants[0].PermissionKind != "api_read" || grants[0].Resource != "tasks" { + t.Fatalf("approved grants = %+v, want api_read:tasks", grants) + } +} + +func TestPublishPackageRejectsStaleEditBaseRelease(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + Title: "Concurrent edits", + }) + first := publishTestPackage(t, service, canvas.ID, "edit-base-a", nil) + second := publishTestPackage(t, service, canvas.ID, "edit-base-b", nil) + + _, err := service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: canvas.ID, + Package: testCanvasPackage("edit-stale", nil), + Artifact: webapp.Artifact{Digest: "edit-stale", RelativePath: "releases/edit-stale", Bytes: 1}, + SourceActorKind: "agent", + ExpectedBaseReleaseID: first.Release.ID, + }) + if !errors.Is(err, ErrStaleCanvasEdit) { + t.Fatalf("stale edit publish error = %v, want ErrStaleCanvasEdit", err) + } + instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + if instance.ActiveReleaseID != second.Release.ID { + t.Fatalf("active release = %q, want newer edit %q", instance.ActiveReleaseID, second.Release.ID) + } +} + +func TestPromotionRejectsStaleReleaseReview(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Review race", + }) + first := publishTestPackage(t, service, canvas.ID, "review-a", nil) + preview, err := service.PromotionPreview(context.Background(), canvas.ID) + if err != nil { + t.Fatalf("promotion preview: %v", err) + } + if preview.ActiveReleaseID != first.Release.ID { + t.Fatalf("preview release = %q, want %q", preview.ActiveReleaseID, first.Release.ID) + } + second := publishTestPackage(t, service, canvas.ID, "review-b", nil) + + _, err = service.PromoteCanvasReviewed(context.Background(), canvas.ID, "user-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration) + if !errors.Is(err, ErrStalePromotionReview) { + t.Fatalf("stale promotion error = %v, want ErrStalePromotionReview", err) + } + instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + if instance.ActiveReleaseID != second.Release.ID || instance.ScopeKind != plugininstances.ScopeTask { + t.Fatalf("instance after stale promotion = %+v, want task scope and release %q", instance, second.Release.ID) + } +} + +func TestPromotionRejectsChangedGrantGeneration(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Grant review race", + }) + publishTestPackage(t, service, canvas.ID, "grant-review", nil) + preview, err := service.PromotionPreview(context.Background(), canvas.ID) + if err != nil { + t.Fatalf("promotion preview: %v", err) + } + if err := instanceStore.AddGrant(context.Background(), plugininstances.Grant{ + InstanceID: canvas.PluginInstanceID, + PermissionKind: "api_read", + Resource: "tasks", + ScopeCeiling: plugininstances.ScopeTask, + ApprovedBy: "user-1", + }); err != nil { + t.Fatalf("add grant: %v", err) + } + + _, err = service.PromoteCanvasReviewed(context.Background(), canvas.ID, "user-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration) + if !errors.Is(err, ErrStalePromotionReview) { + t.Fatalf("changed grant generation error = %v, want ErrStalePromotionReview", err) + } +} + +func TestPermissionsFitHonorsInstanceScope(t *testing.T) { + permissions := PermissionSummary{Reads: []string{"tasks"}} + grants := []plugininstances.Grant{{ + PermissionKind: "api_read", + Resource: "tasks", + ScopeCeiling: plugininstances.ScopeTask, + }} + if permissionsFit(permissions, plugininstances.ScopeTask, grants) != true { + t.Fatal("task grant did not cover task-scoped release") + } + if permissionsFit(permissions, plugininstances.ScopeWorkspace, grants) { + t.Fatal("task grant covered workspace-scoped release") + } +} + +func TestCanvasProjectionIncludesEffectiveGrantedPermissions(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Effective grants", + }) + if err := instanceStore.AddGrant(context.Background(), plugininstances.Grant{ + InstanceID: canvas.PluginInstanceID, + PermissionKind: "api_read", + Resource: "tasks", + ScopeCeiling: plugininstances.ScopeTask, + ApprovedBy: "user-1", + }); err != nil { + t.Fatalf("add grant: %v", err) + } + publishTestPackage(t, service, canvas.ID, "effective-grants", []string{"tasks"}) + + got, err := service.Get(context.Background(), canvas.ID) + if err != nil { + t.Fatalf("get canvas: %v", err) + } + if len(got.EffectiveGrants) != 1 || got.EffectiveGrants[0].PermissionKind != "api_read" || got.EffectiveGrants[0].Resource != "tasks" { + t.Fatalf("effective grants = %+v, want api_read:tasks", got.EffectiveGrants) + } +} + +func TestPublishPackagePrunesSupersededPendingReleasesWithoutChangingActive(t *testing.T) { + service, instanceStore, pool := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Pending releases", + }) + setAuthoringTestClock(service) + + active := publishTestPackage(t, service, canvas.ID, "active-release", nil) + pendingOne := publishTestPackage(t, service, canvas.ID, "pending-one", []string{"tasks"}) + pendingTwo := publishTestPackage(t, service, canvas.ID, "pending-two", []string{"workflows"}) + + instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + if instance.ActiveReleaseID != active.Release.ID { + t.Fatalf("active release = %q, want %q", instance.ActiveReleaseID, active.Release.ID) + } + + releases, err := instanceStore.ListReleases(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("list releases: %v", err) + } + assertReleaseStatus(t, releases, active.Release.ID, plugininstances.ValidationValid, "") + assertReleaseAbsent(t, releases, pendingOne.Release.ID) + assertReleaseStatus(t, releases, pendingTwo.Release.ID, plugininstances.ValidationPendingPermission, "permission_review_required") + if got := countReleaseStatus(releases, plugininstances.ValidationPendingPermission); got != 1 { + t.Fatalf("pending releases = %d, want 1", got) + } + if got := cleanupArtifactPaths(t, pool, canvas.PluginInstanceID); len(got) != 1 || got[0] != "releases/pending-one" { + t.Fatalf("cleanup paths = %v, want [releases/pending-one]", got) + } +} + +func TestPublishPackageRetainsPriorValidReleaseForRollback(t *testing.T) { + service, instanceStore, pool := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Valid releases", + }) + setAuthoringTestClock(service) + active := publishTestPackage(t, service, canvas.ID, "active-release", nil) + + if err := instanceStore.AddGrant(context.Background(), plugininstances.Grant{ + InstanceID: canvas.PluginInstanceID, + PermissionKind: "api_read", + Resource: "tasks", + ScopeCeiling: plugininstances.ScopeTask, + ApprovedBy: "user-1", + }); err != nil { + t.Fatalf("add grant: %v", err) + } + prior := publishTestPackage(t, service, canvas.ID, "prior-release", []string{"tasks"}) + latest := publishTestPackage(t, service, canvas.ID, "latest-release", []string{"tasks"}) + + instance, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + if instance.ActiveReleaseID != latest.Release.ID { + t.Fatalf("active release = %q, want %q", instance.ActiveReleaseID, latest.Release.ID) + } + + releases, err := instanceStore.ListReleases(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("list releases: %v", err) + } + assertReleaseAbsent(t, releases, active.Release.ID) + assertReleaseStatus(t, releases, prior.Release.ID, plugininstances.ValidationValid, "") + assertReleaseStatus(t, releases, latest.Release.ID, plugininstances.ValidationValid, "") + if got := countReleaseStatus(releases, plugininstances.ValidationValid); got != 2 { + t.Fatalf("valid releases = %d, want active plus one prior", got) + } + if got := cleanupArtifactPaths(t, pool, canvas.PluginInstanceID); len(got) != 1 || got[0] != "releases/active-release" { + t.Fatalf("cleanup paths = %v, want [releases/active-release]", got) + } + if err := instanceStore.ActivateRelease(context.Background(), canvas.PluginInstanceID, active.Release.ID); !errors.Is(err, plugininstances.ErrInvalidRelease) { + t.Fatalf("activate superseded release = %v, want ErrInvalidRelease", err) + } + + rolledBack, err := service.RollbackRelease(context.Background(), canvas.ID, "") + if err != nil { + t.Fatalf("rollback: %v", err) + } + if rolledBack.ActiveReleaseID != prior.Release.ID { + t.Fatalf("rollback active release = %q, want %q", rolledBack.ActiveReleaseID, prior.Release.ID) + } + if rolledBack.PluginInstanceID != canvas.PluginInstanceID || rolledBack.ScopeKind != plugininstances.ScopeTask { + t.Fatalf("rollback changed canvas identity or scope: %+v", rolledBack) + } +} + +func TestRejectReleasePrunesRejectedArtifact(t *testing.T) { + service, instanceStore, pool := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Reject cleanup", + }) + active := publishTestPackage(t, service, canvas.ID, "reject-active", nil) + pending := publishTestPackage(t, service, canvas.ID, "reject-pending", []string{"tasks"}) + + if _, err := service.RejectRelease(context.Background(), canvas.ID, pending.Release.ID); err != nil { + t.Fatalf("reject release: %v", err) + } + releases, err := instanceStore.ListReleases(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("list releases: %v", err) + } + assertReleaseStatus(t, releases, active.Release.ID, plugininstances.ValidationValid, "") + assertReleaseAbsent(t, releases, pending.Release.ID) + if got := cleanupArtifactPaths(t, pool, canvas.PluginInstanceID); len(got) != 1 || got[0] != "releases/reject-pending" { + t.Fatalf("cleanup paths = %v, want [releases/reject-pending]", got) + } +} + +func TestReleaseMutationsRequireRestoreAfterArchive(t *testing.T) { + service, _, _ := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Archived release mutations", + }) + prior := publishTestPackage(t, service, canvas.ID, "archived-prior", nil) + publishTestPackage(t, service, canvas.ID, "archived-active", nil) + pending := publishTestPackage(t, service, canvas.ID, "archived-pending", []string{"tasks"}) + if _, err := service.ArchiveCanvas(context.Background(), canvas.ID); err != nil { + t.Fatalf("archive canvas: %v", err) + } + + if _, err := service.ApproveRelease(context.Background(), canvas.ID, pending.Release.ID, "user-1"); !errors.Is(err, ErrInvalidCanvasState) { + t.Fatalf("approve archived release = %v, want ErrInvalidCanvasState", err) + } + if _, err := service.RollbackRelease(context.Background(), canvas.ID, prior.Release.ID); !errors.Is(err, ErrInvalidCanvasState) { + t.Fatalf("rollback archived release = %v, want ErrInvalidCanvasState", err) + } + if _, err := service.PromoteCanvas(context.Background(), canvas.ID, "user-1"); !errors.Is(err, ErrInvalidLifecycleState) { + t.Fatalf("promote archived canvas = %v, want ErrInvalidLifecycleState", err) + } +} + +func TestPublishPackageRejectsAuthorityChangedAfterAuthorization(t *testing.T) { + tests := []struct { + name string + mutate func(*Service, *plugininstances.Store, string, string) error + }{ + { + name: "archive", + mutate: func(_ *Service, store *plugininstances.Store, _ string, instanceID string) error { + return store.Archive(context.Background(), instanceID) + }, + }, + { + name: "promote", + mutate: func(service *Service, _ *plugininstances.Store, canvasID, _ string) error { + _, err := service.PromoteCanvas(context.Background(), canvasID, "user-1") + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Publish authority race", + }) + if tt.name == "promote" { + publishTestPackage(t, service, canvas.ID, "authority-base", nil) + } + captured, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("capture authority: %v", err) + } + barrier := &publishTransactionBarrier{ + Store: instanceStore, + entered: make(chan struct{}), + continueFirst: make(chan struct{}), + } + service.instances = barrier + resultCh := make(chan error, 1) + go func() { + _, publishErr := service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: canvas.ID, Package: testCanvasPackage("authority-race", nil), + Artifact: webapp.Artifact{Digest: "authority-race", RelativePath: "releases/authority-race", Bytes: 1}, + ExpectedAuthority: captured.PublishAuthority(), SourceActorKind: "agent", + }) + resultCh <- publishErr + }() + <-barrier.entered + + if err := tt.mutate(service, instanceStore, canvas.ID, canvas.PluginInstanceID); err != nil { + t.Fatalf("%s authority mutation: %v", tt.name, err) + } + close(barrier.continueFirst) + if err := <-resultCh; !errors.Is(err, ErrStaleCanvasPublish) { + t.Fatalf("publish after %s = %v, want ErrStaleCanvasPublish", tt.name, err) + } + }) + } +} + +type publishTransactionBarrier struct { + *plugininstances.Store + entered chan struct{} + continueFirst chan struct{} + once sync.Once +} + +func (s *publishTransactionBarrier) WithTransaction(ctx context.Context, fn func(*sqlx.Tx) error) error { + first := false + s.once.Do(func() { + first = true + close(s.entered) + }) + if first { + <-s.continueFirst + } + return s.Store.WithTransaction(ctx, fn) +} + +func publishTestPackage(t *testing.T, service *Service, canvasID, digest string, reads []string) *PublishResult { + t.Helper() + pkg := testCanvasPackage(digest, reads) + result, err := service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: canvasID, + Package: pkg, + Artifact: webapp.Artifact{Digest: digest, RelativePath: "releases/" + digest, Bytes: 1}, + SourceActorKind: "agent", + }) + if err != nil { + t.Fatalf("publish %s: %v", digest, err) + } + return result +} + +func testCanvasPackage(digest string, reads []string) *webapp.Package { + return &webapp.Package{ + Manifest: &manifest.Manifest{ + ID: "canvas-board", + APIVersion: manifest.CurrentAPIVersion, + Version: digest, + UI: manifest.UISection{WebApps: []manifest.WebApp{{ + Key: "main", + Title: "Board", + Entry: "index.html", + Placements: []string{manifest.WebAppPlacementTask}, + }}}, + Capabilities: manifest.Capabilities{APIRead: reads}, + }, + Digest: digest, + } +} + +func setAuthoringTestClock(service *Service) { + base := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + tick := 0 + service.clock = func() time.Time { + tick++ + return base.Add(time.Duration(tick) * time.Second) + } +} + +func assertReleaseStatus(t *testing.T, releases []plugininstances.Release, id, status, validationError string) { + t.Helper() + for _, release := range releases { + if release.ID != id { + continue + } + if release.ValidationStatus != status || release.ValidationError != validationError { + t.Fatalf("release %q = status %q, error %q; want status %q, error %q", id, release.ValidationStatus, release.ValidationError, status, validationError) + } + return + } + t.Fatalf("release %q not found in %+v", id, releases) +} + +func assertReleaseAbsent(t *testing.T, releases []plugininstances.Release, id string) { + t.Helper() + for _, release := range releases { + if release.ID == id { + t.Fatalf("release %q is still retained: %+v", id, release) + } + } +} + +func countReleaseStatus(releases []plugininstances.Release, status string) int { + count := 0 + for _, release := range releases { + if release.ValidationStatus == status { + count++ + } + } + return count +} + +func cleanupArtifactPaths(t *testing.T, pool *db.Pool, instanceID string) []string { + t.Helper() + rows, err := pool.Reader().Queryx(pool.Reader().Rebind( + "SELECT artifact_path FROM plugin_artifact_cleanup_jobs WHERE instance_id = ? ORDER BY artifact_path", + ), instanceID) + if err != nil { + t.Fatalf("list cleanup jobs: %v", err) + } + defer func() { _ = rows.Close() }() + var paths []string + for rows.Next() { + var path string + if err := rows.Scan(&path); err != nil { + t.Fatalf("scan cleanup job: %v", err) + } + paths = append(paths, path) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate cleanup jobs: %v", err) + } + return paths +} diff --git a/apps/backend/internal/canvas/canvas_test.go b/apps/backend/internal/canvas/canvas_test.go new file mode 100644 index 0000000000..e0d73e6cc3 --- /dev/null +++ b/apps/backend/internal/canvas/canvas_test.go @@ -0,0 +1,576 @@ +package canvas + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" + plugininstances "github.com/kandev/kandev/internal/plugins/instances" +) + +func openCanvasPool(t *testing.T, path string) *db.Pool { + t.Helper() + conn, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + pool := db.NewPool(sqlx.NewDb(conn, "sqlite3"), sqlx.NewDb(conn, "sqlite3")) + t.Cleanup(func() { _ = pool.Close() }) + return pool +} + +func newCanvasService(t *testing.T) (*Service, *plugininstances.Store, *db.Pool) { + t.Helper() + pool := openCanvasPool(t, filepath.Join(t.TempDir(), "canvas.db")) + instanceStore, err := plugininstances.NewStore(pool) + if err != nil { + t.Fatalf("new instance store: %v", err) + } + repo, err := NewRepository(pool) + if err != nil { + t.Fatalf("new canvas repository: %v", err) + } + return NewService(repo, instanceStore), instanceStore, pool +} + +func createCanvas(t *testing.T, service *Service, request CreateCanvasRequest) Canvas { + t.Helper() + created, err := service.Create(context.Background(), request) + if err != nil { + t.Fatalf("create canvas: %v", err) + } + return *created +} + +func TestGetExposesPendingFirstRelease(t *testing.T) { + service, store, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Pending"}) + release := plugininstances.Release{ + ID: "release-pending", PluginID: "canvas-package", InstanceID: created.PluginInstanceID, + PackageDigest: "digest", SourceKind: plugininstances.SourceLocalCanvas, + ArtifactPath: "releases/digest", ValidationStatus: plugininstances.ValidationPendingPermission, + ValidationError: "permission_review_required", ProtocolVersion: 1, + } + if err := store.CreateRelease(context.Background(), release); err != nil { + t.Fatalf("create pending release: %v", err) + } + got, err := service.Get(context.Background(), created.ID) + if err != nil { + t.Fatalf("get canvas: %v", err) + } + if got.PendingRelease == nil || got.PendingRelease.ID != release.ID { + t.Fatalf("pending release = %+v, want %s", got.PendingRelease, release.ID) + } + if got.ActiveReleaseID != "" { + t.Fatalf("active release ID = %q, want empty", got.ActiveReleaseID) + } +} + +func TestPendingReleaseLeavesRuntimeIdentityAndGenerationUntouched(t *testing.T) { + service, store, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Pending permissions", + }) + before, err := store.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance before publish: %v", err) + } + result := publishTestPackage(t, service, created.ID, "pending-release", []string{"tasks"}) + if result.Activated { + t.Fatal("permission-requiring release activated without a grant") + } + after, err := store.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance after publish: %v", err) + } + if after.PluginID != before.PluginID { + t.Fatalf("plugin identity changed for pending release: %q -> %q", before.PluginID, after.PluginID) + } + if after.GrantGeneration != before.GrantGeneration { + t.Fatalf("grant generation changed for pending release: %d -> %d", before.GrantGeneration, after.GrantGeneration) + } + if after.ActiveReleaseID != before.ActiveReleaseID { + t.Fatalf("active release changed for pending release: %q -> %q", before.ActiveReleaseID, after.ActiveReleaseID) + } + if result.Release.ValidationStatus != plugininstances.ValidationPendingPermission { + t.Fatalf("pending release status = %q", result.Release.ValidationStatus) + } +} + +func TestReconcileRepairsOrphanCanvasRowsAndInstances(t *testing.T) { + service, store, _ := newCanvasService(t) + ctx := context.Background() + orphanInstanceID := "orphan-instance" + if err := store.Create(ctx, plugininstances.Instance{ + ID: orphanInstanceID, PluginID: CanvasPluginID, + SourceKind: plugininstances.SourceLocalCanvas, ScopeKind: plugininstances.ScopeWorkspace, + WorkspaceID: "workspace-1", Status: plugininstances.StatusPending, + }); err != nil { + t.Fatalf("create orphan instance: %v", err) + } + metadata := CanvasMetadata{ + ID: "orphan-metadata", PluginInstanceID: "missing-instance", + WorkspaceID: "workspace-1", Title: "Orphan metadata", + CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + } + if err := service.repo.Create(ctx, metadata); err != nil { + t.Fatalf("create orphan metadata: %v", err) + } + cleanedState := make(map[string]int) + service.SetInstanceStateCleanup(func(_ context.Context, instanceID string) error { + cleanedState[instanceID]++ + return nil + }) + if err := service.Reconcile(ctx); err != nil { + t.Fatalf("reconcile: %v", err) + } + instance, err := store.Get(ctx, orphanInstanceID) + if err != nil { + t.Fatalf("get reconciled instance: %v", err) + } + if instance.Status != plugininstances.StatusRemoved { + t.Fatalf("orphan instance status = %q, want removed", instance.Status) + } + if _, err := service.repo.Get(ctx, metadata.ID); !errors.Is(err, ErrCanvasNotFound) { + t.Fatalf("orphan metadata = %v, want ErrCanvasNotFound", err) + } + if cleanedState[orphanInstanceID] != 1 || cleanedState[metadata.PluginInstanceID] != 1 { + t.Fatalf("cleaned state = %#v, want both orphan identifiers once", cleanedState) + } +} + +func TestCreateRollsBackInstanceWhenCanvasAdmissionFails(t *testing.T) { + service, store, _ := newCanvasService(t) + ctx := context.Background() + for i := 0; i < MaxWorkspaceCanvases; i++ { + if err := service.repo.Create(ctx, CanvasMetadata{ + ID: fmt.Sprintf("metadata-%d", i), PluginInstanceID: fmt.Sprintf("instance-%d", i), + WorkspaceID: "workspace-full", Title: "Existing metadata", + CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("seed metadata %d: %v", i, err) + } + } + _, err := service.Create(ctx, CreateCanvasRequest{WorkspaceID: "workspace-full", Title: "Rejected"}) + if !errors.Is(err, ErrWorkspaceCanvasLimit) { + t.Fatalf("create error = %v, want workspace limit", err) + } + instances, err := store.ListBySource(ctx, plugininstances.SourceLocalCanvas, false) + if err != nil { + t.Fatalf("list instances: %v", err) + } + if len(instances) != 0 { + t.Fatalf("persisted instances after rejected create = %d, want 0", len(instances)) + } +} + +func TestCleanupTaskClearsPromotedCanvasOrigin(t *testing.T) { + service, _, _ := newCanvasService(t) + ctx := context.Background() + now := time.Now().UTC() + if err := service.repo.Create(ctx, CanvasMetadata{ + ID: "task-metadata", PluginInstanceID: "missing-task-instance", + WorkspaceID: "workspace-1", TaskID: "task-1", OriginTaskID: "task-1", + Title: "Task canvas", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create task metadata: %v", err) + } + if err := service.repo.Create(ctx, CanvasMetadata{ + ID: "promoted-metadata", PluginInstanceID: "missing-promoted-instance", + WorkspaceID: "workspace-1", OriginTaskID: "task-1", + Title: "Promoted canvas", CreatedAt: now.Add(time.Second), UpdatedAt: now.Add(time.Second), + }); err != nil { + t.Fatalf("create promoted metadata: %v", err) + } + if err := service.CleanupTask(ctx, "task-1"); err != nil { + t.Fatalf("cleanup task: %v", err) + } + promoted, err := service.repo.Get(ctx, "promoted-metadata") + if err != nil { + t.Fatalf("get promoted metadata: %v", err) + } + if promoted.OriginTaskID != "" { + t.Fatalf("promoted origin task = %q, want empty", promoted.OriginTaskID) + } + if _, err := service.repo.Get(ctx, "task-metadata"); !errors.Is(err, ErrCanvasNotFound) { + t.Fatalf("task metadata = %v, want ErrCanvasNotFound", err) + } +} + +// @covers AC-CANVASES-AGENT-WEB-APPS-005.1 +func TestCanvasListsTaskAndWorkspaceScope(t *testing.T) { + service, _, _ := newCanvasService(t) + ctx := context.Background() + createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Task board"}) + workspaceCanvas := createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", Title: "Workspace board"}) + + taskCanvases, err := service.ListForTask(ctx, "workspace-1", "task-1", false) + if err != nil { + t.Fatalf("list task canvases: %v", err) + } + if len(taskCanvases) != 2 { + t.Fatalf("task canvas count = %d, want 2", len(taskCanvases)) + } + workspaceCanvases, err := service.ListWorkspaceCanvases(ctx, "workspace-1", false) + if err != nil { + t.Fatalf("list workspace canvases: %v", err) + } + if len(workspaceCanvases) != 1 || workspaceCanvases[0].ID != workspaceCanvas.ID { + t.Fatalf("workspace canvases = %+v, want only %s", workspaceCanvases, workspaceCanvas.ID) + } + wrongWorkspace, err := service.ListForTask(ctx, "workspace-2", "task-1", false) + if err != nil { + t.Fatalf("list foreign task workspace: %v", err) + } + if len(wrongWorkspace) != 0 { + t.Fatalf("foreign workspace returned %d canvases, want 0", len(wrongWorkspace)) + } +} + +func TestLifecycleEventsArePublishedAfterCommittedMutations(t *testing.T) { + service, _, _ := newCanvasService(t) + var events []LifecycleEvent + var callbackErr error + service.SetEventPublisher(func(ctx context.Context, event LifecycleEvent) { + events = append(events, event) + if event.Type == EventCreated { + _, callbackErr = service.Get(ctx, event.CanvasID) + } + }) + created := createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Events"}) + if callbackErr != nil { + t.Fatalf("created event observed before commit: %v", callbackErr) + } + if _, err := service.Archive(context.Background(), created.ID); err != nil { + t.Fatalf("archive: %v", err) + } + if _, err := service.Restore(context.Background(), created.ID); err != nil { + t.Fatalf("restore: %v", err) + } + if err := service.Remove(context.Background(), created.ID); err != nil { + t.Fatalf("remove: %v", err) + } + want := []string{EventCreated, EventArchived, EventRestored, EventRemoved} + if len(events) != len(want) { + t.Fatalf("event count = %d, want %d (%+v)", len(events), len(want), events) + } + for index, event := range events { + if event.Type != want[index] || event.CanvasID != created.ID || event.PluginInstanceID == "" { + t.Fatalf("event[%d] = %+v, want type %s for %s", index, event, want[index], created.ID) + } + } +} + +// @covers AC-CANVASES-AGENT-WEB-APPS-008.1 +func TestConcurrentTaskAdmissionDoesNotExceedLimit(t *testing.T) { + service, _, _ := newCanvasService(t) + const extraAttempts = 7 + attempts := MaxTaskCanvases + extraAttempts + start := make(chan struct{}) + var wait sync.WaitGroup + var mu sync.Mutex + created := 0 + var unexpected []error + for i := 0; i < attempts; i++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + <-start + _, err := service.Create(context.Background(), CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Task canvas " + strconv.Itoa(index), + }) + mu.Lock() + defer mu.Unlock() + if err == nil { + created++ + } else if !errors.Is(err, ErrTaskCanvasLimit) { + unexpected = append(unexpected, err) + } + }(i) + } + close(start) + wait.Wait() + if len(unexpected) > 0 { + t.Fatalf("unexpected admission errors: %v", unexpected) + } + if created != MaxTaskCanvases { + t.Fatalf("created = %d, want %d", created, MaxTaskCanvases) + } + canvases, err := service.ListTaskCanvases(context.Background(), "task-1", true) + if err != nil { + t.Fatalf("list task canvases: %v", err) + } + if len(canvases) != MaxTaskCanvases { + t.Fatalf("persisted task canvases = %d, want %d", len(canvases), MaxTaskCanvases) + } +} + +// @covers AC-CANVASES-AGENT-WEB-APPS-008.1 +func TestConcurrentWorkspaceAdmissionDoesNotExceedLimit(t *testing.T) { + service, _, _ := newCanvasService(t) + attempts := MaxWorkspaceCanvases + 5 + start := make(chan struct{}) + var wait sync.WaitGroup + var mu sync.Mutex + created := 0 + var unexpected []error + for i := 0; i < attempts; i++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + <-start + _, err := service.Create(context.Background(), CreateCanvasRequest{ + WorkspaceID: "workspace-1", + Title: fmt.Sprintf("Workspace canvas %d", index), + }) + mu.Lock() + defer mu.Unlock() + if err == nil { + created++ + } else if !errors.Is(err, ErrWorkspaceCanvasLimit) { + unexpected = append(unexpected, err) + } + }(i) + } + close(start) + wait.Wait() + if len(unexpected) > 0 { + t.Fatalf("unexpected admission errors: %v", unexpected) + } + if created != MaxWorkspaceCanvases { + t.Fatalf("created = %d, want %d", created, MaxWorkspaceCanvases) + } + canvases, err := service.ListWorkspaceCanvases(context.Background(), "workspace-1", false) + if err != nil { + t.Fatalf("list workspace canvases: %v", err) + } + if len(canvases) != MaxWorkspaceCanvases { + t.Fatalf("persisted workspace canvases = %d, want %d", len(canvases), MaxWorkspaceCanvases) + } +} + +// @covers AC-CANVASES-AGENT-WEB-APPS-002.1 +func TestCanvasScopeAndActiveReleaseSurviveRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "canvas.db") + pool := openCanvasPool(t, path) + instanceStore, err := plugininstances.NewStore(pool) + if err != nil { + t.Fatalf("new instance store: %v", err) + } + repo, err := NewRepository(pool) + if err != nil { + t.Fatalf("new canvas repository: %v", err) + } + service := NewService(repo, instanceStore) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Durable board", + }) + if err := instanceStore.CreateRelease(context.Background(), plugininstances.Release{ + ID: "release-1", + PluginID: created.PluginID, + InstanceID: created.PluginInstanceID, + PackageDigest: "digest-1", + SourceKind: plugininstances.SourceLocalCanvas, + SourceActorKind: "agent", + ArtifactPath: "releases/digest-1", + ValidationStatus: plugininstances.ValidationValid, + }); err != nil { + t.Fatalf("create release: %v", err) + } + if err := instanceStore.SetActiveRelease(context.Background(), created.PluginInstanceID, "release-1"); err != nil { + t.Fatalf("activate release: %v", err) + } + if err := pool.Close(); err != nil { + t.Fatalf("close first pool: %v", err) + } + + restartedPool := openCanvasPool(t, path) + restartedInstances, err := plugininstances.NewStore(restartedPool) + if err != nil { + t.Fatalf("reopen instance store: %v", err) + } + restartedRepo, err := NewRepository(restartedPool) + if err != nil { + t.Fatalf("reopen canvas repository: %v", err) + } + restarted := NewService(restartedRepo, restartedInstances) + got, err := restarted.Get(context.Background(), created.ID) + if err != nil { + t.Fatalf("get after restart: %v", err) + } + if got.ScopeKind != plugininstances.ScopeTask || got.TaskID != "task-1" { + t.Fatalf("scope after restart = %q/%q", got.ScopeKind, got.TaskID) + } + if got.ActiveReleaseID != "release-1" || got.ActiveReleaseStatus != plugininstances.ValidationValid { + t.Fatalf("release after restart = %q/%q", got.ActiveReleaseID, got.ActiveReleaseStatus) + } +} + +// @covers AC-CANVASES-AGENT-WEB-APPS-007.3 +func TestUnavailableActiveReleaseRemainsVisibleForRecovery(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Missing board"}) + if err := instanceStore.CreateRelease(context.Background(), plugininstances.Release{ + ID: "release-missing", + PluginID: created.PluginID, + InstanceID: created.PluginInstanceID, + PackageDigest: "digest-missing", + SourceKind: plugininstances.SourceLocalCanvas, + SourceActorKind: "agent", + ArtifactPath: "releases/digest-missing", + ValidationStatus: plugininstances.ValidationValid, + }); err != nil { + t.Fatalf("create release: %v", err) + } + if err := instanceStore.SetActiveRelease(context.Background(), created.PluginInstanceID, "release-missing"); err != nil { + t.Fatalf("activate release: %v", err) + } + marked, err := instanceStore.ReconcileArtifacts(context.Background(), func(string, string, int64) (plugininstances.ArtifactCheck, error) { + return plugininstances.ArtifactCheck{Reason: "digest_mismatch"}, nil + }) + if err != nil || marked != 1 { + t.Fatalf("reconcile = %d, %v; want one unavailable release", marked, err) + } + + got, err := service.Get(context.Background(), created.ID) + if err != nil { + t.Fatalf("get unavailable canvas: %v", err) + } + if got.ActiveReleaseStatus != plugininstances.ValidationUnavailable || got.ActiveReleaseError != "digest_mismatch" { + t.Fatalf("unavailable release = %q/%q", got.ActiveReleaseStatus, got.ActiveReleaseError) + } +} + +// @covers AC-CANVASES-AGENT-WEB-APPS-008.3 +// @covers AC-CANVASES-AGENT-WEB-APPS-008.4 +func TestArchivedCanvasCountsUntilRemovedAndCanThenBeRestored(t *testing.T) { + service, _, _ := newCanvasService(t) + ctx := context.Background() + canvases := make([]Canvas, 0, MaxTaskCanvases) + for i := 0; i < MaxTaskCanvases; i++ { + canvases = append(canvases, createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Canvas " + strconv.Itoa(i), + })) + } + if _, err := service.Archive(ctx, canvases[0].ID); err != nil { + t.Fatalf("archive: %v", err) + } + if _, err := service.Create(ctx, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "over limit"}); !errors.Is(err, ErrTaskCanvasLimit) { + t.Fatalf("create after archive = %v, want task limit", err) + } + if err := service.Remove(ctx, canvases[1].ID); err != nil { + t.Fatalf("remove active canvas: %v", err) + } + if _, err := service.Restore(ctx, canvases[0].ID); err != nil { + t.Fatalf("restore after removal: %v", err) + } + got, err := service.Get(ctx, canvases[0].ID) + if err != nil { + t.Fatalf("get restored canvas: %v", err) + } + if got.Status != plugininstances.StatusActive { + t.Fatalf("restored status = %q, want active", got.Status) + } +} + +// @covers AC-CANVASES-AGENT-WEB-APPS-002.5 +// @covers AC-CANVASES-AGENT-WEB-APPS-002.6 +func TestCleanupRemovesTaskCanvasesPreservesWorkspaceCanvasesAndQueuesArtifacts(t *testing.T) { + service, instanceStore, pool := newCanvasService(t) + ctx := context.Background() + taskCanvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + TaskID: "task-1", + Title: "Task board", + }) + workspaceCanvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", + OriginTaskID: "task-1", + Title: "Promoted board", + }) + createRelease(t, instanceStore, taskCanvas, "release-task", "releases/task") + createRelease(t, instanceStore, workspaceCanvas, "release-workspace", "releases/workspace") + + if err := service.CleanupTask(ctx, "task-1"); err != nil { + t.Fatalf("cleanup task: %v", err) + } + if _, err := service.Get(ctx, taskCanvas.ID); !errors.Is(err, ErrCanvasNotFound) { + t.Fatalf("task canvas after cleanup = %v, want not found", err) + } + if _, err := service.Get(ctx, workspaceCanvas.ID); err != nil { + t.Fatalf("promoted canvas after task cleanup: %v", err) + } + if got := cleanupJobCount(t, pool, taskCanvas.PluginInstanceID); got != 1 { + t.Fatalf("task cleanup jobs = %d, want 1", got) + } + + if err := service.CleanupWorkspace(ctx, "workspace-1"); err != nil { + t.Fatalf("cleanup workspace: %v", err) + } + if _, err := service.Get(ctx, workspaceCanvas.ID); !errors.Is(err, ErrCanvasNotFound) { + t.Fatalf("workspace canvas after cleanup = %v, want not found", err) + } + if got := cleanupJobCount(t, pool, workspaceCanvas.PluginInstanceID); got != 1 { + t.Fatalf("workspace cleanup jobs = %d, want 1", got) + } + if got := metadataCount(t, pool, "workspace-1"); got != 0 { + t.Fatalf("metadata rows after workspace cleanup = %d, want 0", got) + } +} + +func createRelease(t *testing.T, store *plugininstances.Store, canvas Canvas, id, path string) { + t.Helper() + if err := store.CreateRelease(context.Background(), plugininstances.Release{ + ID: id, + PluginID: canvas.PluginID, + InstanceID: canvas.PluginInstanceID, + PackageDigest: id + "-digest", + SourceKind: plugininstances.SourceLocalCanvas, + SourceActorKind: "agent", + ArtifactPath: path, + ValidationStatus: plugininstances.ValidationValid, + }); err != nil { + t.Fatalf("create release %s: %v", id, err) + } +} + +func cleanupJobCount(t *testing.T, pool *db.Pool, instanceID string) int { + t.Helper() + var count int + err := pool.Reader().Get(&count, pool.Reader().Rebind( + "SELECT COUNT(*) FROM plugin_artifact_cleanup_jobs WHERE instance_id = ?", + ), instanceID) + if err != nil { + t.Fatalf("count cleanup jobs: %v", err) + } + return count +} + +func metadataCount(t *testing.T, pool *db.Pool, workspaceID string) int { + t.Helper() + var count int + err := pool.Reader().Get(&count, pool.Reader().Rebind( + "SELECT COUNT(*) FROM canvas_lifecycle_metadata WHERE workspace_id = ?", + ), workspaceID) + if err != nil { + t.Fatalf("count canvas metadata: %v", err) + } + return count +} diff --git a/apps/backend/internal/canvas/release_projection.go b/apps/backend/internal/canvas/release_projection.go new file mode 100644 index 0000000000..ade674e2bb --- /dev/null +++ b/apps/backend/internal/canvas/release_projection.go @@ -0,0 +1,65 @@ +package canvas + +import ( + "sort" + + plugininstances "github.com/kandev/kandev/internal/plugins/instances" +) + +func releaseMetadata(release plugininstances.Release, scope string, grants []plugininstances.Grant) *ReleaseMetadata { + permissions := ReleasePermissionSummary(release) + return &ReleaseMetadata{ + ID: release.ID, + PackageDigest: release.PackageDigest, + ValidationStatus: release.ValidationStatus, + ValidationError: release.ValidationError, + Permissions: &permissions, + MissingPermissions: MissingPermissionKeys(permissions, scope, grants), + PermissionDigest: PermissionDigest(release), + SourceActorKind: release.SourceActorKind, + SourceUserID: release.SourceUserID, + SourceTaskID: release.SourceTaskID, + SourceSessionID: release.SourceSessionID, + ProtocolVersion: release.ProtocolVersion, + CreatedAt: release.CreatedAt, + } +} + +func effectiveGrantProjection(instance plugininstances.Instance, summary PermissionSummary, grants []plugininstances.Grant) []GrantProjection { + if instance.ActiveReleaseID == "" { + return nil + } + declared := permissionKeys(summary) + result := make([]GrantProjection, 0, len(grants)) + for _, grant := range grants { + if !grantScopeCovers(grant.ScopeCeiling, instance.ScopeKind) { + continue + } + permission := grant.PermissionKind + ":" + grant.Resource + if grant.PermissionKind == permissionKindNetwork { + permission = permissionKindNetwork + ":" + grant.NetworkOrigin + } + if grant.PermissionKind == permissionKindState { + permission = permissionKindState + } + if !containsString(declared, permission) { + continue + } + result = append(result, GrantProjection{ + PermissionKind: grant.PermissionKind, + Resource: grant.Resource, + NetworkOrigin: grant.NetworkOrigin, + ScopeCeiling: grant.ScopeCeiling, + }) + } + sort.Slice(result, func(i, j int) bool { + if result[i].PermissionKind != result[j].PermissionKind { + return result[i].PermissionKind < result[j].PermissionKind + } + if result[i].Resource != result[j].Resource { + return result[i].Resource < result[j].Resource + } + return result[i].NetworkOrigin < result[j].NetworkOrigin + }) + return result +} diff --git a/apps/backend/internal/canvas/repository.go b/apps/backend/internal/canvas/repository.go new file mode 100644 index 0000000000..644103dcc3 --- /dev/null +++ b/apps/backend/internal/canvas/repository.go @@ -0,0 +1,397 @@ +package canvas + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +// Repository stores the canvas-owned metadata that is not part of a plugin +// instance. It intentionally uses a separate table name so databases that +// still contain the superseded declarative canvases table are not read as if +// they used the current plugin-backed lifecycle schema. +type Repository struct { + db *sqlx.DB + ro *sqlx.DB +} + +// SchemaSQL is exported for schema replay and backup tests. The statements +// use the common SQLite/PostgreSQL subset and are safe to run repeatedly. +const SchemaSQL = ` +CREATE TABLE IF NOT EXISTS canvas_lifecycle_metadata ( + id TEXT PRIMARY KEY, + plugin_instance_id TEXT NOT NULL UNIQUE, + workspace_id TEXT NOT NULL, + task_id TEXT NOT NULL DEFAULT '', + origin_task_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + created_by_session_id TEXT NOT NULL DEFAULT '', + promoted_by_user_id TEXT NOT NULL DEFAULT '', + promoted_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +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_lifecycle_admission ( + id INTEGER PRIMARY KEY, + version INTEGER NOT NULL +); +INSERT INTO canvas_lifecycle_admission (id, version) +VALUES (1, 1) ON CONFLICT (id) DO NOTHING; +` + +// NewRepository constructs the canvas metadata repository on an existing +// application database pool. Plugin instance schema initialization remains +// owned by internal/plugins/instances. +func NewRepository(pool *db.Pool) (*Repository, error) { + if pool == nil || pool.Writer() == nil || pool.Reader() == nil { + return nil, errors.New("canvas: database pool is required") + } + return NewRepositoryWithDB(pool.Writer(), pool.Reader()) +} + +// NewRepositoryWithDB is useful for tests and for callers that already own +// separate read and write sqlx handles. +func NewRepositoryWithDB(writer, reader *sqlx.DB) (*Repository, error) { + if writer == nil || reader == nil { + return nil, errors.New("canvas: database connections are required") + } + repo := &Repository{db: writer, ro: reader} + if err := repo.initSchema(); err != nil { + return nil, fmt.Errorf("canvas schema: %w", err) + } + return repo, nil +} + +// NewStore is a naming-compatible convenience for callers that use Store for +// database-backed domain repositories. +func NewStore(pool *db.Pool) (*Repository, error) { + return NewRepository(pool) +} + +func (r *Repository) initSchema() error { + for _, statement := range strings.Split(SchemaSQL, ";") { + if strings.TrimSpace(statement) == "" { + continue + } + if _, err := r.db.Exec(statement); err != nil { + return err + } + } + return nil +} + +// Create persists metadata after the plugin instance admission has succeeded. +func (r *Repository) Create(ctx context.Context, metadata CanvasMetadata) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := r.CreateTx(ctx, tx, metadata); err != nil { + return err + } + return tx.Commit() +} + +// CreateTx persists metadata in an existing lifecycle transaction. Canvas +// creation uses this with the plugin-instance insert so a crash cannot leave +// quota-consuming instance authority without its one-to-one canvas row. +func (r *Repository) CreateTx(ctx context.Context, tx *sqlx.Tx, metadata CanvasMetadata) error { + if err := validateMetadata(metadata); err != nil { + return err + } + createdAt := metadata.CreatedAt.UTC() + if metadata.CreatedAt.IsZero() { + createdAt = time.Now().UTC() + } + updatedAt := metadata.UpdatedAt.UTC() + if metadata.UpdatedAt.IsZero() { + updatedAt = createdAt + } + // The singleton update serializes admission across service instances and + // processes on PostgreSQL, and obtains SQLite's write lock before counts. + if _, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE canvas_lifecycle_admission SET version = version WHERE id = 1`)); err != nil { + return err + } + if err := checkAdmission(ctx, tx, metadata); err != nil { + return err + } + _, err := tx.ExecContext(ctx, tx.Rebind(` +INSERT INTO canvas_lifecycle_metadata + (id, plugin_instance_id, workspace_id, task_id, origin_task_id, title, + created_by_session_id, promoted_by_user_id, promoted_at, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + metadata.ID, + metadata.PluginInstanceID, + metadata.WorkspaceID, + metadata.TaskID, + metadata.OriginTaskID, + metadata.Title, + metadata.CreatedBySessionID, + metadata.PromotedByUserID, + formatOptionalTime(metadata.PromotedAt), + createdAt.Format(time.RFC3339Nano), + updatedAt.Format(time.RFC3339Nano), + ) + if err != nil { + return err + } + return nil +} + +func checkAdmission(ctx context.Context, tx *sqlx.Tx, metadata CanvasMetadata) error { + var count int + if metadata.TaskID != "" { + if err := tx.GetContext(ctx, &count, tx.Rebind( + `SELECT COUNT(*) FROM canvas_lifecycle_metadata WHERE task_id = ?`), metadata.TaskID); err != nil { + return err + } + if count >= MaxTaskCanvases { + return ErrTaskCanvasLimit + } + } + if err := tx.GetContext(ctx, &count, tx.Rebind( + `SELECT COUNT(*) FROM canvas_lifecycle_metadata WHERE workspace_id = ?`), metadata.WorkspaceID); err != nil { + return err + } + if count >= MaxWorkspaceCanvases { + return ErrWorkspaceCanvasLimit + } + return nil +} + +// Get returns one canvas-owned metadata record. +func (r *Repository) Get(ctx context.Context, id string) (CanvasMetadata, error) { + if strings.TrimSpace(id) == "" { + return CanvasMetadata{}, ErrCanvasNotFound + } + var row metadataRow + err := r.ro.GetContext(ctx, &row, r.ro.Rebind(` +SELECT id, plugin_instance_id, workspace_id, task_id, origin_task_id, title, + created_by_session_id, promoted_by_user_id, promoted_at, created_at, updated_at +FROM canvas_lifecycle_metadata WHERE id = ?`), id) + if errors.Is(err, sql.ErrNoRows) { + return CanvasMetadata{}, ErrCanvasNotFound + } + if err != nil { + return CanvasMetadata{}, err + } + return row.metadata() +} + +// ListByWorkspace returns metadata in stable creation order. Lifecycle status +// filtering is performed by Service because status belongs to the plugin +// instance store. +func (r *Repository) ListByWorkspace(ctx context.Context, workspaceID string) ([]CanvasMetadata, error) { + if strings.TrimSpace(workspaceID) == "" { + return nil, ErrInvalidCanvas + } + return r.list(ctx, ` +SELECT id, plugin_instance_id, workspace_id, task_id, origin_task_id, title, + created_by_session_id, promoted_by_user_id, promoted_at, created_at, updated_at +FROM canvas_lifecycle_metadata WHERE workspace_id = ? ORDER BY created_at, id`, workspaceID) +} + +// ListByTask returns unpromoted task metadata. Promotion clears task_id, so a +// promoted canvas is naturally preserved by task cleanup. +func (r *Repository) ListByTask(ctx context.Context, taskID string) ([]CanvasMetadata, error) { + if strings.TrimSpace(taskID) == "" { + return nil, ErrInvalidCanvas + } + return r.list(ctx, ` +SELECT id, plugin_instance_id, workspace_id, task_id, origin_task_id, title, + created_by_session_id, promoted_by_user_id, promoted_at, created_at, updated_at +FROM canvas_lifecycle_metadata WHERE task_id = ? ORDER BY created_at, id`, taskID) +} + +// ListAll returns every canvas metadata row for startup lifecycle +// reconciliation. Status and release authority remain owned by the instance +// store. +func (r *Repository) ListAll(ctx context.Context) ([]CanvasMetadata, error) { + return r.list(ctx, ` +SELECT id, plugin_instance_id, workspace_id, task_id, origin_task_id, title, + created_by_session_id, promoted_by_user_id, promoted_at, created_at, updated_at +FROM canvas_lifecycle_metadata ORDER BY created_at, id`) +} + +// ClearOriginTask removes provenance that points at a task after that task +// has been deleted. Promoted canvases have no current task_id and remain in +// the workspace. +func (r *Repository) ClearOriginTask(ctx context.Context, taskID string) error { + if strings.TrimSpace(taskID) == "" { + return ErrInvalidCanvas + } + _, err := r.db.ExecContext(ctx, r.db.Rebind( + `UPDATE canvas_lifecycle_metadata SET origin_task_id = '', updated_at = ? WHERE origin_task_id = ? AND task_id = ''`), + time.Now().UTC().Format(time.RFC3339Nano), taskID) + return err +} + +// Delete removes only the canvas-owned metadata. Service.Remove deletes the +// plugin instance first so its artifact cleanup inventory is durable before +// this authority record disappears. +func (r *Repository) Delete(ctx context.Context, id string) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := r.DeleteTx(ctx, tx, id); err != nil { + return err + } + return tx.Commit() +} + +// DeleteTx removes metadata in an existing lifecycle transaction. +func (r *Repository) DeleteTx(ctx context.Context, tx *sqlx.Tx, id string) error { + result, err := tx.ExecContext(ctx, tx.Rebind( + `DELETE FROM canvas_lifecycle_metadata WHERE id = ?`), id) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrCanvasNotFound + } + return nil +} + +// Promote records the human-owned provenance and clears the current task +// relationship. The instance scope/grants are changed by the plugin +// instance store immediately before this metadata transaction. +func (r *Repository) Promote(ctx context.Context, id, userID string, promotedAt time.Time) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := r.PromoteTx(ctx, tx, id, userID, promotedAt); err != nil { + return err + } + return tx.Commit() +} + +// PromoteTx records workspace provenance in an existing lifecycle +// transaction. The canvas service uses this with the instance scope and grant +// mutation so promotion commits both authorities together. +func (r *Repository) PromoteTx(ctx context.Context, tx *sqlx.Tx, id, userID string, promotedAt time.Time) error { + if strings.TrimSpace(id) == "" || strings.TrimSpace(userID) == "" { + return ErrInvalidCanvas + } + if promotedAt.IsZero() { + promotedAt = time.Now().UTC() + } + result, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE canvas_lifecycle_metadata SET task_id = '', promoted_by_user_id = ?, promoted_at = ?, updated_at = ? WHERE id = ? AND task_id <> ''`, + ), userID, promotedAt.UTC().Format(time.RFC3339Nano), promotedAt.UTC().Format(time.RFC3339Nano), id) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrInvalidCanvas + } + return nil +} + +func (r *Repository) list(ctx context.Context, query string, args ...any) ([]CanvasMetadata, error) { + rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + metadata := make([]CanvasMetadata, 0) + for rows.Next() { + var row metadataRow + if err := rows.StructScan(&row); err != nil { + return nil, err + } + item, err := row.metadata() + if err != nil { + return nil, err + } + metadata = append(metadata, item) + } + return metadata, rows.Err() +} + +func validateMetadata(metadata CanvasMetadata) error { + if strings.TrimSpace(metadata.ID) == "" || strings.TrimSpace(metadata.PluginInstanceID) == "" || strings.TrimSpace(metadata.WorkspaceID) == "" || strings.TrimSpace(metadata.Title) == "" { + return ErrInvalidCanvas + } + return nil +} + +type metadataRow struct { + ID string `db:"id"` + PluginInstanceID string `db:"plugin_instance_id"` + WorkspaceID string `db:"workspace_id"` + TaskID string `db:"task_id"` + OriginTaskID string `db:"origin_task_id"` + Title string `db:"title"` + CreatedBySessionID string `db:"created_by_session_id"` + PromotedByUserID string `db:"promoted_by_user_id"` + PromotedAt string `db:"promoted_at"` + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` +} + +func (r metadataRow) metadata() (CanvasMetadata, error) { + createdAt, err := time.Parse(time.RFC3339Nano, r.CreatedAt) + if err != nil { + return CanvasMetadata{}, fmt.Errorf("canvas: parse created_at: %w", err) + } + updatedAt, err := time.Parse(time.RFC3339Nano, r.UpdatedAt) + if err != nil { + return CanvasMetadata{}, fmt.Errorf("canvas: parse updated_at: %w", err) + } + promotedAt, err := parseOptionalTime(r.PromotedAt) + if err != nil { + return CanvasMetadata{}, fmt.Errorf("canvas: parse promoted_at: %w", err) + } + return CanvasMetadata{ + ID: r.ID, + PluginInstanceID: r.PluginInstanceID, + WorkspaceID: r.WorkspaceID, + TaskID: r.TaskID, + OriginTaskID: r.OriginTaskID, + Title: r.Title, + CreatedBySessionID: r.CreatedBySessionID, + PromotedByUserID: r.PromotedByUserID, + PromotedAt: promotedAt, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, nil +} + +func formatOptionalTime(value *time.Time) string { + if value == nil || value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} + +func parseOptionalTime(value string) (*time.Time, error) { + if value == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return nil, err + } + return &parsed, nil +} diff --git a/apps/backend/internal/canvas/service.go b/apps/backend/internal/canvas/service.go new file mode 100644 index 0000000000..14a2f886c2 --- /dev/null +++ b/apps/backend/internal/canvas/service.go @@ -0,0 +1,859 @@ +package canvas + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + plugininstances "github.com/kandev/kandev/internal/plugins/instances" +) + +// Service coordinates canvas metadata with its plugin instance. The mutex +// covers admission and lifecycle mutations so a create, restore, or cleanup +// cannot observe a partially completed operation in this process. The plugin +// instance store remains the durable admission authority. +type Service struct { + repo *Repository + instances PluginInstanceStore + + mu sync.Mutex + clock func() time.Time + publisher EventPublisher + stateCleanup func(context.Context, string) error +} + +// NewService constructs a canvas lifecycle service. The instance store is +// normally *instances.Store from internal/plugins/instances. +func NewService(repo *Repository, instanceStore PluginInstanceStore) *Service { + return &Service{repo: repo, instances: instanceStore, clock: time.Now} +} + +// SetEventPublisher attaches the content-free lifecycle notification sink. +// Wire it before serving requests. Publishing occurs after the database +// mutation has committed and never determines mutation success. +func (s *Service) SetEventPublisher(publisher EventPublisher) { + if s == nil { + return + } + s.mu.Lock() + s.publisher = publisher + s.mu.Unlock() +} + +// SetInstanceStateCleanup wires the instance-state owner into canvas removal. +// The callback is optional so the lifecycle service remains usable with the +// narrow in-memory stores used by tests and migrations. +func (s *Service) SetInstanceStateCleanup(cleanup func(context.Context, string) error) { + if s == nil { + return + } + s.mu.Lock() + s.stateCleanup = cleanup + s.mu.Unlock() +} + +// Create creates a pending canvas and its task- or workspace-scoped plugin +// instance. A task ID creates a task canvas; an empty task ID is reserved for +// trusted internal callers creating a workspace-scoped canvas. +func (s *Service) Create(ctx context.Context, request CreateCanvasRequest) (*Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + request, err := normalizeCreateRequest(request) + if err != nil { + return nil, err + } + + s.mu.Lock() + canvas, event, err := s.createLocked(ctx, request) + publisher := s.publisher + s.mu.Unlock() + if err != nil { + return nil, err + } + publishEvent(ctx, publisher, event) + return &canvas, nil +} + +// CreateCanvas is the descriptive alias used by HTTP and MCP adapters. +func (s *Service) CreateCanvas(ctx context.Context, request CreateCanvasRequest) (*Canvas, error) { + return s.Create(ctx, request) +} + +func (s *Service) createLocked(ctx context.Context, request CreateCanvasRequest) (Canvas, LifecycleEvent, error) { + now := s.nowUTC() + canvasID := uuid.NewString() + instanceID := uuid.NewString() + scopeKind := ScopeWorkspace + if request.TaskID != "" { + 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, + } + instance := plugininstances.Instance{ + ID: instanceID, + PluginID: request.PluginID, + SourceKind: plugininstances.SourceLocalCanvas, + ScopeKind: scopeKind, + WorkspaceID: request.WorkspaceID, + TaskID: request.TaskID, + Status: plugininstances.StatusPending, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.createAuthority(ctx, instance, metadata); err != nil { + return Canvas{}, LifecycleEvent{}, err + } + canvas := canvasFromMetadataInstance(metadata, instance) + return canvas, lifecycleEvent(EventCreated, canvas), nil +} + +func (s *Service) createAuthority(ctx context.Context, instance plugininstances.Instance, metadata CanvasMetadata) error { + transactional, ok := s.instances.(transactionalPluginInstanceStore) + if ok { + return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error { + if err := transactional.CreateTx(ctx, tx, instance); err != nil { + return err + } + return s.repo.CreateTx(ctx, tx, metadata) + }) + } + if err := s.instances.Create(ctx, instance); err != nil { + return err + } + if err := s.repo.Create(ctx, metadata); err != nil { + cleanupErr := s.compensateCreate(instance.ID) + if cleanupErr != nil { + return errors.Join(err, fmt.Errorf("remove admitted plugin instance: %w", cleanupErr)) + } + return err + } + return nil +} + +// Get returns one non-removed canvas and reports an unavailable active release +// in the projection without attempting to execute or remove that release. +func (s *Service) Get(ctx context.Context, id string) (*Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + metadata, instance, err := s.load(ctx, id) + if err != nil { + return nil, err + } + if instance.Status == StatusRemoved { + return nil, ErrCanvasNotFound + } + canvas, err := s.buildCanvas(ctx, metadata, instance) + if err != nil { + return nil, err + } + return &canvas, nil +} + +// GetCanvas is the descriptive alias used by API adapters. +func (s *Service) GetCanvas(ctx context.Context, id string) (*Canvas, error) { + return s.Get(ctx, id) +} + +// ListTaskCanvases lists task-scoped canvases. Removed canvases are never +// returned; archived canvases are returned only when includeArchived is true. +func (s *Service) ListTaskCanvases(ctx context.Context, taskID string, includeArchived bool) ([]Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + metadata, err := s.repo.ListByTask(ctx, taskID) + if err != nil { + return nil, err + } + return s.listMetadata(ctx, metadata, includeArchived, isTaskCanvas) +} + +// ListWorkspaceCanvases lists promoted or internally-created workspace-scoped +// canvases. Task-only canvases are not included. +func (s *Service) ListWorkspaceCanvases(ctx context.Context, workspaceID string, includeArchived bool) ([]Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + metadata, err := s.repo.ListByWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + return s.listMetadata(ctx, metadata, includeArchived, isWorkspaceCanvas) +} + +// ListForTask combines task canvases with workspace canvases applicable to +// the task's workspace. +func (s *Service) ListForTask(ctx context.Context, workspaceID, taskID string, includeArchived bool) ([]Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + if strings.TrimSpace(taskID) == "" || strings.TrimSpace(workspaceID) == "" { + return nil, ErrInvalidCanvas + } + taskMetadata, err := s.repo.ListByTask(ctx, taskID) + if err != nil { + return nil, err + } + workspaceMetadata, err := s.repo.ListByWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + workspaceOnly := make([]CanvasMetadata, 0, len(workspaceMetadata)) + for _, item := range workspaceMetadata { + if item.TaskID == "" { + workspaceOnly = append(workspaceOnly, item) + } + } + metadata := make([]CanvasMetadata, 0, len(taskMetadata)+len(workspaceOnly)) + metadata = append(metadata, taskMetadata...) + metadata = append(metadata, workspaceOnly...) + return s.listMetadata(ctx, metadata, includeArchived, func(item CanvasMetadata, instance plugininstances.Instance) bool { + if item.WorkspaceID != workspaceID { + return false + } + return (isTaskCanvas(item, instance) && item.TaskID == taskID) || isWorkspaceCanvas(item, instance) + }) +} + +// ListCanvasesForTask is an alias for ListForTask. +func (s *Service) ListCanvasesForTask(ctx context.Context, workspaceID, taskID string, includeArchived bool) ([]Canvas, error) { + return s.ListForTask(ctx, workspaceID, taskID, includeArchived) +} + +// Archive changes a canvas to the archived instance state. Archived canvases +// remain admitted and therefore continue to count against both limits. +func (s *Service) Archive(ctx context.Context, id string) (*Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + s.mu.Lock() + canvas, event, changed, err := s.archiveLocked(ctx, id) + publisher := s.publisher + s.mu.Unlock() + if err != nil { + return nil, err + } + if changed { + publishEvent(ctx, publisher, event) + } + return &canvas, nil +} + +// ArchiveCanvas is the descriptive alias used by API adapters. +func (s *Service) ArchiveCanvas(ctx context.Context, id string) (*Canvas, error) { + return s.Archive(ctx, id) +} + +func (s *Service) archiveLocked(ctx context.Context, id string) (Canvas, LifecycleEvent, bool, error) { + metadata, instance, err := s.load(ctx, id) + if err != nil { + return Canvas{}, LifecycleEvent{}, false, err + } + if instance.Status == StatusRemoved { + return Canvas{}, LifecycleEvent{}, false, ErrCanvasNotFound + } + if instance.Status == StatusArchived { + canvas, err := s.buildCanvas(ctx, metadata, instance) + return canvas, LifecycleEvent{}, false, err + } + if err := s.instances.Archive(ctx, instance.ID); err != nil { + return Canvas{}, LifecycleEvent{}, false, err + } + instance, err = s.instances.Get(ctx, instance.ID) + if err != nil { + return Canvas{}, LifecycleEvent{}, false, err + } + canvas, err := s.buildCanvas(ctx, metadata, instance) + if err != nil { + return Canvas{}, LifecycleEvent{}, false, err + } + return canvas, lifecycleEvent(EventArchived, canvas), true, nil +} + +// Restore re-admits an archived canvas through the instance store's atomic +// limit check. A full task or workspace returns the stable admission error. +func (s *Service) Restore(ctx context.Context, id string) (*Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + s.mu.Lock() + canvas, event, err := s.restoreLocked(ctx, id) + publisher := s.publisher + s.mu.Unlock() + if err != nil { + return nil, err + } + publishEvent(ctx, publisher, event) + return &canvas, nil +} + +// RestoreCanvas is the descriptive alias used by API adapters. +func (s *Service) RestoreCanvas(ctx context.Context, id string) (*Canvas, error) { + return s.Restore(ctx, id) +} + +func (s *Service) restoreLocked(ctx context.Context, id string) (Canvas, LifecycleEvent, error) { + metadata, instance, err := s.load(ctx, id) + if err != nil { + return Canvas{}, LifecycleEvent{}, err + } + if instance.Status == StatusRemoved { + return Canvas{}, LifecycleEvent{}, ErrCanvasNotFound + } + if instance.Status != StatusArchived { + return Canvas{}, LifecycleEvent{}, fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, instance.Status) + } + if err := s.instances.Restore(ctx, instance.ID); err != nil { + return Canvas{}, LifecycleEvent{}, err + } + instance, err = s.instances.Get(ctx, instance.ID) + if err != nil { + return Canvas{}, LifecycleEvent{}, err + } + canvas, err := s.buildCanvas(ctx, metadata, instance) + if err != nil { + return Canvas{}, LifecycleEvent{}, err + } + return canvas, lifecycleEvent(EventRestored, canvas), nil +} + +// Remove first asks the plugin instance store to record cleanup jobs and +// remove live instance authority, then deletes canvas metadata. This ordering +// makes artifact cleanup durable if the process stops between both commits. +func (s *Service) Remove(ctx context.Context, id string) error { + if err := s.ready(); err != nil { + return err + } + s.mu.Lock() + event, err := s.removeLocked(ctx, id) + publisher := s.publisher + s.mu.Unlock() + if err != nil { + return err + } + publishEvent(ctx, publisher, event) + return nil +} + +// RemoveCanvas is the descriptive alias used by API adapters. +func (s *Service) RemoveCanvas(ctx context.Context, id string) error { + return s.Remove(ctx, id) +} + +func (s *Service) removeLocked(ctx context.Context, id string) (LifecycleEvent, error) { + metadata, instance, err := s.load(ctx, id) + if err != nil { + return LifecycleEvent{}, err + } + if err := s.removeAuthority(ctx, metadata, instance); err != nil { + return LifecycleEvent{}, err + } + instance.Status = StatusRemoved + instance.ActiveReleaseID = "" + if s.stateCleanup != nil { + if err := s.stateCleanup(ctx, instance.ID); err != nil { + return LifecycleEvent{}, err + } + } + canvas := canvasFromMetadataInstance(metadata, instance) + return lifecycleEvent(EventRemoved, canvas), nil +} + +func (s *Service) removeAuthority(ctx context.Context, metadata CanvasMetadata, instance plugininstances.Instance) error { + if instance.Status == StatusRemoved { + err := s.repo.Delete(ctx, metadata.ID) + if errors.Is(err, ErrCanvasNotFound) { + return nil + } + return err + } + transactional, ok := s.instances.(transactionalPluginInstanceStore) + if ok { + return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error { + if err := transactional.RemoveInstanceTx(ctx, tx, instance.ID); err != nil { + return err + } + return s.repo.DeleteTx(ctx, tx, metadata.ID) + }) + } + if err := s.instances.RemoveInstance(ctx, instance.ID); err != nil { + return err + } + return s.repo.Delete(ctx, metadata.ID) +} + +// CleanupTask removes all current task-scoped canvases. Promotion clears the +// task_id in metadata, so promoted canvases are intentionally untouched. +func (s *Service) CleanupTask(ctx context.Context, taskID string) error { + if err := s.ready(); err != nil { + return err + } + if strings.TrimSpace(taskID) == "" { + return ErrInvalidCanvas + } + s.mu.Lock() + metadata, err := s.repo.ListByTask(ctx, taskID) + if err != nil { + s.mu.Unlock() + return err + } + events, err := s.cleanupMetadataLocked(ctx, metadata) + if err == nil { + err = s.repo.ClearOriginTask(ctx, taskID) + } + publisher := s.publisher + s.mu.Unlock() + for _, event := range events { + publishEvent(ctx, publisher, event) + } + return err +} + +// CleanupTaskCanvases is an explicit alias for task-service wiring. +func (s *Service) CleanupTaskCanvases(ctx context.Context, taskID string) error { + return s.CleanupTask(ctx, taskID) +} + +// CleanupWorkspace removes every canvas in a workspace, including archived +// and task-scoped canvases. Each plugin instance removal inventories its +// retained release artifacts before its metadata is deleted. +func (s *Service) CleanupWorkspace(ctx context.Context, workspaceID string) error { + if err := s.ready(); err != nil { + return err + } + if strings.TrimSpace(workspaceID) == "" { + return ErrInvalidCanvas + } + s.mu.Lock() + metadata, err := s.repo.ListByWorkspace(ctx, workspaceID) + if err != nil { + s.mu.Unlock() + return err + } + events, err := s.cleanupMetadataLocked(ctx, metadata) + publisher := s.publisher + s.mu.Unlock() + for _, event := range events { + publishEvent(ctx, publisher, event) + } + return err +} + +// CleanupWorkspaceCanvases is an explicit alias for workspace-service wiring. +func (s *Service) CleanupWorkspaceCanvases(ctx context.Context, workspaceID string) error { + return s.CleanupWorkspace(ctx, workspaceID) +} + +// Reconcile repairs lifecycle rows left across a process boundary. Canvas +// creation and removal are transactional for the durable store, but this +// pass also handles databases written by an older binary or a crash during +// the compatibility fallback. It never executes a release. +func (s *Service) Reconcile(ctx context.Context) error { + if err := s.ready(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + + metadata, err := s.repo.ListAll(ctx) + if err != nil { + return err + } + knownInstances, errs := s.reconcileMetadata(ctx, metadata) + inventory, ok := s.instances.(interface { + ListBySource(context.Context, string, bool) ([]plugininstances.Instance, error) + }) + if !ok { + return errors.Join(errs...) + } + instances, err := inventory.ListBySource(ctx, plugininstances.SourceLocalCanvas, true) + if err != nil { + errs = append(errs, fmt.Errorf("list canvas instances: %w", err)) + return errors.Join(errs...) + } + errs = append(errs, s.reconcileOrphanInstances(ctx, instances, knownInstances)...) + return errors.Join(errs...) +} + +func (s *Service) reconcileMetadata(ctx context.Context, metadata []CanvasMetadata) (map[string]struct{}, []error) { + knownInstances := make(map[string]struct{}, len(metadata)) + for _, item := range metadata { + knownInstances[item.PluginInstanceID] = struct{}{} + } + instancesByID, err := s.loadMetadataInstances(ctx, metadata) + if err != nil { + return knownInstances, []error{fmt.Errorf("inspect canvas instances: %w", err)} + } + var errs []error + for _, item := range metadata { + instance, exists := instancesByID[item.PluginInstanceID] + if exists && instance.Status != plugininstances.StatusRemoved { + continue + } + if _, err := s.removeMetadataLocked(ctx, item); err != nil { + errs = append(errs, fmt.Errorf("remove orphan canvas metadata %s: %w", item.ID, err)) + } + } + return knownInstances, errs +} + +func (s *Service) reconcileOrphanInstances(ctx context.Context, instances []plugininstances.Instance, known map[string]struct{}) []error { + var errs []error + for _, instance := range instances { + if _, exists := known[instance.ID]; exists { + continue + } + if instance.Status != plugininstances.StatusRemoved { + if err := s.instances.RemoveInstance(ctx, instance.ID); err != nil && !errors.Is(err, plugininstances.ErrNotFound) { + errs = append(errs, fmt.Errorf("remove orphan canvas instance %s: %w", instance.ID, err)) + continue + } + } + if s.stateCleanup != nil { + if err := s.stateCleanup(ctx, instance.ID); err != nil { + errs = append(errs, fmt.Errorf("remove orphan canvas state %s: %w", instance.ID, err)) + } + } + } + return errs +} + +func (s *Service) cleanupMetadataLocked(ctx context.Context, metadata []CanvasMetadata) ([]LifecycleEvent, error) { + var errs []error + events := make([]LifecycleEvent, 0, len(metadata)) + for _, item := range metadata { + event, err := s.removeMetadataLocked(ctx, item) + if err == nil { + events = append(events, event) + continue + } + if !errors.Is(err, ErrCanvasNotFound) { + errs = append(errs, fmt.Errorf("remove canvas %s: %w", item.ID, err)) + } + } + return events, errors.Join(errs...) +} + +func (s *Service) load(ctx context.Context, id string) (CanvasMetadata, plugininstances.Instance, error) { + metadata, err := s.repo.Get(ctx, id) + if err != nil { + return CanvasMetadata{}, plugininstances.Instance{}, err + } + instance, err := s.instances.Get(ctx, metadata.PluginInstanceID) + if errors.Is(err, plugininstances.ErrNotFound) { + return CanvasMetadata{}, plugininstances.Instance{}, ErrCanvasNotFound + } + if err != nil { + return CanvasMetadata{}, plugininstances.Instance{}, err + } + return metadata, instance, nil +} + +func (s *Service) removeMetadataLocked(ctx context.Context, metadata CanvasMetadata) (LifecycleEvent, error) { + instance, err := s.instances.Get(ctx, metadata.PluginInstanceID) + if errors.Is(err, plugininstances.ErrNotFound) { + // A crash can happen after instance removal commits but before the + // metadata transaction commits. There is no remaining instance + // authority to remove, so clear its state and finish metadata cleanup. + if s.stateCleanup != nil { + if err := s.stateCleanup(ctx, metadata.PluginInstanceID); err != nil { + return LifecycleEvent{}, err + } + } + if err := s.repo.Delete(ctx, metadata.ID); err != nil && !errors.Is(err, ErrCanvasNotFound) { + return LifecycleEvent{}, err + } + instance = plugininstances.Instance{ + ID: metadata.PluginInstanceID, PluginID: CanvasPluginID, + ScopeKind: ScopeWorkspace, WorkspaceID: metadata.WorkspaceID, + Status: StatusRemoved, + } + canvas := canvasFromMetadataInstance(metadata, instance) + return lifecycleEvent(EventRemoved, canvas), nil + } + if err != nil { + return LifecycleEvent{}, err + } + if err := s.removeAuthority(ctx, metadata, instance); err != nil { + return LifecycleEvent{}, err + } + instance.Status = StatusRemoved + instance.ActiveReleaseID = "" + if s.stateCleanup != nil { + if err := s.stateCleanup(ctx, instance.ID); err != nil { + return LifecycleEvent{}, err + } + } + canvas := canvasFromMetadataInstance(metadata, instance) + return lifecycleEvent(EventRemoved, canvas), nil +} + +func (s *Service) listMetadata( + ctx context.Context, + metadata []CanvasMetadata, + includeArchived bool, + allowed func(CanvasMetadata, plugininstances.Instance) bool, +) ([]Canvas, error) { + instancesByID, err := s.loadMetadataInstances(ctx, metadata) + if err != nil { + return nil, err + } + canvases := make([]Canvas, 0, len(metadata)) + for _, item := range metadata { + instance, exists := instancesByID[item.PluginInstanceID] + if !exists || instance.Status == StatusRemoved { + continue + } + if !includeArchived && instance.Status == StatusArchived { + continue + } + if allowed != nil && !allowed(item, instance) { + continue + } + canvas, err := s.buildCanvas(ctx, item, instance) + if err != nil { + return nil, err + } + canvases = append(canvases, canvas) + } + sort.SliceStable(canvases, func(i, j int) bool { + if canvases[i].CreatedAt.Equal(canvases[j].CreatedAt) { + return canvases[i].ID < canvases[j].ID + } + return canvases[i].CreatedAt.Before(canvases[j].CreatedAt) + }) + return canvases, nil +} + +type bulkPluginInstanceStore interface { + GetMany(context.Context, []string) ([]plugininstances.Instance, error) +} + +func (s *Service) loadMetadataInstances(ctx context.Context, metadata []CanvasMetadata) (map[string]plugininstances.Instance, error) { + ids := make([]string, 0, len(metadata)) + seen := make(map[string]struct{}, len(metadata)) + for _, item := range metadata { + if _, exists := seen[item.PluginInstanceID]; exists { + continue + } + seen[item.PluginInstanceID] = struct{}{} + ids = append(ids, item.PluginInstanceID) + } + result := make(map[string]plugininstances.Instance, len(ids)) + if bulk, ok := s.instances.(bulkPluginInstanceStore); ok { + instances, err := bulk.GetMany(ctx, ids) + if err != nil { + return nil, err + } + for _, instance := range instances { + result[instance.ID] = instance + } + return result, nil + } + for _, id := range ids { + instance, err := s.instances.Get(ctx, id) + if errors.Is(err, plugininstances.ErrNotFound) { + continue + } + if err != nil { + return nil, err + } + result[instance.ID] = instance + } + return result, nil +} + +func (s *Service) buildCanvas(ctx context.Context, metadata CanvasMetadata, instance plugininstances.Instance) (Canvas, error) { + if err := validateInstanceBinding(metadata, instance); err != nil { + return Canvas{}, err + } + canvas := canvasFromMetadataInstance(metadata, instance) + grants, err := s.listGrants(ctx, instance.ID) + if err != nil { + return Canvas{}, err + } + if instance.ActiveReleaseID == "" { + return s.addPendingRelease(ctx, &canvas, instance.ID, instance.ScopeKind, grants) + } + release, err := s.instances.GetRelease(ctx, instance.ActiveReleaseID) + if errors.Is(err, plugininstances.ErrNotFound) { + canvas.ActiveReleaseStatus = ValidationUnavailable + canvas.ActiveReleaseError = "active_release_missing" + canvas.ActiveRelease = &ReleaseMetadata{ID: instance.ActiveReleaseID, ValidationStatus: ValidationUnavailable, ValidationError: canvas.ActiveReleaseError} + return canvas, nil + } + if err != nil { + return Canvas{}, err + } + if release.InstanceID != instance.ID { + return Canvas{}, fmt.Errorf("%w: active release %s belongs to another instance", ErrCanvasMetadataBroken, release.ID) + } + canvas.ActiveReleaseStatus = release.ValidationStatus + canvas.ActiveReleaseError = release.ValidationError + canvas.EffectiveGrants = effectiveGrantProjection(instance, ReleasePermissionSummary(release), grants) + canvas.ActiveRelease = releaseMetadata(release, instance.ScopeKind, grants) + return canvas, nil +} + +type releaseLister interface { + ListReleases(context.Context, string) ([]plugininstances.Release, error) +} + +func (s *Service) addPendingRelease(ctx context.Context, canvas *Canvas, instanceID, scope string, grants []plugininstances.Grant) (Canvas, error) { + lister, ok := s.instances.(releaseLister) + if !ok { + return *canvas, nil + } + releases, err := lister.ListReleases(ctx, instanceID) + if err != nil { + return Canvas{}, err + } + for _, release := range releases { + if release.ValidationStatus != ValidationPendingPermission { + continue + } + canvas.PendingRelease = releaseMetadata(release, scope, grants) + break + } + return *canvas, nil +} + +type grantLister interface { + ListGrants(context.Context, string) ([]plugininstances.Grant, error) +} + +func (s *Service) listGrants(ctx context.Context, instanceID string) ([]plugininstances.Grant, error) { + lister, ok := s.instances.(grantLister) + if !ok { + return nil, nil + } + return lister.ListGrants(ctx, instanceID) +} + +func (s *Service) compensateCreate(instanceID string) error { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), 5*time.Second) + defer cancel() + return s.instances.RemoveInstance(cleanupCtx, instanceID) +} + +func (s *Service) ready() error { + if s == nil || s.repo == nil || s.instances == nil { + return ErrCanvasNotConfigured + } + return nil +} + +func (s *Service) nowUTC() time.Time { + if s.clock == nil { + return time.Now().UTC() + } + return s.clock().UTC() +} + +func normalizeCreateRequest(request CreateCanvasRequest) (CreateCanvasRequest, error) { + request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) + request.TaskID = strings.TrimSpace(request.TaskID) + request.OriginTaskID = strings.TrimSpace(request.OriginTaskID) + request.CreatedBySessionID = strings.TrimSpace(request.CreatedBySessionID) + request.PluginID = strings.TrimSpace(request.PluginID) + request.Title = strings.TrimSpace(request.Title) + if request.PluginID == "" { + request.PluginID = CanvasPluginID + } + if request.WorkspaceID == "" { + return CreateCanvasRequest{}, fmt.Errorf("%w: workspace_id is required", ErrInvalidCanvas) + } + if request.Title == "" || len([]rune(request.Title)) > MaxTitleLength { + return CreateCanvasRequest{}, fmt.Errorf("%w: title must contain 1-%d characters", ErrInvalidCanvas, MaxTitleLength) + } + if request.TaskID != "" && request.OriginTaskID == "" { + request.OriginTaskID = request.TaskID + } + return request, nil +} + +func validateInstanceBinding(metadata CanvasMetadata, instance plugininstances.Instance) error { + if metadata.PluginInstanceID != instance.ID || metadata.WorkspaceID != instance.WorkspaceID { + return ErrCanvasMetadataBroken + } + if metadata.TaskID == "" { + if instance.ScopeKind != ScopeWorkspace || instance.TaskID != "" { + return ErrCanvasMetadataBroken + } + return nil + } + if instance.ScopeKind != ScopeTask || instance.TaskID != metadata.TaskID { + return ErrCanvasMetadataBroken + } + return nil +} + +func canvasFromMetadataInstance(metadata CanvasMetadata, instance plugininstances.Instance) Canvas { + updatedAt := metadata.UpdatedAt + if instance.UpdatedAt.After(updatedAt) { + updatedAt = instance.UpdatedAt + } + return Canvas{ + ID: metadata.ID, + PluginInstanceID: instance.ID, + PluginID: instance.PluginID, + WorkspaceID: metadata.WorkspaceID, + TaskID: metadata.TaskID, + OriginTaskID: metadata.OriginTaskID, + ScopeKind: instance.ScopeKind, + Title: metadata.Title, + CreatedBySessionID: metadata.CreatedBySessionID, + PromotedByUserID: metadata.PromotedByUserID, + PromotedAt: metadata.PromotedAt, + Status: instance.Status, + ActiveReleaseID: instance.ActiveReleaseID, + GrantGeneration: instance.GrantGeneration, + CreatedAt: metadata.CreatedAt, + UpdatedAt: updatedAt, + } +} + +func isTaskCanvas(_ CanvasMetadata, instance plugininstances.Instance) bool { + return instance.ScopeKind == ScopeTask +} + +func isWorkspaceCanvas(metadata CanvasMetadata, instance plugininstances.Instance) bool { + return metadata.TaskID == "" && instance.ScopeKind == ScopeWorkspace +} + +func lifecycleEvent(eventType string, canvas Canvas) LifecycleEvent { + return LifecycleEvent{ + Type: eventType, + CanvasID: canvas.ID, + PluginInstanceID: canvas.PluginInstanceID, + WorkspaceID: canvas.WorkspaceID, + TaskID: canvas.TaskID, + ScopeKind: canvas.ScopeKind, + Status: canvas.Status, + ActiveReleaseID: canvas.ActiveReleaseID, + ActiveReleaseStatus: canvas.ActiveReleaseStatus, + } +} + +func publishEvent(ctx context.Context, publisher EventPublisher, event LifecycleEvent) { + if publisher != nil { + publisher(ctx, event) + } +} diff --git a/apps/backend/internal/canvas/types.go b/apps/backend/internal/canvas/types.go new file mode 100644 index 0000000000..3f319733b1 --- /dev/null +++ b/apps/backend/internal/canvas/types.go @@ -0,0 +1,202 @@ +// Package canvas owns the lifecycle metadata for agent-authored web-app +// canvases. Plugin instance scope, status, releases, and cleanup inventory +// remain authoritative in internal/plugins/instances. +package canvas + +import ( + "context" + "errors" + "time" + + "github.com/jmoiron/sqlx" + + plugininstances "github.com/kandev/kandev/internal/plugins/instances" +) + +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 + + MaxTaskCanvases = plugininstances.MaxTaskInstances + MaxWorkspaceCanvases = plugininstances.MaxWorkspaceInstances +) + +const ( + ScopeTask = plugininstances.ScopeTask + ScopeWorkspace = plugininstances.ScopeWorkspace + + StatusPending = plugininstances.StatusPending + StatusActive = plugininstances.StatusActive + StatusDisabled = plugininstances.StatusDisabled + StatusArchived = plugininstances.StatusArchived + StatusError = plugininstances.StatusError + StatusRemoved = plugininstances.StatusRemoved + + ValidationValid = plugininstances.ValidationValid + ValidationPendingPermission = plugininstances.ValidationPendingPermission + ValidationInvalid = plugininstances.ValidationInvalid + ValidationUnavailable = plugininstances.ValidationUnavailable +) + +const ( + permissionKindAPIRead = "api_read" + permissionKindAPIWrite = "api_write" + permissionKindEvents = "events" + permissionKindNetwork = "network" + permissionKindState = "state" +) + +var ( + ErrCanvasNotFound = errors.New("canvas not found") + ErrInvalidCanvas = errors.New("invalid canvas") + ErrInvalidCanvasState = errors.New("invalid canvas state") + ErrCanvasMetadataBroken = errors.New("canvas metadata is inconsistent with its plugin instance") + ErrCanvasNotConfigured = errors.New("canvas lifecycle is not configured") + ErrStalePromotionReview = plugininstances.ErrStalePromotionReview + ErrStaleCanvasEdit = plugininstances.ErrStaleCanvasEdit + ErrStaleCanvasPublish = plugininstances.ErrStaleCanvasPublish + ErrInvalidLifecycleState = plugininstances.ErrInvalidLifecycleState + + // 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 +) + +// CreateCanvasRequest contains trusted scope metadata supplied by the caller. +// Agent-facing entry points must populate it from their execution context; +// the canvas service does not infer or authorize arbitrary task IDs. +type CreateCanvasRequest struct { + 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"` + PluginID string `json:"plugin_id,omitempty"` +} + +// Canvas is the lifecycle projection consumed by host, API, and event layers. +// Scope, status, and active release fields come from the plugin instance; the +// remaining fields are canvas-owned metadata. +type Canvas struct { + ID string `json:"id"` + PluginInstanceID string `json:"plugin_instance_id"` + PluginID string `json:"plugin_id"` + WorkspaceID string `json:"workspace_id"` + TaskID string `json:"task_id,omitempty"` + OriginTaskID string `json:"origin_task_id,omitempty"` + ScopeKind string `json:"scope_kind"` + 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"` + Status string `json:"status"` + ActiveReleaseID string `json:"active_release_id,omitempty"` + ActiveReleaseStatus string `json:"active_release_status,omitempty"` + ActiveReleaseError string `json:"active_release_error,omitempty"` + GrantGeneration int64 `json:"grant_generation,omitempty"` + EffectiveGrants []GrantProjection `json:"effective_grants,omitempty"` + ActiveRelease *ReleaseMetadata `json:"active_release,omitempty"` + PendingRelease *ReleaseMetadata `json:"pending_release,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ReleaseMetadata deliberately omits manifest and artifact contents. The host +// receives only the review-safe declaration, provenance, and current grant +// diff needed to choose recovery actions. +type ReleaseMetadata struct { + ID string `json:"id"` + PackageDigest string `json:"package_digest"` + ValidationStatus string `json:"validation_status"` + ValidationError string `json:"validation_error,omitempty"` + Permissions *PermissionSummary `json:"permissions,omitempty"` + MissingPermissions []string `json:"missing_permissions,omitempty"` + PermissionDigest string `json:"permission_digest,omitempty"` + SourceActorKind string `json:"source_actor_kind,omitempty"` + SourceUserID string `json:"source_user_id,omitempty"` + SourceTaskID string `json:"source_task_id,omitempty"` + SourceSessionID string `json:"source_session_id,omitempty"` + ProtocolVersion int `json:"protocol_version,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// GrantProjection is the safe, non-credential view of one effective grant. +// It intentionally excludes approver identity and timestamps. +type GrantProjection struct { + PermissionKind string `json:"permission_kind"` + Resource string `json:"resource,omitempty"` + NetworkOrigin string `json:"network_origin,omitempty"` + ScopeCeiling string `json:"scope_ceiling"` +} + +// 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"` +} + +// PluginInstanceStore is the small adapter surface used by the lifecycle +// service. *instances.Store satisfies it, while a narrow fake can be used by +// service tests or a later transactional integration. +type PluginInstanceStore interface { + Create(context.Context, plugininstances.Instance) error + Get(context.Context, string) (plugininstances.Instance, error) + GetRelease(context.Context, string) (plugininstances.Release, error) + Archive(context.Context, string) error + Restore(context.Context, string) error + RemoveInstance(context.Context, string) error +} + +// transactionalPluginInstanceStore is the durable integration surface used +// when canvas metadata and plugin-instance authority must commit together. +// It is optional so small lifecycle fakes remain useful in unit tests. +type transactionalPluginInstanceStore interface { + PluginInstanceStore + WithTransaction(context.Context, func(*sqlx.Tx) error) error + CreateTx(context.Context, *sqlx.Tx, plugininstances.Instance) error + PromoteScopeAndGrantsTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant) error + RemoveInstanceTx(context.Context, *sqlx.Tx, string) error + ListBySource(context.Context, string, bool) ([]plugininstances.Instance, error) +} + +// LifecycleEvent is a content-free notification for the WebSocket gateway. +// It never carries source files, application state, package bodies, or runtime +// capabilities. +type LifecycleEvent struct { + Type string `json:"type"` + CanvasID string `json:"canvas_id"` + PluginInstanceID string `json:"plugin_instance_id"` + WorkspaceID string `json:"workspace_id"` + TaskID string `json:"task_id,omitempty"` + ScopeKind string `json:"scope_kind"` + Status string `json:"status"` + ActiveReleaseID string `json:"active_release_id,omitempty"` + ActiveReleaseStatus string `json:"active_release_status,omitempty"` +} + +const ( + EventCreated = "canvas.created" + EventReleaseActivated = "canvas.release.activated" + EventReleasePermissionRequired = "canvas.release.permission_required" + EventPromoted = "canvas.promoted" + EventArchived = "canvas.archived" + EventRestored = "canvas.restored" + EventRemoved = "canvas.removed" +) + +// EventPublisher receives committed lifecycle changes. Publishing is +// best-effort and must be wired to an out-of-transaction event gateway. +type EventPublisher func(context.Context, LifecycleEvent) diff --git a/apps/backend/internal/common/config/catalog.go b/apps/backend/internal/common/config/catalog.go index 776760d5fc..8846217dab 100644 --- a/apps/backend/internal/common/config/catalog.go +++ b/apps/backend/internal/common/config/catalog.go @@ -128,6 +128,7 @@ var startupExclusions = []CatalogExclusion{ {EnvVar: "KANDEV_MOCK_LINEAR", Class: "test", Reason: "test-only provider replacement"}, {EnvVar: "KANDEV_FEATURES_OFFICE", Class: "profile", Reason: "runtime feature flag registry"}, {EnvVar: "KANDEV_FEATURES_AUTH", Class: "profile", Reason: "runtime feature flag registry"}, + {EnvVar: "KANDEV_FEATURES_CANVASES", Class: "profile", Reason: "runtime feature flag registry"}, {EnvVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", Class: "debug", Reason: "runtime feature flag registry"}, {EnvVar: "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", Class: "debug", Reason: "runtime feature flag registry"}, {EnvVar: "KANDEV_FEATURES_OFFICE_SESSION_IDENTITY", Class: "debug", Reason: "runtime feature flag registry"}, diff --git a/apps/backend/internal/common/config/catalog_test.go b/apps/backend/internal/common/config/catalog_test.go index a729fe4464..c5d3cbe130 100644 --- a/apps/backend/internal/common/config/catalog_test.go +++ b/apps/backend/internal/common/config/catalog_test.go @@ -148,6 +148,7 @@ func auditedStartupEnvironmentInventory() []auditedStartupEnvironment { {envVar: "KANDEV_MOCK_LINEAR", class: "exclusion"}, {envVar: "KANDEV_FEATURES_OFFICE", class: "exclusion"}, {envVar: "KANDEV_FEATURES_AUTH", class: "exclusion"}, + {envVar: "KANDEV_FEATURES_CANVASES", class: "exclusion"}, {envVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", class: "exclusion"}, {envVar: "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", class: "exclusion"}, {envVar: "KANDEV_FEATURES_OFFICE_SESSION_IDENTITY", class: "exclusion"}, diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index b5271d0f2e..0efb323494 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -442,6 +442,11 @@ type FeaturesConfig struct { // opts in. Auth bool `mapstructure:"auth" json:"auth"` + // Canvases gates agent-authored plugin web applications and their task and + // workspace surfaces. It remains off in every shipped profile until the + // isolated runtime and lifecycle are ready for opt-in use. + Canvases bool `mapstructure:"canvases" json:"canvases"` + // MultiTenancy gates organizations: a tenant boundary above users, where // every user belongs to exactly one org and cross-org reach is a bug // rather than a permission level. It requires Auth; enabling it without diff --git a/apps/backend/internal/common/config/config_test.go b/apps/backend/internal/common/config/config_test.go index 52b72d5a31..a96de8d8b6 100644 --- a/apps/backend/internal/common/config/config_test.go +++ b/apps/backend/internal/common/config/config_test.go @@ -210,6 +210,7 @@ func TestFeatures_ProductionDefaults(t *testing.T) { // host shell cannot change the production-profile defaults under test. t.Setenv("KANDEV_FEATURES_OFFICE", "") unsetEnv(t, "KANDEV_FEATURES_AUTH") + unsetEnv(t, "KANDEV_FEATURES_CANVASES") unsetEnv(t, "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF") t.Setenv("KANDEV_DEBUG_DEV_MODE", "") t.Setenv("KANDEV_DEBUG_PPROF_ENABLED", "") diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 03bc52c3a7..a95930513d 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -19,6 +19,19 @@ const ( TaskDependencyFailed = "task.dependency_failed" ) +// Event types for plugin-backed canvas lifecycle changes. Payloads contain +// only canvas identity, scope, status, and release metadata. They never carry +// source files, application state, or runtime capabilities. +const ( + CanvasCreated = "canvas.created" + CanvasReleaseActivated = "canvas.release.activated" + CanvasReleasePermissionRequired = "canvas.release.permission_required" + CanvasPromoted = "canvas.promoted" + CanvasArchived = "canvas.archived" + CanvasRestored = "canvas.restored" + CanvasRemoved = "canvas.removed" +) + // Event types for office task tree controls. const ( OfficeTaskTreeHoldCreated = "task.tree_hold_created" diff --git a/apps/backend/internal/gateway/websocket/canvas_notifications.go b/apps/backend/internal/gateway/websocket/canvas_notifications.go new file mode 100644 index 0000000000..79f9fb89f6 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/canvas_notifications.go @@ -0,0 +1,124 @@ +package websocket + +import ( + "context" + "encoding/json" + "sync" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// CanvasEventBroadcaster forwards the content-free canvas lifecycle events to +// the owner of the canvas workspace. Application data belongs on the +// capability-bound SSE protocol, not on this shared WebSocket channel. +type CanvasEventBroadcaster struct { + hub *Hub + subscriptions []bus.Subscription + logger *logger.Logger + closeMu sync.Mutex + closed bool +} + +// RegisterCanvasNotifications wires the gated canvas lifecycle events to the +// existing workspace-scoped WebSocket hub. Callers must only invoke it when +// features.canvases is enabled. +func RegisterCanvasNotifications(ctx context.Context, eventBus bus.EventBus, hub *Hub, log *logger.Logger) *CanvasEventBroadcaster { + b := &CanvasEventBroadcaster{ + hub: hub, + logger: log.WithFields(zap.String("component", "ws-canvas-broadcaster")), + } + if eventBus == nil || hub == nil { + return b + } + + for _, event := range []struct { + subject string + action string + }{ + {events.CanvasCreated, ws.ActionCanvasCreated}, + {events.CanvasReleaseActivated, ws.ActionCanvasReleaseActivated}, + {events.CanvasReleasePermissionRequired, ws.ActionCanvasReleasePermissionRequired}, + {events.CanvasPromoted, ws.ActionCanvasPromoted}, + {events.CanvasArchived, ws.ActionCanvasArchived}, + {events.CanvasRestored, ws.ActionCanvasRestored}, + {events.CanvasRemoved, ws.ActionCanvasRemoved}, + } { + b.subscribe(eventBus, event.subject, event.action) + } + + go func() { + <-ctx.Done() + b.Close() + }() + return b +} + +// Close releases event-bus subscriptions. It is safe to call more than once. +func (b *CanvasEventBroadcaster) Close() { + b.closeMu.Lock() + if b.closed { + b.closeMu.Unlock() + return + } + b.closed = true + subscriptions := b.subscriptions + b.subscriptions = nil + b.closeMu.Unlock() + + for _, sub := range subscriptions { + if sub != nil && sub.IsValid() { + _ = sub.Unsubscribe() + } + } +} + +func (b *CanvasEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action string) { + sub, err := eventBus.Subscribe(subject, func(ctx context.Context, event *bus.Event) error { + msg, err := ws.NewNotification(action, event.Data) + if err != nil { + b.logger.Error("failed to build canvas websocket notification", zap.String("action", action), zap.Error(err)) + return nil + } + workspaceID := canvasWorkspaceID(event.Data) + // Canvas events are always workspace-scoped. Dropping an unattributed + // event under auth is safer than allowing it to fan out globally. + b.hub.BroadcastToWorkspaceOrDrop(workspaceID, msg) + return nil + }) + if err != nil { + b.logger.Error("failed to subscribe to canvas events", zap.String("subject", subject), zap.Error(err)) + return + } + b.closeMu.Lock() + if b.closed { + b.closeMu.Unlock() + if sub.IsValid() { + _ = sub.Unsubscribe() + } + return + } + b.subscriptions = append(b.subscriptions, sub) + b.closeMu.Unlock() +} + +func canvasWorkspaceID(data interface{}) string { + if workspaceID := extractWorkspaceID(data); workspaceID != "" { + return workspaceID + } + // Memory-event callers often pass a typed LifecycleEvent while NATS + // callers pass a JSON-shaped map. Normalize the former without making the + // gateway depend on the canvas package's concrete event type. + encoded, err := json.Marshal(data) + if err != nil { + return "" + } + var fields map[string]interface{} + if err := json.Unmarshal(encoded, &fields); err != nil { + return "" + } + return extractWorkspaceID(fields) +} diff --git a/apps/backend/internal/mcp/canvasskill/bundled.go b/apps/backend/internal/mcp/canvasskill/bundled.go new file mode 100644 index 0000000000..0aecec06ac --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/bundled.go @@ -0,0 +1,312 @@ +// Package canvasskill owns the Kandev canvas-authoring system skill. +// +// Canvas authoring is deliberately separate from the Office skill embed. The +// skill is materialized under Kandev's system-skills directory and is read by +// the canvas MCP surface without being copied into an execution workspace. +package canvasskill + +import ( + "embed" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" +) + +const ( + // Slug is the stable system-skill identity returned by canvas MCP tools. + Slug = "kandev-canvas-authoring" + // Version changes when the authoring contract or its supporting references + // change. Canvas releases keep the version in their authoring metadata. + Version = "2" + + materializedDirectory = "system-skills" + versionFileName = ".kandev-version" + maxReadableFileBytes = 1 << 20 +) + +// The files directory is the complete allowlisted inventory. Do not add an +// Office skill path here: the two embeds have independent lifecycle and +// ownership rules. +// +//go:embed files +var bundledFiles embed.FS + +// BundleFile is a file returned as part of the compact core bundle or used by +// the generated source scaffold. Paths are always relative to the canvas +// source directory and Content is copied before it is returned. +type BundleFile struct { + Path string + Content []byte +} + +var canonicalInventory = []string{ + "SKILL.md", + "references/browser-api.md", + "references/data-and-state.md", + "references/events-and-recovery.md", + "references/manifest.md", + "references/security.md", + "references/ui-patterns.md", + "scaffold/appearance.js", + "scaffold/index.html", + "scaffold/script.js", + "scaffold/styles.css", +} + +var coreInventory = []string{ + "SKILL.md", + "scaffold/index.html", + "scaffold/appearance.js", + "scaffold/script.js", + "scaffold/styles.css", +} + +var scaffoldInventory = []string{ + "manifest.yaml", + "index.html", + "appearance.js", + "script.js", + "styles.css", +} + +// Inventory returns the stable, slash-separated list of files that Canvas MCP +// may read. The returned slice is a new value on every call. +func Inventory() []string { + return append([]string(nil), canonicalInventory...) +} + +// CoreInventory returns the single-read bundle in delivery order. Detailed +// references remain available through Inventory and ReadMaterialized. +func CoreInventory() []string { + return append([]string(nil), coreInventory...) +} + +// ScaffoldInventory returns the exact files materialized in an assigned +// canvas source directory. The manifest is generated from the canvas request; +// the remaining files come from the embedded templates. +func ScaffoldInventory() []string { + return append([]string(nil), scaffoldInventory...) +} + +// ScaffoldTemplateFiles returns the static files copied into every new canvas +// source directory. The returned byte slices are independent of the embed. +func ScaffoldTemplateFiles() ([]BundleFile, error) { + files := make([]BundleFile, 0, len(coreInventory)-1) + for _, rel := range coreInventory[1:] { + content, err := fs.ReadFile(bundledFiles, filepath.ToSlash(filepath.Join("files", rel))) + if err != nil { + return nil, fmt.Errorf("read embedded canvas scaffold %q: %w", rel, err) + } + files = append(files, BundleFile{ + Path: strings.TrimPrefix(rel, "scaffold/"), + Content: append([]byte(nil), content...), + }) + } + return files, nil +} + +// EnsureMaterialized writes the embedded skill into Kandev's system-owned +// directory. Existing files are replaced atomically. The directory is not a +// user skill directory and is never scanned by Office skill synchronization. +func EnsureMaterialized(home string) error { + root, err := materializedRoot(home) + if err != nil { + return err + } + if err := ensureDirectory(root); err != nil { + return err + } + + for _, rel := range Inventory() { + content, err := fs.ReadFile(bundledFiles, filepath.ToSlash(filepath.Join("files", rel))) + if err != nil { + return fmt.Errorf("read embedded canvas skill %q: %w", rel, err) + } + target, err := materializedFilePath(root, rel) + if err != nil { + return err + } + if err := ensureDirectory(filepath.Dir(target)); err != nil { + return err + } + if err := writeAtomic(target, content, 0o644); err != nil { + return fmt.Errorf("materialize canvas skill %q: %w", rel, err) + } + } + + if err := writeAtomic(filepath.Join(root, versionFileName), []byte(Version+"\n"), 0o644); err != nil { + return fmt.Errorf("materialize canvas skill version: %w", err) + } + return nil +} + +// MaterializedVersion returns the version marker written by +// EnsureMaterialized. An empty result means the system skill is not +// materialized or its marker cannot be read. +func MaterializedVersion(home string) string { + root, err := materializedRoot(home) + if err != nil { + return "" + } + content, err := os.ReadFile(filepath.Join(root, versionFileName)) + if err != nil { + return "" + } + return strings.TrimSpace(string(content)) +} + +// ReadMaterialized reads one file from the current materialized inventory. +// Empty path selects SKILL.md. Absolute paths, traversal, links, and files +// outside the embedded inventory are rejected before the file is opened. +func ReadMaterialized(home, requestedPath string) ([]byte, error) { + root, err := materializedRoot(home) + if err != nil { + return nil, err + } + rel, err := allowlistedPath(requestedPath) + if err != nil { + return nil, err + } + if !containsInventory(rel) { + return nil, fmt.Errorf("canvas skill path %q is not allowlisted", requestedPath) + } + if err := ensureDirectory(root); err != nil { + return nil, err + } + target, err := materializedFilePath(root, rel) + if err != nil { + return nil, err + } + info, err := os.Lstat(target) + if err != nil { + return nil, fmt.Errorf("read canvas skill %q: %w", rel, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("canvas skill path %q is a symlink", rel) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("canvas skill path %q is not a regular file", rel) + } + if info.Size() > maxReadableFileBytes { + return nil, fmt.Errorf("canvas skill path %q exceeds the read limit", rel) + } + + file, err := os.Open(target) + if err != nil { + return nil, fmt.Errorf("read canvas skill %q: %w", rel, err) + } + defer func() { _ = file.Close() }() + content, err := io.ReadAll(io.LimitReader(file, maxReadableFileBytes+1)) + if err != nil { + return nil, fmt.Errorf("read canvas skill %q: %w", rel, err) + } + if len(content) > maxReadableFileBytes { + return nil, fmt.Errorf("canvas skill path %q exceeds the read limit", rel) + } + return content, nil +} + +func materializedRoot(home string) (string, error) { + if strings.TrimSpace(home) == "" { + return "", errors.New("kandev home is required for canvas skill materialization") + } + absHome, err := filepath.Abs(home) + if err != nil { + return "", fmt.Errorf("resolve Kandev home: %w", err) + } + return filepath.Join(absHome, materializedDirectory, Slug), nil +} + +func allowlistedPath(requestedPath string) (string, error) { + if requestedPath == "" { + requestedPath = "SKILL.md" + } + if strings.IndexByte(requestedPath, 0) >= 0 || filepath.IsAbs(requestedPath) || path.IsAbs(filepath.ToSlash(requestedPath)) || filepath.VolumeName(requestedPath) != "" { + return "", fmt.Errorf("canvas skill path %q must be relative", requestedPath) + } + if strings.Contains(requestedPath, `\`) { + return "", fmt.Errorf("canvas skill path %q must use slash-separated relative paths", requestedPath) + } + clean := path.Clean(requestedPath) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("canvas skill path %q contains traversal", requestedPath) + } + return clean, nil +} + +func containsInventory(rel string) bool { + for _, candidate := range Inventory() { + if candidate == rel { + return true + } + } + return false +} + +func materializedFilePath(root, rel string) (string, error) { + rel, err := allowlistedPath(rel) + if err != nil { + return "", err + } + target := filepath.Join(root, filepath.FromSlash(rel)) + rootAbs, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("resolve canvas skill root: %w", err) + } + targetAbs, err := filepath.Abs(target) + if err != nil { + return "", fmt.Errorf("resolve canvas skill path: %w", err) + } + relToRoot, err := filepath.Rel(rootAbs, targetAbs) + if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("canvas skill path %q escapes the skill root", rel) + } + return targetAbs, nil +} + +func ensureDirectory(directory string) error { + if err := os.MkdirAll(directory, 0o755); err != nil { + return fmt.Errorf("create canvas skill directory: %w", err) + } + info, err := os.Lstat(directory) + if err != nil { + return fmt.Errorf("inspect canvas skill directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("canvas skill directory %q is a symlink", directory) + } + if !info.IsDir() { + return fmt.Errorf("canvas skill path %q is not a directory", directory) + } + return nil +} + +func writeAtomic(target string, content []byte, mode os.FileMode) error { + file, err := os.CreateTemp(filepath.Dir(target), ".canvas-skill-*") + if err != nil { + return err + } + temporary := file.Name() + defer func() { _ = os.Remove(temporary) }() + if err := file.Chmod(mode); err != nil { + _ = file.Close() + return err + } + if _, err := file.Write(content); err != nil { + _ = file.Close() + return err + } + if err := file.Sync(); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(temporary, target) +} diff --git a/apps/backend/internal/mcp/canvasskill/bundled_test.go b/apps/backend/internal/mcp/canvasskill/bundled_test.go new file mode 100644 index 0000000000..9049d5caa6 --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/bundled_test.go @@ -0,0 +1,166 @@ +package canvasskill + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/stretchr/testify/require" +) + +func TestEnsureMaterialized_WritesKandevOwnedCanvasSkill(t *testing.T) { + home := t.TempDir() + + require.NoError(t, EnsureMaterialized(home)) + + root := filepath.Join(home, "system-skills", Slug) + skill, err := os.ReadFile(filepath.Join(root, "SKILL.md")) + require.NoError(t, err) + require.Contains(t, string(skill), "./_kandev/v1") + require.Contains(t, string(skill), "browser storage") + require.FileExists(t, filepath.Join(root, "references", "browser-api.md")) + require.NoDirExists(t, filepath.Join(home, "skills", Slug)) +} + +func TestReadMaterialized_UsesAllowlistedInventory(t *testing.T) { + home := t.TempDir() + require.NoError(t, EnsureMaterialized(home)) + + content, err := ReadMaterialized(home, "SKILL.md") + require.NoError(t, err) + require.NotEmpty(t, content) + require.Equal(t, Version, MaterializedVersion(home)) + require.Equal(t, "2", MaterializedVersion(home)) + + for _, path := range []string{"../outside.txt", filepath.Join(string(os.PathSeparator), "etc", "passwd"), "missing.md"} { + _, err := ReadMaterialized(home, path) + require.Error(t, err, "path %q should be rejected", path) + } +} + +func TestReadMaterialized_RejectsSymlinkedSupportFile(t *testing.T) { + home := t.TempDir() + require.NoError(t, EnsureMaterialized(home)) + root := filepath.Join(home, "system-skills", Slug) + outside := filepath.Join(home, "outside.txt") + require.NoError(t, os.WriteFile(outside, []byte("secret"), 0o600)) + link := filepath.Join(root, "references", "outside.md") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlinks are unavailable: %v", err) + } + + _, err := ReadMaterialized(home, "references/outside.md") + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "allowlisted") || strings.Contains(err.Error(), "symlink")) +} + +func TestInventory_IsStableAndContainsSupportingReferences(t *testing.T) { + inventory := Inventory() + require.Equal(t, []string{ + "SKILL.md", + "references/browser-api.md", + "references/data-and-state.md", + "references/events-and-recovery.md", + "references/manifest.md", + "references/security.md", + "references/ui-patterns.md", + "scaffold/appearance.js", + "scaffold/index.html", + "scaffold/script.js", + "scaffold/styles.css", + }, inventory) + require.NotEmpty(t, Version) +} + +func TestCoreInventory_IsOneReadAuthoringBundle(t *testing.T) { + require.Equal(t, []string{ + "SKILL.md", + "scaffold/index.html", + "scaffold/appearance.js", + "scaffold/script.js", + "scaffold/styles.css", + }, CoreInventory()) +} + +func TestScaffoldInventory_IsTheGeneratedSourceContract(t *testing.T) { + require.Equal(t, []string{ + "manifest.yaml", + "index.html", + "appearance.js", + "script.js", + "styles.css", + }, ScaffoldInventory()) + + files, err := ScaffoldTemplateFiles() + require.NoError(t, err) + require.Equal(t, []string{ + "index.html", + "appearance.js", + "script.js", + "styles.css", + }, filePaths(files)) + for _, file := range files { + require.NotEmpty(t, file.Content, file.Path) + } +} + +func filePaths(files []BundleFile) []string { + paths := make([]string, 0, len(files)) + for _, file := range files { + paths = append(paths, file.Path) + } + return paths +} + +func TestAuthoringReferencesMatchValidatedManifestAndProtocol(t *testing.T) { + home := t.TempDir() + require.NoError(t, EnsureMaterialized(home)) + + manifestText, err := ReadMaterialized(home, "references/manifest.md") + require.NoError(t, err) + manifestSource := string(manifestText) + start := strings.Index(manifestSource, "```yaml\n") + require.GreaterOrEqual(t, start, 0, "manifest reference must contain a YAML example") + start += len("```yaml\n") + end := strings.Index(manifestSource[start:], "\n```") + require.GreaterOrEqual(t, end, 0, "manifest YAML example must be closed") + parsed, err := manifest.Parse([]byte(manifestSource[start : start+end])) + require.NoError(t, err) + require.NoError(t, parsed.Validate()) + require.Equal(t, manifest.CurrentAPIVersion, parsed.APIVersion) + require.Len(t, parsed.UI.WebApps, 1) + require.Equal(t, "ui/index.html", parsed.UI.WebApps[0].Entry) + + browserAPI, err := ReadMaterialized(home, "references/browser-api.md") + require.NoError(t, err) + browserSource := string(browserAPI) + for _, route := range []string{ + "GET ./_kandev/v1/context", + "GET | `./_kandev/v1/data/tasks`", + "GET | `./_kandev/v1/data/tasks/{task_id}`", + "PATCH | `./_kandev/v1/data/tasks/{task_id}`", + "POST | `./_kandev/v1/data/tasks/{task_id}/messages`", + "GET | `./_kandev/v1/data/workflows`", + "GET | `./_kandev/v1/data/workflows/{workflow_id}/steps`", + "GET | `./_kandev/v1/state`", + "GET | `./_kandev/v1/state/{key}`", + "PUT | `./_kandev/v1/state/{key}`", + "DELETE | `./_kandev/v1/state/{key}`", + "GET ./_kandev/v1/events", + "POST ./_kandev/v1/actions/{key}", + } { + require.Contains(t, browserSource, route, "browser API reference is missing %q", route) + } + for _, contract := range []string{ + "If-Match", + "plugin_state_conflict", + "runtime.resync_required", + "workflow_step_id", + "Last-Event-ID", + "permission", + } { + require.Contains(t, browserSource, contract) + } +} diff --git a/apps/backend/internal/mcp/canvasskill/files/SKILL.md b/apps/backend/internal/mcp/canvasskill/files/SKILL.md new file mode 100644 index 0000000000..b0ed507e7a --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/SKILL.md @@ -0,0 +1,86 @@ +--- +name: kandev-canvas-authoring +version: "1" +description: Author a Kandev task canvas as a self-contained web application. +--- + +# Kandev canvas authoring + +Use one `read_canvas_authoring_skill_kandev` call without `path` when you need +the authoring contract. That response is the complete core bundle. It includes +this workflow, the manifest and browser protocol summary, appearance rules, +the minimal scaffold, and the exact supporting-file inventory. Do not read the +core bundle again during the same authoring task. + +## Required workflow + +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. +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. +3. Keep every source path relative to the returned directory. Do not write + outside it. Bundle executable dependencies. Node, a package manager, and a + network build step are not available at runtime. +4. Run local checks, then call `publish_canvas_kandev` with the returned canvas + ID and source path. Read validation diagnostics and correct rejected source + before publishing again. + +## Core application contract + +- Include ``. +- Use relative `./_kandev/v1` paths for Kandev data, state, actions, and events. +- Treat Kandev domain data as the source of truth. Derive filters and summaries + in memory instead of storing a second copy of domain records. +- Store only small application-specific shared values in instance state. Keep + temporary input in memory and use conditional revisions for writes. +- The canvas has an opaque origin. Do not use browser storage, service workers, + origin-wide cookies, host URLs, or authorization headers. +- Avoid secrets in source, URLs, query strings, logs, and client state. +- Render loading, empty, error, and retry states. Keep destructive actions + explicit and explain their result. +- Use accessible labels, keyboard operation, visible focus, and touch targets. + +## 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. + +## Browser protocol summary + +Resolve all routes from the application document with `./_kandev/v1`. Use +`context`, the paginated `data` routes, `state/{key}` with `If-Match`, and the +bounded `events` stream as documented by the optional references. Events are +hints that invalidate a read. Refetch authoritative data after an event, +reconnect with `Last-Event-ID`, and perform a full refetch after +`runtime.resync_required`. + +## Appearance protocol + +The host may send the public presentation-only message +`kandev.web_app.appearance` with `version: 1`, `mode: light|dark`, and exactly +these color tokens: `background`, `foreground`, `card`, `cardForeground`, +`muted`, `mutedForeground`, `border`, `primary`, `primaryForeground`, +`accent`, `accentForeground`, `destructive`, `destructiveForeground`, and +`ring`. Accept it only when `event.source === window.parent`, the type and +version match, the keys are exact, and each serialized color is bounded. Map +the tokens to the same-name kebab-case CSS variables. Keep light and dark +fallbacks so the app remains usable before the first message. The message has +no identity, capability, data, storage, navigation, or action fields. + +The generated `appearance.js` implements this listener. It is optional, but +copy its pattern when replacing the scaffold. + +Read a supporting reference only when its topic is needed: + +- `references/browser-api.md` for detailed browser routes and errors. +- `references/manifest.md` for the full manifest shape and validation rules. +- `references/data-and-state.md` for domain data and instance state. +- `references/events-and-recovery.md` for events, reconnect, and retries. +- `references/security.md` for opaque-origin and source safety rules. +- `references/ui-patterns.md` for responsive and accessible UI patterns. diff --git a/apps/backend/internal/mcp/canvasskill/files/references/browser-api.md b/apps/backend/internal/mcp/canvasskill/files/references/browser-api.md new file mode 100644 index 0000000000..abceaad8bf --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/references/browser-api.md @@ -0,0 +1,192 @@ +# Canvas browser API + +The host exposes protocol version 1 below the capability URL. Resolve every +route from the application document with a relative URL such as +`./_kandev/v1/context`. Do not use a host URL, a hard-coded port, or a token +from application state. The browser sends the capability token as part of the +current URL. + +Every request is checked against the current canvas release, scope, status, +and grants. A request can fail after a release is archived, a grant is +revoked, or the canvas is removed. Abort requests during iframe teardown and +show a retry state for transient reads. + +## Context + +`GET ./_kandev/v1/context` returns: + +```json +{ + "protocol_version": 1, + "instance_id": "instance-id", + "plugin_id": "example-canvas", + "release_id": "release-id", + "web_app_key": "main", + "placement": "task-canvas", + "scope_kind": "task", + "workspace_id": "workspace-id", + "task_id": "task-id", + "session_id": "session-id", + "repository_id": "repository-id", + "capabilities": ["api_read:tasks", "api_write:messages"] +} +``` + +Scope identifiers are omitted when they do not apply. `capabilities` contains +the effective, approved permission keys. It is not a replacement for handling +permission errors from later requests. + +## Data routes + +All data responses use JSON. Collection responses use this envelope: + +```json +{ + "items": [], + "page_info": { "next_cursor": "", "has_more": false } +} +``` + +`next_cursor` is omitted when there is no next page. `limit` must be from 1 to +the host page limit. A task-scoped canvas is restricted to its task. A +workspace-scoped canvas is restricted to its workspace. + +| Method | Route | Permission | Use | +| --- | --- | --- | --- | +| GET | `./_kandev/v1/data/tasks` | `api_read:tasks` | List tasks | +| GET | `./_kandev/v1/data/tasks/{task_id}` | `api_read:tasks` | Read one task | +| PATCH | `./_kandev/v1/data/tasks/{task_id}` | `api_write:tasks` | Update a task | +| POST | `./_kandev/v1/data/tasks/{task_id}/messages` | `api_write:messages` | Send a task message | +| GET | `./_kandev/v1/data/workflows` | `api_read:workflows` | List workflows | +| GET | `./_kandev/v1/data/workflows/{workflow_id}/steps` | `api_read:workflows` | Read workflow steps | + +The task-list query accepts `cursor`, `limit`, `include_archived`, +`workflow_id`, `state`, and `parent_id`. `workflow_id` and `state` can be +repeated or comma-separated. `include_archived` is a boolean. + +A task object contains these fields: `id`, `workspace_id`, `workflow_id`, +`title`, `description`, `state`, `priority`, `created_by`, `created_at`, +`updated_at`, `started_at`, `completed_at`, `parent_id`, `identifier`, +`is_ephemeral`, `repositories`, `metadata`, `archived_at`, `pull_requests`, +`workflow_step_id`, `position`, `assignee_agent_profile_id`, `labels`, +`autopilot`, `wip_admitted`, `queued_for_step_id`, `queued_at`, `project_id`, +and `external_id`. Repository entries contain `id`, `repository_id`, +`base_branch`, `position`, and `checkout_branch`. + +A workflow object contains `id`, `workspace_id`, `name`, `description`, +`sort_order`, `created_at`, and `updated_at`. A workflow-step object contains +`id`, `workflow_id`, `name`, `position`, `stage_type`, `color`, +`is_start_step`, `wip_limit`, `agent_profile_id`, and +`on_enter_action_types`. + +## Writes and workflow movement + +`PATCH ./_kandev/v1/data/tasks/{task_id}` accepts one or more of these JSON +fields: `title`, `description`, `state`, and `workflow_step_id`. A workflow +step move is therefore a task patch. There is no separate workflow-step write +route. The response is the updated task object. + +For example, to continue work, send a message through the normal task +message path: + +```http +POST ./_kandev/v1/data/tasks/task-id/messages +Content-Type: application/json + +{"text":"continue"} +``` + +To move the task, use its known target step ID: + +```http +PATCH ./_kandev/v1/data/tasks/task-id +Content-Type: application/json + +{"workflow_step_id":"step-in-progress"} +``` + +`POST ./_kandev/v1/data/tasks/{task_id}/messages` requires a non-empty `text` +field and accepts an optional `session_id`. It returns HTTP 202 with +`session_id` and `status`, where status is `queued`, `sent`, or `started`. + +## Instance state + +`GET ./_kandev/v1/state` lists state entries. `GET +./_kandev/v1/state/{key}` reads one entry. `PUT` and `DELETE` use +`./_kandev/v1/state/{key}` and require `If-Match`. + +| Method | Route | Permission | Use | +| --- | --- | --- | --- | +| GET | `./_kandev/v1/state` | `state` | List state | +| GET | `./_kandev/v1/state/{key}` | `state` | Read state | +| PUT | `./_kandev/v1/state/{key}` | `state` | Replace state | +| DELETE | `./_kandev/v1/state/{key}` | `state` | Delete state | + +`If-Match` is a non-negative integer revision, either bare (`If-Match: 3`) or +quoted (`If-Match: "3"`). A wildcard, missing header, malformed value, or +negative value returns HTTP 428 with `plugin_state_precondition_required`. +The PUT body must be one valid JSON value. A revision mismatch returns HTTP +409 with: + +```json +{"error":"plugin_state_conflict","current_revision":4} +``` + +State entries contain `key`, `value`, `revision`, `writer_kind`, and +`updated_at`. State is shared by all approved releases of the same canvas +instance. Keep values small and do not store secrets. + +## Events and reconnect + +`GET ./_kandev/v1/events` opens a bounded Server-Sent Events stream. Send the +last received event ID in the `Last-Event-ID` request header when reconnecting. +Normal events have an ID in `generation:sequence` form and contain the full +event envelope in the SSE data field: + +```text +id: generation:12 +event: task.updated +data: {"id":"generation:12","generation":"generation","sequence":12,"type":"task.updated","scope":{"instance_id":"instance-id"},"data":{}} +``` + +The envelope fields are `id`, `generation`, `sequence`, `type`, `scope`, and +`data`. The scope contains `instance_id` and can contain `workspace_id`, +`task_id`, `session_id`, and `repository_id`. The event data is specific to +the event type. + +The server sends `: heartbeat` comments at the heartbeat interval. Streams +have bounded queues and a finite lifetime. A slow consumer can be closed. +When the requested cursor is invalid, expired, from another process +generation, or ahead of the current sequence, the server sends +`runtime.resync_required` with an empty SSE ID. Its data contains +`reason`, `generation`, and `reset: true`. On resync, clear the cursor, +refetch the authoritative data, and reconnect without a cursor. + +Event delivery does not replace HTTP reads. Refetch after an event that can +change visible data, and stop using the iframe immediately when its host +reports a lifecycle or authority change. + +## Actions and errors + +`POST ./_kandev/v1/actions/{key}` accepts a JSON body only when the manifest +declares the matching `action:{key}` permission. The current static canvas +host has no action implementation and returns HTTP 501 with +`plugin_action_unavailable`. An undeclared action returns HTTP 403 with +`plugin_permission_denied`. + +Other stable error codes are: + +| Status | Error | +| --- | --- | +| 400 | `invalid_request` | +| 401 | `runtime_token_stale` | +| 403 | `plugin_permission_denied` | +| 404 | `not_found` | +| 405 | `method_not_allowed` | +| 409 | `plugin_state_conflict` | +| 428 | `plugin_state_precondition_required` | +| 413 or 500 | `response_too_large` when a host limit is exceeded | +| 503 | `runtime_unavailable` | + +All request bodies are bounded. Treat unknown error codes as retryable only +when the operation is a read and the canvas is still mounted. diff --git a/apps/backend/internal/mcp/canvasskill/files/references/data-and-state.md b/apps/backend/internal/mcp/canvasskill/files/references/data-and-state.md new file mode 100644 index 0000000000..01feb667bd --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/references/data-and-state.md @@ -0,0 +1,13 @@ +# Data and instance state + +Kandev domain data is authoritative. Read it on demand and derive filters, +sorting, and display summaries in memory. Do not persist a second copy of +domain records in instance state. + +Use instance state only for small application values shared between the task's +canvas views, such as a selected tab, a filter, or a compact collapsed-group +map. Give each value a stable key and tolerate a missing key on first load. + +Read the current revision before a conditional update. If the host reports a +revision conflict, reload the value and let the user choose whether to apply +the change again. Keep draft input in memory until the user submits it. diff --git a/apps/backend/internal/mcp/canvasskill/files/references/events-and-recovery.md b/apps/backend/internal/mcp/canvasskill/files/references/events-and-recovery.md new file mode 100644 index 0000000000..fda62a2803 --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/references/events-and-recovery.md @@ -0,0 +1,13 @@ +# Events and recovery + +Subscribe to the relative event path supplied by the canvas contract when a +view needs live updates. Treat events as hints that invalidate a read; the +next read remains the source of truth. + +Reconnect with bounded backoff after a dropped connection. Avoid duplicate +subscriptions and stop timers when the page is hidden or disposed. Show a +stale-data or reconnecting indicator when the view cannot refresh. + +A failed publish does not activate a partial release. Keep the current draft +available, show safe validation diagnostics, and retry only after the source or +manifest is corrected. diff --git a/apps/backend/internal/mcp/canvasskill/files/references/manifest.md b/apps/backend/internal/mcp/canvasskill/files/references/manifest.md new file mode 100644 index 0000000000..bb7e6747f4 --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/references/manifest.md @@ -0,0 +1,67 @@ +# Canvas manifest + +Publish one `manifest.yaml` file at the root of the source directory returned +by `create_canvas_kandev`. Canvas packages use the current Kandev plugin +manifest schema, with `api_version: 2`. A static canvas package has no +`base_url` or managed backend endpoints. + +This is a complete minimal example. The example uses a nested entry to show +that files next to the entry document can use normal relative URLs. + +```yaml +id: example-canvas +api_version: 2 +version: 1.0.0 +display_name: Example canvas +description: A small task canvas. +author: Canvas author +ui: + web_apps: + - key: main + title: Example canvas + entry: ui/index.html + placements: + - task-canvas + - workspace-canvas + network_origins: + - https://api.example.com +capabilities: + api_read: + - tasks + - workflows + api_write: + - tasks + - messages + events: + - task.updated + state: true +``` + +The host parses YAML and calls the same manifest validator used for plugin +registration. Required identity fields are `id`, `api_version`, `version`, +`display_name`, `description`, and `author`. `api_version` must be `1` or `2`; +new canvases must use `2`. + +`ui.web_apps` contains one or more applications. Each application requires a +lowercase `key`, a `title`, a clean package-relative `entry`, and at least one +placement. A placement is `task-canvas` or `workspace-canvas`. The package may +contain `ui/index.html`, `ui/app.js`, and `ui/app.css`; references such as +`./app.js` resolve beside `ui/index.html`. Do not use absolute paths, `..`, +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 +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. + +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. diff --git a/apps/backend/internal/mcp/canvasskill/files/references/security.md b/apps/backend/internal/mcp/canvasskill/files/references/security.md new file mode 100644 index 0000000000..115d60c9b9 --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/references/security.md @@ -0,0 +1,14 @@ +# Security rules + +Canvas source runs in an opaque origin. Never depend on browser storage, +service workers, origin-wide cookies, or a stable origin identifier. + +Keep source and assets inside the assigned canvas source directory. Use +workspace-relative paths only. Do not create symlinks, device files, sockets, +or parent-directory references. Do not attempt to read another canvas, +another task, or the host filesystem. + +Escape text through the framework or DOM APIs. Do not build HTML from untrusted +domain values. Never put access tokens, credentials, or private task data in +source, URLs, or diagnostics. Request only the permissions needed by the +application and handle denial without exposing sensitive details. diff --git a/apps/backend/internal/mcp/canvasskill/files/references/ui-patterns.md b/apps/backend/internal/mcp/canvasskill/files/references/ui-patterns.md new file mode 100644 index 0000000000..9e2377c18f --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/references/ui-patterns.md @@ -0,0 +1,26 @@ +# UI patterns + +Design the first useful view for a narrow mobile viewport and expand it for +desktop. Use a clear page title, one primary action, readable empty states, +and a compact status area. + +Use semantic headings and controls. Every icon-only control needs an accessible +name. Keep focus visible and preserve focus after a dialog closes. Support +keyboard activation for actions that also have a pointer gesture. + +For asynchronous work, show loading, success, error, and retry states. Avoid +blocking the whole page for a small update. Confirm destructive operations and +state what will change before the user accepts. + +## Appearance + +Use the semantic CSS variables from the host appearance message when they are +available: `--background`, `--foreground`, `--card`, `--card-foreground`, +`--muted`, `--muted-foreground`, `--border`, `--primary`, +`--primary-foreground`, `--accent`, `--accent-foreground`, `--destructive`, +`--destructive-foreground`, and `--ring`. + +Define usable light and dark fallback values in the document before the first +message. Accept only bounded color values from the parent window. Do not use +the appearance message for identity, permissions, data, navigation, or +actions. diff --git a/apps/backend/internal/mcp/canvasskill/files/scaffold/appearance.js b/apps/backend/internal/mcp/canvasskill/files/scaffold/appearance.js new file mode 100644 index 0000000000..3524673375 --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/scaffold/appearance.js @@ -0,0 +1,113 @@ +(() => { + "use strict"; + + const type = "kandev.web_app.appearance"; + const version = 1; + const keys = [ + "background", + "foreground", + "card", + "cardForeground", + "muted", + "mutedForeground", + "border", + "primary", + "primaryForeground", + "accent", + "accentForeground", + "destructive", + "destructiveForeground", + "ring", + ]; + const variables = { + background: "--background", + foreground: "--foreground", + card: "--card", + cardForeground: "--card-foreground", + muted: "--muted", + mutedForeground: "--muted-foreground", + border: "--border", + primary: "--primary", + primaryForeground: "--primary-foreground", + accent: "--accent", + accentForeground: "--accent-foreground", + destructive: "--destructive", + destructiveForeground: "--destructive-foreground", + ring: "--ring", + }; + const fallbacks = { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.145 0 0)", + card: "oklch(1 0 0)", + cardForeground: "oklch(0.145 0 0)", + muted: "oklch(0.97 0 0)", + mutedForeground: "oklch(0.556 0 0)", + border: "oklch(0.922 0 0)", + primary: "oklch(0.51 0.23 277)", + primaryForeground: "oklch(0.96 0.02 272)", + accent: "oklch(0.68 0.18 276)", + accentForeground: "oklch(0.98 0 0)", + destructive: "oklch(0.58 0.22 27)", + destructiveForeground: "oklch(0.96 0.02 27)", + ring: "oklch(0.708 0 0)", + }, + dark: { + background: "oklch(0.145 0 0)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.205 0 0)", + cardForeground: "oklch(0.985 0 0)", + muted: "oklch(0.269 0 0)", + mutedForeground: "oklch(0.708 0 0)", + border: "oklch(1 0 0 / 10%)", + primary: "oklch(0.65 0.2 277)", + primaryForeground: "oklch(0.145 0 0)", + accent: "oklch(0.45 0.16 276)", + accentForeground: "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + destructiveForeground: "oklch(0.985 0 0)", + ring: "oklch(0.556 0 0)", + }, + }; + const colorPattern = /^[#a-z0-9(),.%/\s.+-]+$/i; + const colorFunctionPattern = /^(?:oklch|oklab|rgb|rgba|hsl|hsla|hwb|lab|lch|color)\([^{};]+\)$/i; + + function isColor(value) { + if (typeof value !== "string") return false; + const color = value.trim(); + if (color.length === 0 || color.length > 128 || !colorPattern.test(color)) return false; + return ( + /^#[0-9a-f]{3,8}$/i.test(color) || + colorFunctionPattern.test(color) || + color === "transparent" || + color === "currentcolor" + ); + } + + function isAppearanceMessage(value) { + if (!value || value.type !== type || value.version !== version) return false; + if (value.mode !== "light" && value.mode !== "dark") return false; + if (!value.tokens || typeof value.tokens !== "object") return false; + const tokenKeys = Object.keys(value.tokens); + return ( + tokenKeys.length === keys.length && + keys.every((key) => tokenKeys.includes(key) && isColor(value.tokens[key])) + ); + } + + function applyAppearance(value) { + keys.forEach((key) => { + document.documentElement.style.setProperty(variables[key], value.tokens[key]); + }); + document.documentElement.style.colorScheme = value.mode; + document.documentElement.dataset.kandevAppearance = value.mode; + } + + const initialMode = document.documentElement.dataset.kandevAppearance === "dark" ? "dark" : "light"; + applyAppearance({ tokens: fallbacks[initialMode], mode: initialMode }); + window.addEventListener("message", (event) => { + if (event.source === window.parent && isAppearanceMessage(event.data)) { + applyAppearance(event.data); + } + }); +})(); diff --git a/apps/backend/internal/mcp/canvasskill/files/scaffold/index.html b/apps/backend/internal/mcp/canvasskill/files/scaffold/index.html new file mode 100644 index 0000000000..d1b1eb3936 --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/scaffold/index.html @@ -0,0 +1,23 @@ + + + + + + Canvas + + + + +
+
+

Kandev canvas

+

Your canvas

+
+
+

Replace this scaffold with the task view.

+

Loading canvas context...

+
+
+ + + diff --git a/apps/backend/internal/mcp/canvasskill/files/scaffold/script.js b/apps/backend/internal/mcp/canvasskill/files/scaffold/script.js new file mode 100644 index 0000000000..23f7a5923b --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/scaffold/script.js @@ -0,0 +1,18 @@ +(() => { + "use strict"; + + const contextTarget = document.querySelector("[data-canvas-context]"); + if (!contextTarget) return; + + fetch("./_kandev/v1/context") + .then((response) => { + if (!response.ok) throw new Error("context unavailable"); + return response.json(); + }) + .then((context) => { + contextTarget.textContent = context.task_id ? `Task ${context.task_id}` : "Workspace canvas"; + }) + .catch(() => { + contextTarget.textContent = "Canvas context unavailable"; + }); +})(); diff --git a/apps/backend/internal/mcp/canvasskill/files/scaffold/styles.css b/apps/backend/internal/mcp/canvasskill/files/scaffold/styles.css new file mode 100644 index 0000000000..49ffc358dd --- /dev/null +++ b/apps/backend/internal/mcp/canvasskill/files/scaffold/styles.css @@ -0,0 +1,61 @@ +:root { + color-scheme: light; + font-family: system-ui, sans-serif; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --border: oklch(0.922 0 0); + --primary: oklch(0.51 0.23 277); + --primary-foreground: oklch(0.96 0.02 272); + --accent: oklch(0.68 0.18 276); + --accent-foreground: oklch(0.98 0 0); + --destructive: oklch(0.58 0.22 27); + --destructive-foreground: oklch(0.96 0.02 27); + --ring: oklch(0.708 0 0); + background: var(--background); + color: var(--foreground); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--background); + color: var(--foreground); +} + +.canvas-shell { + width: min(100% - 2rem, 72rem); + margin: 0 auto; + padding: 2rem 0; +} + +.canvas-header { + margin-bottom: 1.5rem; +} + +.eyebrow { + margin: 0 0 0.5rem; + color: var(--primary); + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: clamp(1.75rem, 4vw, 3rem); +} + +.canvas-card { + border: 1px solid var(--border); + border-radius: 0.75rem; + padding: 1rem; + background: var(--card); + color: var(--card-foreground); +} diff --git a/apps/backend/internal/mcp/handlers/canvas.go b/apps/backend/internal/mcp/handlers/canvas.go new file mode 100644 index 0000000000..52bd52bd79 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/canvas.go @@ -0,0 +1,337 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// CanvasAgentContext is derived from the stream that carried the MCP call. +// None of these fields are accepted from a canvas tool payload. +type CanvasAgentContext struct { + ExecutionID string + TaskID string + SessionID string +} + +type CanvasListRequest struct { + Agent CanvasAgentContext +} + +type CanvasReadSkillRequest struct { + Agent CanvasAgentContext + Path string +} + +type CanvasCreateRequest struct { + Agent CanvasAgentContext + Title string + Summary string +} + +type CanvasGetRequest struct { + Agent CanvasAgentContext + CanvasID string +} + +type CanvasPublishRequest struct { + Agent CanvasAgentContext + CanvasID string + SourcePath string +} + +type CanvasGetStateRequest struct { + Agent CanvasAgentContext + CanvasID string + Key string +} + +type CanvasSetStateRequest struct { + Agent CanvasAgentContext + CanvasID string + Key string + Value json.RawMessage + ExpectedRevision *int64 +} + +// CanvasAuthoringService is the narrow backend boundary for task-local canvas +// operations. Implementations must resolve workspace, owner, edit target, +// source root, release validation, and state authorization from Agent and +// server-side records. The MCP payload is never an authorization boundary. +type CanvasAuthoringService interface { + ListCanvases(context.Context, CanvasListRequest) (any, error) + ReadCanvasAuthoringSkill(context.Context, CanvasReadSkillRequest) (any, error) + CreateCanvas(context.Context, CanvasCreateRequest) (any, error) + GetCanvas(context.Context, CanvasGetRequest) (any, error) + PublishCanvas(context.Context, CanvasPublishRequest) (any, error) + GetCanvasState(context.Context, CanvasGetStateRequest) (any, error) + SetCanvasState(context.Context, CanvasSetStateRequest) (any, error) +} + +// CanvasAuthoringError lets the task-06 canvas service return a stable safe +// error code without exposing implementation details through the MCP bridge. +type CanvasAuthoringError struct { + Code string + Message string + Details map[string]interface{} +} + +func (e *CanvasAuthoringError) Error() string { + if e == nil { + return "canvas operation failed" + } + return e.Message +} + +func (h *Handlers) registerCanvasHandlers(d mcpActionRegistrar) { + if h.canvasAuthoringSvc == nil { + return + } + d.RegisterFunc(ws.ActionMCPListCanvases, h.handleListCanvases) + d.RegisterFunc(ws.ActionMCPReadCanvasAuthoringSkill, h.handleReadCanvasAuthoringSkill) + d.RegisterFunc(ws.ActionMCPCreateCanvas, h.handleCreateCanvas) + d.RegisterFunc(ws.ActionMCPGetCanvas, h.handleGetCanvas) + d.RegisterFunc(ws.ActionMCPPublishCanvas, h.handlePublishCanvas) + d.RegisterFunc(ws.ActionMCPGetCanvasState, h.handleGetCanvasState) + d.RegisterFunc(ws.ActionMCPSetCanvasState, h.handleSetCanvasState) +} + +func (h *Handlers) handleListCanvases(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + _, agent, response, err := h.canvasRequestContext(ctx, msg) + if response != nil || err != nil { + return response, err + } + result, err := h.canvasAuthoringSvc.ListCanvases(ctx, CanvasListRequest{Agent: agent}) + return h.canvasServiceResponse(msg, result, err) +} + +func (h *Handlers) handleReadCanvasAuthoringSkill(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + fields, agent, response, err := h.canvasRequestContext(ctx, msg) + if response != nil || err != nil { + return response, err + } + path, err := optionalCanvasString(fields, "path") + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + result, err := h.canvasAuthoringSvc.ReadCanvasAuthoringSkill(ctx, CanvasReadSkillRequest{Agent: agent, Path: path}) + return h.canvasServiceResponse(msg, result, err) +} + +func (h *Handlers) handleCreateCanvas(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + fields, agent, response, err := h.canvasRequestContext(ctx, msg) + if response != nil || err != nil { + return response, err + } + title, err := requiredCanvasString(fields, "title") + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + summary, err := requiredCanvasString(fields, "summary") + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + result, err := h.canvasAuthoringSvc.CreateCanvas(ctx, CanvasCreateRequest{ + Agent: agent, Title: title, Summary: summary, + }) + return h.canvasServiceResponse(msg, result, err) +} + +func (h *Handlers) handleGetCanvas(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + fields, agent, response, err := h.canvasRequestContext(ctx, msg) + if response != nil || err != nil { + return response, err + } + canvasID, err := requiredCanvasString(fields, "canvas_id") + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + result, err := h.canvasAuthoringSvc.GetCanvas(ctx, CanvasGetRequest{Agent: agent, CanvasID: canvasID}) + return h.canvasServiceResponse(msg, result, err) +} + +func (h *Handlers) handlePublishCanvas(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + fields, agent, response, err := h.canvasRequestContext(ctx, msg) + if response != nil || err != nil { + return response, err + } + canvasID, err := requiredCanvasString(fields, "canvas_id") + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + sourcePath, err := requiredCanvasString(fields, "source_path") + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + result, err := h.canvasAuthoringSvc.PublishCanvas(ctx, CanvasPublishRequest{ + Agent: agent, CanvasID: canvasID, SourcePath: sourcePath, + }) + return h.canvasServiceResponse(msg, result, err) +} + +func (h *Handlers) handleGetCanvasState(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + fields, agent, response, err := h.canvasRequestContext(ctx, msg) + if response != nil || err != nil { + return response, err + } + canvasID, err := requiredCanvasString(fields, "canvas_id") + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + key, err := optionalCanvasString(fields, "key") + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + result, err := h.canvasAuthoringSvc.GetCanvasState(ctx, CanvasGetStateRequest{ + Agent: agent, CanvasID: canvasID, Key: key, + }) + return h.canvasServiceResponse(msg, result, err) +} + +func (h *Handlers) handleSetCanvasState(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + fields, agent, response, err := h.canvasRequestContext(ctx, msg) + if response != nil || err != nil { + return response, err + } + canvasID, err := requiredCanvasString(fields, "canvas_id") + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + key, err := requiredCanvasString(fields, "key") + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + value, ok := fields["value"] + if !ok { + return canvasBadRequest(msg, "value is required") + } + revision, err := optionalCanvasRevision(fields) + if err != nil { + return canvasBadRequest(msg, err.Error()) + } + result, err := h.canvasAuthoringSvc.SetCanvasState(ctx, CanvasSetStateRequest{ + Agent: agent, CanvasID: canvasID, Key: key, Value: value, ExpectedRevision: revision, + }) + return h.canvasServiceResponse(msg, result, err) +} + +func (h *Handlers) canvasRequestContext(ctx context.Context, msg *ws.Message) (map[string]json.RawMessage, CanvasAgentContext, *ws.Message, error) { + fields, response, err := canvasPayloadFields(msg) + if response != nil || err != nil { + return nil, CanvasAgentContext{}, response, err + } + if err := rejectCanvasIdentityFields(fields); err != nil { + response, responseErr := canvasBadRequest(msg, err.Error()) + return nil, CanvasAgentContext{}, response, responseErr + } + agent, response, err := h.canvasAgentContext(ctx, msg) + return fields, agent, response, err +} + +func canvasPayloadFields(msg *ws.Message) (map[string]json.RawMessage, *ws.Message, error) { + fields := make(map[string]json.RawMessage) + if len(msg.Payload) == 0 || string(msg.Payload) == "null" { + return fields, nil, nil + } + if err := json.Unmarshal(msg.Payload, &fields); err != nil { + response, responseErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil) + return nil, response, responseErr + } + if fields == nil { + fields = make(map[string]json.RawMessage) + } + return fields, nil, nil +} + +func rejectCanvasIdentityFields(fields map[string]json.RawMessage) error { + for _, field := range []string{"task_id", "session_id", "workspace_id", "user_id", "owner_id", "execution_id"} { + if _, present := fields[field]; present { + return fmt.Errorf("%s must be derived from the running execution", field) + } + } + return nil +} + +func requiredCanvasString(fields map[string]json.RawMessage, name string) (string, error) { + value, err := optionalCanvasString(fields, name) + if err != nil { + return "", err + } + if value == "" { + return "", fmt.Errorf("%s is required", name) + } + return value, nil +} + +func optionalCanvasString(fields map[string]json.RawMessage, name string) (string, error) { + raw, ok := fields[name] + if !ok { + return "", nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("%s must be a string", name) + } + return value, nil +} + +func optionalCanvasRevision(fields map[string]json.RawMessage) (*int64, error) { + raw, ok := fields["expected_revision"] + if !ok || string(raw) == "null" { + return nil, nil + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value interface{} + if err := decoder.Decode(&value); err != nil { + return nil, errors.New("expected_revision must be a non-negative integer") + } + number, ok := value.(json.Number) + if !ok { + return nil, errors.New("expected_revision must be a non-negative integer") + } + revision, err := number.Int64() + if err != nil || revision < 0 { + return nil, errors.New("expected_revision must be a non-negative integer") + } + return &revision, nil +} + +func (h *Handlers) canvasAgentContext(ctx context.Context, msg *ws.Message) (CanvasAgentContext, *ws.Message, error) { + execution, ok := streams.MCPExecutionContextFromContext(ctx) + if !ok { + response, err := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeUnauthorized, + "canvas authoring requires a bound task execution", nil) + return CanvasAgentContext{}, response, err + } + return CanvasAgentContext{ + ExecutionID: execution.ExecutionID, + TaskID: execution.TaskID, + SessionID: execution.SessionID, + }, nil, nil +} + +func canvasBadRequest(msg *ws.Message, message string) (*ws.Message, error) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, message, nil) +} + +func (h *Handlers) canvasServiceResponse(msg *ws.Message, result any, operationErr error) (*ws.Message, error) { + if operationErr != nil { + var canvasErr *CanvasAuthoringError + if errors.As(operationErr, &canvasErr) && canvasErr != nil { + code := canvasErr.Code + if code == "" { + code = ws.ErrorCodeInternalError + } + return ws.NewError(msg.ID, msg.Action, code, canvasErr.Message, canvasErr.Details) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, + "canvas operation failed: "+operationErr.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, result) +} diff --git a/apps/backend/internal/mcp/handlers/canvas_test.go b/apps/backend/internal/mcp/handlers/canvas_test.go new file mode 100644 index 0000000000..8d780e4802 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/canvas_test.go @@ -0,0 +1,135 @@ +package handlers + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/require" +) + +type canvasAuthoringServiceFake struct { + create CanvasCreateRequest + read []CanvasReadSkillRequest + called bool +} + +func (f *canvasAuthoringServiceFake) ListCanvases(context.Context, CanvasListRequest) (any, error) { + return map[string]any{"canvases": []any{}}, nil +} + +func (f *canvasAuthoringServiceFake) ReadCanvasAuthoringSkill(_ context.Context, request CanvasReadSkillRequest) (any, error) { + f.read = append(f.read, request) + return map[string]any{"path": "SKILL.md"}, nil +} + +func (f *canvasAuthoringServiceFake) CreateCanvas(_ context.Context, request CanvasCreateRequest) (any, error) { + f.called = true + f.create = request + return map[string]any{"canvas_id": "canvas-1"}, nil +} + +func (f *canvasAuthoringServiceFake) GetCanvas(context.Context, CanvasGetRequest) (any, error) { + return map[string]any{"canvas_id": "canvas-1"}, nil +} + +func (f *canvasAuthoringServiceFake) PublishCanvas(context.Context, CanvasPublishRequest) (any, error) { + return map[string]any{"published": true}, nil +} + +func (f *canvasAuthoringServiceFake) GetCanvasState(context.Context, CanvasGetStateRequest) (any, error) { + return map[string]any{"revision": 1}, nil +} + +func (f *canvasAuthoringServiceFake) SetCanvasState(context.Context, CanvasSetStateRequest) (any, error) { + return map[string]any{"revision": 2}, nil +} + +func newCanvasHandlersForTest(t *testing.T, service CanvasAuthoringService) (*Handlers, *ws.Dispatcher) { + t.Helper() + h := NewHandlers(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, testLogger(t)) + h.SetCanvasAuthoringService(service) + d := ws.NewDispatcher() + h.RegisterHandlers(d) + return h, d +} + +func canvasExecutionContext() context.Context { + return streams.WithMCPExecutionContext(context.Background(), streams.MCPExecutionContext{ + ExecutionID: "execution-1", + TaskID: "task-1", + SessionID: "session-1", + }) +} + +func TestCanvasHandlers_AreAbsentWithoutService(t *testing.T) { + _, dispatcher := newCanvasHandlersForTest(t, nil) + + require.False(t, dispatcher.HasHandler(ws.ActionMCPCreateCanvas)) + require.False(t, dispatcher.HasHandler(ws.ActionMCPPublishCanvas)) +} + +func TestCanvasHandlers_UseTrustedExecutionContext(t *testing.T) { + fake := &canvasAuthoringServiceFake{} + _, dispatcher := newCanvasHandlersForTest(t, fake) + message := makeWSMessage(t, ws.ActionMCPCreateCanvas, map[string]any{ + "title": "Release dashboard", + "summary": "Shows deployment health.", + }) + + response, err := dispatcher.Dispatch(canvasExecutionContext(), message) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, response.Type) + require.True(t, fake.called) + require.Equal(t, "task-1", fake.create.Agent.TaskID) + require.Equal(t, "session-1", fake.create.Agent.SessionID) + require.Equal(t, "execution-1", fake.create.Agent.ExecutionID) + require.Equal(t, "Release dashboard", fake.create.Title) + require.Equal(t, "Shows deployment health.", fake.create.Summary) +} + +func TestCanvasHandlers_RejectCallerSuppliedExecutionIdentity(t *testing.T) { + fake := &canvasAuthoringServiceFake{} + _, dispatcher := newCanvasHandlersForTest(t, fake) + message := makeWSMessage(t, ws.ActionMCPCreateCanvas, map[string]any{ + "title": "Release dashboard", + "summary": "Shows deployment health.", + "task_id": "task-foreign", + "session_id": "session-foreign", + }) + + response, err := dispatcher.Dispatch(canvasExecutionContext(), message) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeError, response.Type) + var payload ws.ErrorPayload + require.NoError(t, json.Unmarshal(response.Payload, &payload)) + require.Equal(t, ws.ErrorCodeBadRequest, payload.Code) + require.False(t, fake.called) +} + +func TestCanvasHandlers_RequireBoundExecution(t *testing.T) { + fake := &canvasAuthoringServiceFake{} + _, dispatcher := newCanvasHandlersForTest(t, fake) + message := makeWSMessage(t, ws.ActionMCPListCanvases, map[string]any{}) + + response, err := dispatcher.Dispatch(context.Background(), message) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeError, response.Type) + var payload ws.ErrorPayload + require.NoError(t, json.Unmarshal(response.Payload, &payload)) + require.Equal(t, ws.ErrorCodeUnauthorized, payload.Code) +} + +func TestCanvasHandlers_ReadAuthoringSkillUsesOnePathlessCoreCall(t *testing.T) { + fake := &canvasAuthoringServiceFake{} + _, dispatcher := newCanvasHandlersForTest(t, fake) + message := makeWSMessage(t, ws.ActionMCPReadCanvasAuthoringSkill, map[string]any{}) + + response, err := dispatcher.Dispatch(canvasExecutionContext(), message) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, response.Type) + require.Len(t, fake.read, 1) + require.Empty(t, fake.read[0].Path) +} diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 36f683976e..86070048a0 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -298,9 +298,10 @@ type Handlers struct { // Native code review (optional, set via SetReviewService / // SetReviewRunner). Without them the review actions are simply not // registered — see registerReviewHandlers. - reviewService *service.ReviewService - reviewRunner ReviewRunner - pluginSvc *plugins.Service + reviewService *service.ReviewService + reviewRunner ReviewRunner + pluginSvc *plugins.Service + canvasAuthoringSvc CanvasAuthoringService // Optional task-bound GitHub PR automation controls. taskPRAutomation TaskPRAutomationService @@ -430,6 +431,13 @@ func (h *Handlers) SetPluginService(svc *plugins.Service) { h.pluginSvc = svc } +// SetCanvasAuthoringService wires the feature-gated, task-scoped canvas +// authoring boundary. Leave it unset when canvas support is disabled so raw WS +// canvas actions are not registered either. +func (h *Handlers) SetCanvasAuthoringService(svc CanvasAuthoringService) { + h.canvasAuthoringSvc = svc +} + // RegisterHandlers registers all MCP handlers with the dispatcher. func (h *Handlers) RegisterHandlers(dispatcher *ws.Dispatcher) { d := &guardedMCPDispatcher{Dispatcher: dispatcher, handlers: h} @@ -447,6 +455,7 @@ func (h *Handlers) registerTaskModeHandlers(d *guardedMCPDispatcher) { h.registerTaskPlanHandlers(d) h.registerTaskQuestionHandlers(d) h.registerReviewHandlers(d) + h.registerCanvasHandlers(d) } func (h *Handlers) registerTaskReadHandlers(d *guardedMCPDispatcher) { diff --git a/apps/backend/internal/mcp/profile/profile.go b/apps/backend/internal/mcp/profile/profile.go index 5f15593984..707e57cda2 100644 --- a/apps/backend/internal/mcp/profile/profile.go +++ b/apps/backend/internal/mcp/profile/profile.go @@ -27,6 +27,7 @@ const ( CapabilityTaskTitle Capability = "task-title" CapabilityGitHubPR Capability = "github-pr" CapabilityGitLabMR Capability = "gitlab-mr" + CapabilityCanvas Capability = "canvas-authoring" ) // Context is the complete, backend-resolved MCP profile for one agent diff --git a/apps/backend/internal/mcp/server/canvas_tools.go b/apps/backend/internal/mcp/server/canvas_tools.go new file mode 100644 index 0000000000..bc3cd6916e --- /dev/null +++ b/apps/backend/internal/mcp/server/canvas_tools.go @@ -0,0 +1,280 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "math" + + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + canvasIDArg = "canvas_id" + canvasTitleArg = "title" + canvasSummaryArg = "summary" + canvasSourcePathArg = "source_path" + canvasSkillPathArg = "path" + canvasStateKeyArg = "key" + canvasStateValueArg = "value" + canvasExpectedRevisionArg = "expected_revision" +) + +// registerCanvasTools registers the complete task-local canvas surface. The +// profile registry calls this only for a backend-issued Kanban profile carrying +// CapabilityCanvas. The handlers intentionally do not send task, session, or +// workspace IDs from this MCP argument map; the backend derives those from the +// trusted execution context on the stream. +func (s *Server) registerCanvasTools() { + s.registerCanvasDiscoveryTools() + s.registerCanvasCreateTool() + s.registerCanvasGetTool() + s.registerCanvasPublishTool() + s.registerCanvasStateTools() +} + +func (s *Server) registerCanvasDiscoveryTools() { + s.mcpServer.AddTool( + mcp.NewToolWithRawSchema( + "list_canvases_kandev", + "List canvases belonging to the current task.", + json.RawMessage(`{"type":"object","properties":{}}`), + ), + s.wrapHandler("list_canvases_kandev", s.listCanvasesHandler()), + ) + s.mcpServer.AddTool( + mcp.NewTool( + "read_canvas_authoring_skill_kandev", + mcp.WithDescription("Read the complete versioned Kandev canvas-authoring core bundle without path, or one allowlisted supporting file with a relative path. The skill is read from Kandev's system inventory, not the task workspace."), + mcp.WithString(canvasSkillPathArg, mcp.Description("Optional relative inventory path. Omit it to receive the complete core bundle.")), + ), + s.wrapHandler("read_canvas_authoring_skill_kandev", s.readCanvasAuthoringSkillHandler()), + ) +} + +func (s *Server) registerCanvasCreateTool() { + s.mcpServer.AddTool( + mcp.NewTool( + "create_canvas_kandev", + mcp.WithDescription("Create an inactive draft canvas for the current task and return its assigned source directory, generated scaffold files, manifest scaffold, skill identity, and permission ceiling."), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString(canvasTitleArg, mcp.Required(), mcp.Description("A concise name for the canvas.")), + mcp.WithString(canvasSummaryArg, mcp.Required(), mcp.Description("A short summary of what the canvas shows or does.")), + ), + s.wrapHandler("create_canvas_kandev", s.createCanvasHandler()), + ) +} + +func (s *Server) registerCanvasGetTool() { + s.mcpServer.AddTool( + mcp.NewTool( + "get_canvas_kandev", + mcp.WithDescription("Inspect one canvas assigned to the current task, including its active release and authoring metadata."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString(canvasIDArg, mcp.Required(), mcp.Description("Canvas ID returned by create_canvas_kandev or list_canvases_kandev.")), + ), + s.wrapHandler("get_canvas_kandev", s.getCanvasHandler()), + ) +} + +func (s *Server) registerCanvasPublishTool() { + s.mcpServer.AddTool( + mcp.NewTool( + "publish_canvas_kandev", + mcp.WithDescription("Validate and publish source from the assigned canvas source directory. A rejected publish leaves the current active release unchanged."), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString(canvasIDArg, mcp.Required(), mcp.Description("Canvas ID to publish.")), + mcp.WithString(canvasSourcePathArg, mcp.Required(), mcp.Description("Workspace-relative source directory returned by create_canvas_kandev.")), + ), + s.wrapHandler("publish_canvas_kandev", s.publishCanvasHandler()), + ) +} + +func (s *Server) registerCanvasStateTools() { + s.mcpServer.AddTool( + mcp.NewTool( + "get_canvas_state_kandev", + mcp.WithDescription("Read shared application state for a canvas. Omit key to return the current state object."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString(canvasIDArg, mcp.Required(), mcp.Description("Canvas ID.")), + mcp.WithString(canvasStateKeyArg, mcp.Description("Optional state key.")), + ), + s.wrapHandler("get_canvas_state_kandev", s.getCanvasStateHandler()), + ) + s.mcpServer.AddTool( + mcp.NewTool( + "set_canvas_state_kandev", + mcp.WithDescription("Set one shared application-state value for a canvas. Use expected_revision to reject stale writes."), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString(canvasIDArg, mcp.Required(), mcp.Description("Canvas ID.")), + mcp.WithString(canvasStateKeyArg, mcp.Required(), mcp.Description("State key.")), + mcp.WithAny(canvasStateValueArg, mcp.Required(), mcp.Description("JSON value to store.")), + mcp.WithNumber(canvasExpectedRevisionArg, mcp.Description("Optional current revision required for this write.")), + ), + s.wrapHandler("set_canvas_state_kandev", s.setCanvasStateHandler()), + ) +} + +func (s *Server) listCanvasesHandler() server.ToolHandlerFunc { + return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return s.requestCanvasTool(ctx, ws.ActionMCPListCanvases, nil) + } +} + +func (s *Server) readCanvasAuthoringSkillHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + payload := map[string]interface{}{} + if path := req.GetString(canvasSkillPathArg, ""); path != "" { + payload[canvasSkillPathArg] = path + } + return s.requestCanvasTool(ctx, ws.ActionMCPReadCanvasAuthoringSkill, payload) + } +} + +func (s *Server) createCanvasHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + title, err := req.RequireString(canvasTitleArg) + if err != nil || title == "" { + return mcp.NewToolResultError("title is required"), nil + } + summary, err := req.RequireString(canvasSummaryArg) + if err != nil || summary == "" { + return mcp.NewToolResultError("summary is required"), nil + } + return s.requestCanvasTool(ctx, ws.ActionMCPCreateCanvas, map[string]interface{}{ + canvasTitleArg: title, canvasSummaryArg: summary, + }) + } +} + +func (s *Server) getCanvasHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + canvasID, err := req.RequireString(canvasIDArg) + if err != nil || canvasID == "" { + return mcp.NewToolResultError("canvas_id is required"), nil + } + return s.requestCanvasTool(ctx, ws.ActionMCPGetCanvas, map[string]interface{}{canvasIDArg: canvasID}) + } +} + +func (s *Server) publishCanvasHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + canvasID, err := req.RequireString(canvasIDArg) + if err != nil || canvasID == "" { + return mcp.NewToolResultError("canvas_id is required"), nil + } + sourcePath, err := req.RequireString(canvasSourcePathArg) + if err != nil || sourcePath == "" { + return mcp.NewToolResultError("source_path is required"), nil + } + return s.requestCanvasTool(ctx, ws.ActionMCPPublishCanvas, map[string]interface{}{ + canvasIDArg: canvasID, canvasSourcePathArg: sourcePath, + }) + } +} + +func (s *Server) getCanvasStateHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + canvasID, err := req.RequireString(canvasIDArg) + if err != nil || canvasID == "" { + return mcp.NewToolResultError("canvas_id is required"), nil + } + payload := map[string]interface{}{canvasIDArg: canvasID} + if key := req.GetString(canvasStateKeyArg, ""); key != "" { + payload[canvasStateKeyArg] = key + } + return s.requestCanvasTool(ctx, ws.ActionMCPGetCanvasState, payload) + } +} + +func (s *Server) setCanvasStateHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + canvasID, err := req.RequireString(canvasIDArg) + if err != nil || canvasID == "" { + return mcp.NewToolResultError("canvas_id is required"), nil + } + key, err := req.RequireString(canvasStateKeyArg) + if err != nil || key == "" { + return mcp.NewToolResultError("key is required"), nil + } + value, ok := req.GetArguments()[canvasStateValueArg] + if !ok { + return mcp.NewToolResultError("value is required"), nil + } + payload := map[string]interface{}{ + canvasIDArg: canvasID, canvasStateKeyArg: key, canvasStateValueArg: value, + } + if revision, present, err := canvasExpectedRevision(req.GetArguments()); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } else if present { + payload[canvasExpectedRevisionArg] = revision + } + return s.requestCanvasTool(ctx, ws.ActionMCPSetCanvasState, payload) + } +} + +func (s *Server) requestCanvasTool(ctx context.Context, action string, payload interface{}) (*mcp.CallToolResult, error) { + var result interface{} + if err := s.backend.RequestPayload(ctx, action, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to encode canvas response: %v", err)), nil + } + return mcp.NewToolResultText(string(data)), nil +} + +func canvasExpectedRevision(arguments map[string]interface{}) (int64, bool, error) { + value, ok := arguments[canvasExpectedRevisionArg] + if !ok || value == nil { + return 0, false, nil + } + var revision float64 + switch typed := value.(type) { + case float64: + revision = typed + case float32: + revision = float64(typed) + case int: + revision = float64(typed) + case int64: + revision = float64(typed) + case json.Number: + parsed, err := typed.Float64() + if err != nil { + return 0, false, errorsForCanvasRevision(value) + } + revision = parsed + default: + return 0, false, errorsForCanvasRevision(value) + } + // A float64 cannot represent every int64 exactly. Reject values at the + // upper boundary instead of allowing a platform-dependent conversion to + // wrap into a negative revision. + if revision < 0 || revision >= float64(math.MaxInt64) || math.Trunc(revision) != revision || math.IsInf(revision, 0) || math.IsNaN(revision) { + return 0, false, errorsForCanvasRevision(value) + } + return int64(revision), true, nil +} + +func errorsForCanvasRevision(value interface{}) error { + return fmt.Errorf("%s must be a non-negative integer, got %T", canvasExpectedRevisionArg, value) +} diff --git a/apps/backend/internal/mcp/server/canvas_tools_test.go b/apps/backend/internal/mcp/server/canvas_tools_test.go new file mode 100644 index 0000000000..4b769bd08b --- /dev/null +++ b/apps/backend/internal/mcp/server/canvas_tools_test.go @@ -0,0 +1,113 @@ +package mcp + +import ( + "testing" + + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newCanvasTestServer(t *testing.T, backend BackendClient) *Server { + t.Helper() + return NewWithProfile( + backend, + "session-1", + "task-1", + 10005, + newTestLogger(t), + "", + false, + mcpprofile.New( + mcpprofile.SurfaceKanbanTask, + []mcpprofile.Capability{mcpprofile.CapabilityCanvas}, + nil, + ), + ) +} + +func TestCanvasTools_AreNotRegisteredWithoutCapability(t *testing.T) { + s := newTestServer(t, &testBackend{}) + + for _, name := range []string{ + "list_canvases_kandev", + "read_canvas_authoring_skill_kandev", + "create_canvas_kandev", + "get_canvas_kandev", + "publish_canvas_kandev", + "get_canvas_state_kandev", + "set_canvas_state_kandev", + } { + assert.NotContains(t, s.mcpServer.ListTools(), name) + } +} + +func TestCanvasTools_RegisterAsOneCapabilityGroup(t *testing.T) { + s := newCanvasTestServer(t, &testBackend{}) + + for _, name := range []string{ + "list_canvases_kandev", + "read_canvas_authoring_skill_kandev", + "create_canvas_kandev", + "get_canvas_kandev", + "publish_canvas_kandev", + "get_canvas_state_kandev", + "set_canvas_state_kandev", + } { + assert.Contains(t, s.mcpServer.ListTools(), name) + } +} + +func TestCanvasTools_CreateForwardsOnlyAuthoringInput(t *testing.T) { + backend := &testBackend{response: map[string]interface{}{ + "canvas_id": "canvas-1", + "source_root": ".kandev/canvases/canvas-1", + "skill_version": "v1", + }} + s := newCanvasTestServer(t, backend) + + result := callTool(t, s, "create_canvas_kandev", map[string]interface{}{ + "title": "Release dashboard", + "summary": "Shows deployment health.", + }) + + require.False(t, result.IsError) + assert.Equal(t, ws.ActionMCPCreateCanvas, backend.lastAction) + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "Release dashboard", payload["title"]) + assert.Equal(t, "Shows deployment health.", payload["summary"]) + assert.NotContains(t, payload, "task_id") + assert.NotContains(t, payload, "session_id") + assert.NotContains(t, payload, "workspace_id") +} + +func TestCanvasTools_PublishRequiresCanvasAndSourceRoot(t *testing.T) { + s := newCanvasTestServer(t, &testBackend{}) + + result := callTool(t, s, "publish_canvas_kandev", map[string]interface{}{ + "canvas_id": "canvas-1", + }) + + require.True(t, result.IsError) +} + +func TestCanvasTools_SetStatePreservesJSONValue(t *testing.T) { + backend := &testBackend{response: map[string]interface{}{"revision": float64(4)}} + s := newCanvasTestServer(t, backend) + + result := callTool(t, s, "set_canvas_state_kandev", map[string]interface{}{ + "canvas_id": "canvas-1", + "key": "filters", + "value": map[string]interface{}{"status": "failed", "limit": float64(3)}, + "expected_revision": float64(3), + }) + + require.False(t, result.IsError) + assert.Equal(t, ws.ActionMCPSetCanvasState, backend.lastAction) + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, map[string]interface{}{"status": "failed", "limit": float64(3)}, payload["value"]) + assert.Equal(t, int64(3), payload["expected_revision"]) +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 777a5b2678..1a792f4f38 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -1103,6 +1103,7 @@ func (s *Server) profileToolGroups() []profileToolGroup { {name: "step-completion", enabled: func(ctx mcpprofile.Context) bool { return kanban(ctx) || office(ctx) }, register: func(s *Server) { s.registerStepCompleteTool() }}, {name: "task-title", enabled: andProfilePredicates(kanban, capabilityEnabled(mcpprofile.CapabilityTaskTitle)), register: func(s *Server) { s.registerSetTaskTitleTool() }}, {name: "diagnostics", enabled: kanban, register: func(s *Server) { s.registerDiagnosticBundleTool() }}, + {name: "canvas-authoring", enabled: andProfilePredicates(kanban, capabilityEnabled(mcpprofile.CapabilityCanvas)), register: func(s *Server) { s.registerCanvasTools() }}, } } diff --git a/apps/backend/internal/office/costs/modelsdev/client_test.go b/apps/backend/internal/office/costs/modelsdev/client_test.go index 973875f0cf..c140ffcf70 100644 --- a/apps/backend/internal/office/costs/modelsdev/client_test.go +++ b/apps/backend/internal/office/costs/modelsdev/client_test.go @@ -2,13 +2,16 @@ package modelsdev_test import ( "context" + "io" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "sync" "sync/atomic" "testing" + "testing/synctest" "time" "github.com/kandev/kandev/internal/common/logger" @@ -491,51 +494,83 @@ func (g *requestGate) waitForFirstRequest(t *testing.T) { } } +type blockingRefreshTransport struct { + body string + started chan struct{} + release chan struct{} + releaseOnce sync.Once + requests atomic.Int32 +} + +func newBlockingRefreshTransport(body string) *blockingRefreshTransport { + return &blockingRefreshTransport{ + body: body, + started: make(chan struct{}), + release: make(chan struct{}), + } +} + +func (t *blockingRefreshTransport) RoundTrip(req *http.Request) (*http.Response, error) { + count := t.requests.Add(1) + if count == 1 { + close(t.started) + } + <-t.release + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(t.body)), + Header: make(http.Header), + Request: req, + }, nil +} + +func (t *blockingRefreshTransport) releaseAll() { + t.releaseOnce.Do(func() { close(t.release) }) +} + func TestClient_ConcurrentRefreshCallsShareOneFetch(t *testing.T) { - dir := t.TempDir() - cachePath := filepath.Join(dir, "models-dev.json") - gate := newRequestGate(t, sampleDataset) - c := modelsdev.New(modelsdev.Config{ - CachePath: cachePath, - URL: gate.server.URL, - TTL: time.Hour, - HTTPClient: gate.server.Client(), - }, logger.Default()) + synctest.Test(t, func(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "models-dev.json") + transport := newBlockingRefreshTransport(sampleDataset) + c := modelsdev.New(modelsdev.Config{ + CachePath: cachePath, + URL: "http://models.dev.test/api.json", + TTL: time.Hour, + HTTPClient: &http.Client{Transport: transport}, + }, logger.Default()) - start := make(chan struct{}) - var wg sync.WaitGroup - for index := 0; index < 8; index++ { - wg.Add(1) - go func() { - defer wg.Done() - <-start - if err := c.Refresh(context.Background()); err != nil { - t.Errorf("Refresh: %v", err) - } - }() - } - // If waitForFirstRequest below times out, its t.Fatal runs - // runtime.Goexit and skips the explicit releaseAll+wg.Wait() that - // follows, leaving these 8 goroutines parked in the gate. This - // Cleanup performs release-then-join as one atomic unit so it is - // safe regardless of its LIFO position relative to newRequestGate's - // own release Cleanup: without it, the parked goroutines complete - // only once the deferred release fires, after the test has already - // been marked done, and their t.Errorf call above then panics with - // "Log in goroutine after Test has completed" instead of failing - // cleanly. - t.Cleanup(func() { - gate.releaseAll() + start := make(chan struct{}) + var wg sync.WaitGroup + for index := 0; index < 8; index++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + if err := c.Refresh(context.Background()); err != nil { + t.Errorf("Refresh: %v", err) + } + }() + } + // If synctest.Wait below fails, cleanup still releases the transport + // and joins every caller before the test returns. + t.Cleanup(func() { + transport.releaseAll() + wg.Wait() + }) + close(start) + <-transport.started + // All goroutines are now either inside the same singleflight call or + // waiting for its result. The in-memory transport keeps the leader + // blocked, so this barrier does not depend on network scheduling. + synctest.Wait() + transport.releaseAll() wg.Wait() - }) - close(start) - gate.waitForFirstRequest(t) - gate.releaseAll() - wg.Wait() - if got := gate.requests.Load(); got != 1 { - t.Fatalf("models.dev request count = %d, want 1", got) - } + if got := transport.requests.Load(); got != 1 { + t.Fatalf("models.dev request count = %d, want 1", got) + } + }) } func TestClient_ConcurrentLookupsShareOneBackgroundFetch(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 7ed75a3ee7..e55f13ee4e 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -799,6 +799,7 @@ type Executor struct { capabilities ExecutorTypeCapabilities gitlabCredentials GitLabCredentialResolver logger *logger.Logger + canvasesEnabled bool gitCredentialIssuer GitCredentialLeaseIssuer gitCredentialBrokerURL string @@ -1083,6 +1084,13 @@ func (e *Executor) SetAttachmentReader(reader AttachmentReader) { e.attachmentReader = reader } +// SetCanvasesEnabled controls whether task MCP profiles receive the gated +// canvas-authoring capability. The flag is applied during backend startup +// before any new agent session is launched. +func (e *Executor) SetCanvasesEnabled(enabled bool) { + e.canvasesEnabled = enabled +} + // SetOnTaskStateChange sets a callback for task state changes. // This allows the orchestrator to route state changes through the task service layer // which publishes WebSocket events. Without this, async goroutines would only update diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 8773abeef8..3d711684d8 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -66,7 +66,7 @@ func (e *Executor) resolveTaskSessionMCPProfile(ctx context.Context, taskID stri if session.IsPassthrough { capabilities = nil } - return mcpprofile.New(mcpprofile.SurfaceConfiguration, capabilities, nil), nil + return e.withCanvasCapability(mcpprofile.New(mcpprofile.SurfaceConfiguration, capabilities, nil)), nil } task, err := e.repo.GetTask(ctx, taskID) if err != nil { @@ -78,10 +78,10 @@ func (e *Executor) resolveTaskSessionMCPProfile(ctx context.Context, taskID stri // state). Keep the legacy kanban profile in that narrow case; production // task launches resolve the persisted task above and therefore still get // the exact office/autopilot capability set. - return mcpprofile.Legacy("", session != nil && session.IsPassthrough, nil), nil + return e.withCanvasCapability(mcpprofile.Legacy("", session != nil && session.IsPassthrough, nil)), nil } if task.Origin == models.TaskOriginAutomationRun { - return mcpprofile.NewAutomation(), nil + return e.withCanvasCapability(mcpprofile.NewAutomation()), nil } surface := mcpprofile.SurfaceKanbanTask if task.IsFromOffice { @@ -98,7 +98,14 @@ func (e *Executor) resolveTaskSessionMCPProfile(ctx context.Context, taskID stri if allowTitleTool && surface == mcpprofile.SurfaceKanbanTask && models.IsAgentTitleOwner(task.Metadata, session.ID) { capabilities = append(capabilities, mcpprofile.CapabilityTaskTitle) } - return mcpprofile.New(surface, capabilities, nil), nil + return e.withCanvasCapability(mcpprofile.New(surface, capabilities, nil)), nil +} + +func (e *Executor) withCanvasCapability(profile mcpprofile.Context) mcpprofile.Context { + if e != nil && e.canvasesEnabled && profile.Surface == mcpprofile.SurfaceKanbanTask { + return profile.WithCapability(mcpprofile.CapabilityCanvas) + } + return profile } // isContainerizedExecutor returns true for executor types that run agents in diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index f37e96e40d..93ff2e3313 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -1498,6 +1498,14 @@ func (s *Service) SetAttachmentReader(reader AttachmentReader) { s.executor.SetAttachmentReader(reader) } +// SetCanvasesEnabled applies the release gate to task MCP profiles before +// agentctl receives them. Disabled profiles do not register canvas tools. +func (s *Service) SetCanvasesEnabled(enabled bool) { + if s.executor != nil { + s.executor.SetCanvasesEnabled(enabled) + } +} + // SetLaunchAttachmentClaimer wires staged-descriptor admission into the // unified session launch boundary. func (s *Service) SetLaunchAttachmentClaimer(claimer LaunchAttachmentClaimer) { diff --git a/apps/backend/internal/plugins/instances/store.go b/apps/backend/internal/plugins/instances/store.go new file mode 100644 index 0000000000..2f35c6a292 --- /dev/null +++ b/apps/backend/internal/plugins/instances/store.go @@ -0,0 +1,2233 @@ +// Package instances stores scoped plugin instances, immutable release +// metadata, grants, storage reservations, and cleanup inventory. Native +// installed plugins keep their existing filesystem records; this package is +// the durable authority for isolated web-application instances. +// +//revive:disable:file-length-limit // Instance persistence, admission, release, and cleanup share one transaction boundary. +package instances + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +const ( + SourceInstalled = "installed" + SourceLocalCanvas = "local_canvas" + + ScopeInstance = "instance" + ScopeWorkspace = "workspace" + ScopeTask = "task" + ScopeSession = "session" + ScopeRepository = "repository" + + StatusPending = "pending" + StatusActive = "active" + StatusDisabled = "disabled" + StatusArchived = "archived" + StatusError = "error" + StatusRemoved = "removed" + + ValidationValid = "valid" + ValidationPendingPermission = "pending_permission" + ValidationInvalid = "invalid" + ValidationUnavailable = "plugin_release_unavailable" + + CleanupPending = "pending" + CleanupRunning = "running" + CleanupRetryWait = "retry_wait" + CleanupCompleted = "completed" + + MaxTaskInstances = 10 + MaxWorkspaceInstances = 100 + + WorkspaceArtifactLimitBytes int64 = 2 << 30 + InstallationArtifactLimitBytes int64 = 10 << 30 +) + +var ( + ErrNotFound = errors.New("plugin instance not found") + ErrInvalidScope = errors.New("invalid plugin instance scope") + ErrTaskCanvasLimit = errors.New("canvas task limit exceeded") + ErrWorkspaceCanvasLimit = errors.New("canvas workspace limit exceeded") + ErrWorkspaceStorageLimit = errors.New("canvas workspace storage limit exceeded") + ErrInstallationStorageLimit = errors.New("canvas installation storage limit exceeded") + ErrInvalidRelease = errors.New("invalid plugin release") + ErrStalePromotionReview = errors.New("canvas promotion review is stale") + ErrStaleCanvasEdit = errors.New("canvas edit base release is stale") + ErrStaleCanvasPublish = errors.New("canvas publish authority is stale") + ErrInvalidLifecycleState = errors.New("plugin instance lifecycle state does not allow this operation") +) + +// ScopeIdentifiers contains only the trusted identifiers required by a scope. +// Empty strings represent SQL NULL for optional identifiers. +type ScopeIdentifiers struct { + WorkspaceID string + TaskID string + SessionID string + RepositoryID string +} + +// ValidateScope rejects incomplete and mixed scopes before persistence. +func ValidateScope(kind string, ids ScopeIdentifiers) error { + allowed := scopeIdentifiersFor(kind) + if allowed == nil { + return ErrInvalidScope + } + values := map[string]string{ + "workspace": ids.WorkspaceID, + "task": ids.TaskID, + "session": ids.SessionID, + "repository": ids.RepositoryID, + } + for name, value := range values { + if allowed[name] != (value != "") { + return ErrInvalidScope + } + } + return nil +} + +func scopeIdentifiersFor(kind string) map[string]bool { + switch kind { + case ScopeInstance: + return map[string]bool{} + case ScopeWorkspace: + return map[string]bool{"workspace": true} + case ScopeTask: + return map[string]bool{"workspace": true, "task": true} + case ScopeSession: + return map[string]bool{"workspace": true, "session": true} + case ScopeRepository: + return map[string]bool{"workspace": true, "repository": true} + default: + return nil + } +} + +type Instance struct { + ID string + PluginID string + SourceKind string + ScopeKind string + WorkspaceID string + TaskID string + SessionID string + RepositoryID string + Status string + ActiveReleaseID string + GrantGeneration int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +// PublishAuthority is the durable identity captured when a trusted authoring +// request is authorized. A publish must match every field in this snapshot at +// the release insertion boundary so a scope transition, archive, or competing +// release cannot turn an already-authorized stream into a write for a new +// authority. +type PublishAuthority struct { + InstanceID string + ScopeKind string + WorkspaceID string + TaskID string + SessionID string + RepositoryID string + Status string + ActiveReleaseID string + GrantGeneration int64 +} + +func (i Instance) PublishAuthority() PublishAuthority { + return PublishAuthority{ + InstanceID: i.ID, ScopeKind: i.ScopeKind, WorkspaceID: i.WorkspaceID, + TaskID: i.TaskID, SessionID: i.SessionID, RepositoryID: i.RepositoryID, + Status: i.Status, ActiveReleaseID: i.ActiveReleaseID, GrantGeneration: i.GrantGeneration, + } +} + +func (a PublishAuthority) IsZero() bool { + return a.InstanceID == "" && a.ScopeKind == "" && a.WorkspaceID == "" && + a.TaskID == "" && a.SessionID == "" && a.RepositoryID == "" && + a.Status == "" && a.ActiveReleaseID == "" && a.GrantGeneration == 0 +} + +type Release struct { + ID string + PluginID string + InstanceID string + PackageDigest string + SourceKind string + SourceActorKind string + SourceUserID string + SourceTaskID string + SourceSessionID string + ManifestJSON json.RawMessage + DeclaredPermissionsJSON json.RawMessage + ArtifactPath string + ArtifactBytes int64 + ProtocolVersion int + ValidationStatus string + ValidationError string + CreatedAt time.Time +} + +type Grant struct { + InstanceID string + PermissionKind string + Resource string + NetworkOrigin string + ScopeCeiling string + ApprovedBy string + ApprovedAt time.Time +} + +type Reservation struct { + ID string + WorkspaceID string + Bytes int64 + Status string + CreatedAt time.Time + ExpiresAt time.Time +} + +type CleanupJob struct { + ID string + WorkspaceID string + InstanceID string + ArtifactPath string + Status string + Attempts int + LastError string + CreatedAt time.Time + NextAttemptAt time.Time +} + +// ArtifactCheck is the content-free result returned by startup artifact +// reconciliation. +type ArtifactCheck struct { + Available bool + Reason string +} + +type Store struct { + db *sqlx.DB + ro *sqlx.DB + admission sync.Mutex +} + +func NewStore(pool *db.Pool) (*Store, error) { + if pool == nil || pool.Writer() == nil || pool.Reader() == nil { + return nil, errors.New("plugin instances: database pool is nil") + } + s := &Store{db: pool.Writer(), ro: pool.Reader()} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("plugin instances schema: %w", err) + } + return s, nil +} + +// SchemaSQL is exported for migration replay tests and backup tooling. Each +// statement is idempotent and uses only the common SQLite/PostgreSQL subset. +const SchemaSQL = ` +CREATE TABLE IF NOT EXISTS plugin_instances ( + id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + scope_kind TEXT NOT NULL, + workspace_id TEXT NOT NULL DEFAULT '', + task_id TEXT NOT NULL DEFAULT '', + session_id TEXT NOT NULL DEFAULT '', + repository_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + active_release_id TEXT NOT NULL DEFAULT '', + grant_generation INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_instances_workspace ON plugin_instances(workspace_id, status); +CREATE INDEX IF NOT EXISTS idx_plugin_instances_task ON plugin_instances(task_id, status); +CREATE TABLE IF NOT EXISTS plugin_releases ( + id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + instance_id TEXT NOT NULL, + package_digest TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_actor_kind TEXT NOT NULL, + source_user_id TEXT NOT NULL DEFAULT '', + source_task_id TEXT NOT NULL DEFAULT '', + source_session_id TEXT NOT NULL DEFAULT '', + manifest_json TEXT NOT NULL, + declared_permissions_json TEXT NOT NULL, + artifact_path TEXT NOT NULL, + artifact_bytes INTEGER NOT NULL DEFAULT 0, + protocol_version INTEGER NOT NULL DEFAULT 1, + validation_status TEXT NOT NULL, + validation_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_releases_instance ON plugin_releases(instance_id, created_at); +CREATE TABLE IF NOT EXISTS plugin_instance_grants ( + plugin_instance_id TEXT NOT NULL, + permission_kind TEXT NOT NULL, + resource TEXT NOT NULL, + network_origin TEXT NOT NULL DEFAULT '', + scope_ceiling TEXT NOT NULL, + approved_by TEXT NOT NULL, + approved_at TEXT NOT NULL, + PRIMARY KEY (plugin_instance_id, permission_kind, resource, network_origin) +); +CREATE TABLE IF NOT EXISTS plugin_storage_reservations ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + bytes INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_storage_reservations_scope ON plugin_storage_reservations(workspace_id, status); +CREATE TABLE IF NOT EXISTS plugin_artifact_cleanup_jobs ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + instance_id TEXT NOT NULL DEFAULT '', + artifact_path TEXT NOT NULL, + status TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + next_attempt_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_plugin_artifact_cleanup_jobs_due ON plugin_artifact_cleanup_jobs(status, next_attempt_at); +` + +func (s *Store) initSchema() error { + for _, statement := range strings.Split(SchemaSQL, ";") { + if strings.TrimSpace(statement) == "" { + continue + } + if _, err := s.db.Exec(statement); err != nil { + return err + } + } + return nil +} + +// WithTransaction runs one instance lifecycle transaction while holding the +// in-process admission lock. Callers use the Tx helpers below when a canvas +// metadata mutation must commit with its plugin-instance mutation. +func (s *Store) WithTransaction(ctx context.Context, fn func(*sqlx.Tx) error) error { + if s == nil || s.db == nil || fn == nil { + return ErrInvalidScope + } + s.admission.Lock() + defer s.admission.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := fn(tx); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) Create(ctx context.Context, instance Instance) error { + return s.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return s.CreateTx(ctx, tx, instance) + }) +} + +// CreateTx inserts an instance in an existing lifecycle transaction. +func (s *Store) CreateTx(ctx context.Context, tx *sqlx.Tx, instance Instance) error { + if instance.ID == "" || instance.PluginID == "" || instance.SourceKind == "" || instance.Status == "" { + return ErrInvalidScope + } + if err := ValidateScope(instance.ScopeKind, ScopeIdentifiers{WorkspaceID: instance.WorkspaceID, TaskID: instance.TaskID, SessionID: instance.SessionID, RepositoryID: instance.RepositoryID}); err != nil { + return err + } + if err := s.checkAdmission(ctx, tx, instance.ScopeKind, instance.WorkspaceID, instance.TaskID); err != nil { + return err + } + now := time.Now().UTC() + createdAt, updatedAt := instance.CreatedAt, instance.UpdatedAt + if createdAt.IsZero() { + createdAt = now + } + if updatedAt.IsZero() { + updatedAt = createdAt + } + _, err := tx.ExecContext(ctx, tx.Rebind(` +INSERT INTO plugin_instances (id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), instance.ID, instance.PluginID, instance.SourceKind, instance.ScopeKind, instance.WorkspaceID, instance.TaskID, instance.SessionID, instance.RepositoryID, instance.Status, instance.ActiveReleaseID, instance.GrantGeneration, createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano)) + if err != nil { + return err + } + return nil +} + +// ListBySource returns instances created by a lifecycle source. It is used by +// startup reconciliation to remove instances left behind by a crash before +// their canvas metadata transaction committed. +func (s *Store) ListBySource(ctx context.Context, sourceKind string, includeRemoved bool) ([]Instance, error) { + if strings.TrimSpace(sourceKind) == "" { + return nil, ErrInvalidScope + } + query := `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE source_kind = ?` + args := []any{sourceKind} + if !includeRemoved { + query += ` AND status <> ?` + args = append(args, StatusRemoved) + } + query += ` ORDER BY created_at, id` + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var result []Instance + for rows.Next() { + var row instanceRow + if err := rows.StructScan(&row); err != nil { + return nil, err + } + instance, err := row.instance() + if err != nil { + return nil, err + } + result = append(result, instance) + } + return result, rows.Err() +} + +func (s *Store) checkAdmission(ctx context.Context, tx *sqlx.Tx, scopeKind, workspaceID, taskID string) error { + return s.checkAdmissionExcept(ctx, tx, scopeKind, workspaceID, taskID, "") +} + +// checkAdmissionExcept applies the shared canvas count boundary while +// optionally excluding an existing instance. Promotion changes the scope of +// one existing instance, so its current row must not consume capacity in the +// destination scope when the destination is otherwise full. +func (s *Store) checkAdmissionExcept(ctx context.Context, tx *sqlx.Tx, scopeKind, workspaceID, taskID, excludeID string) error { + exclude := "" + argsSuffix := []any{} + if excludeID != "" { + exclude = " AND id <> ?" + argsSuffix = append(argsSuffix, excludeID) + } + var count int + if scopeKind == ScopeTask { + args := []any{taskID, StatusRemoved} + args = append(args, argsSuffix...) + if err := tx.GetContext(ctx, &count, tx.Rebind(`SELECT COUNT(*) FROM plugin_instances WHERE task_id = ? AND status <> ?`+exclude), args...); err != nil { + return err + } + if count >= MaxTaskInstances { + return ErrTaskCanvasLimit + } + } + if workspaceID != "" { + args := []any{workspaceID, StatusRemoved} + args = append(args, argsSuffix...) + if err := tx.GetContext(ctx, &count, tx.Rebind(`SELECT COUNT(*) FROM plugin_instances WHERE workspace_id = ? AND status <> ?`+exclude), args...); err != nil { + return err + } + if count >= MaxWorkspaceInstances { + return ErrWorkspaceCanvasLimit + } + } + return nil +} + +func (s *Store) Get(ctx context.Context, id string) (Instance, error) { + var row instanceRow + err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id) + if errors.Is(err, sql.ErrNoRows) { + return Instance{}, ErrNotFound + } + if err != nil { + return Instance{}, err + } + return row.instance() +} + +// GetMany loads instance authority in one query for lifecycle projections. +// Missing IDs are omitted so callers can reconcile stale metadata without +// turning an expected orphan into a query error. +func (s *Store) GetMany(ctx context.Context, ids []string) ([]Instance, error) { + unique := make([]string, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + if strings.TrimSpace(id) == "" { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + if len(unique) == 0 { + return []Instance{}, nil + } + placeholders := strings.TrimRight(strings.Repeat("?,", len(unique)), ",") + args := make([]any, len(unique)) + for i, id := range unique { + args[i] = id + } + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id IN (`+placeholders+`)`), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + result := make([]Instance, 0, len(unique)) + for rows.Next() { + var row instanceRow + if err := rows.StructScan(&row); err != nil { + return nil, err + } + instance, err := row.instance() + if err != nil { + return nil, err + } + result = append(result, instance) + } + return result, rows.Err() +} + +func (s *Store) List(ctx context.Context, workspaceID string, includeRemoved bool) ([]Instance, error) { + query := `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE workspace_id = ?` + args := []any{workspaceID} + if !includeRemoved { + query += ` AND status <> ?` + args = append(args, StatusRemoved) + } + query += ` ORDER BY created_at, id` + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var result []Instance + for rows.Next() { + var row instanceRow + if err := rows.StructScan(&row); err != nil { + return nil, err + } + instance, err := row.instance() + if err != nil { + return nil, err + } + result = append(result, instance) + } + return result, rows.Err() +} + +func (s *Store) CountActive(ctx context.Context, scopeKind, scopeID string) (int, error) { + column := map[string]string{ScopeTask: "task_id", ScopeWorkspace: "workspace_id", ScopeSession: "session_id", ScopeRepository: "repository_id"}[scopeKind] + if column == "" || scopeID == "" { + return 0, ErrInvalidScope + } + var count int + err := s.ro.GetContext(ctx, &count, s.ro.Rebind(`SELECT COUNT(*) FROM plugin_instances WHERE `+column+` = ? AND status <> ?`), scopeID, StatusRemoved) + return count, err +} + +func (s *Store) Archive(ctx context.Context, id string) error { + return s.updateStatus(ctx, id, StatusArchived) +} + +func (s *Store) Restore(ctx context.Context, id string) error { + s.admission.Lock() + defer s.admission.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + var row instanceRow + if err := tx.GetContext(ctx, &row, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if row.Status != StatusArchived { + return fmt.Errorf("%w: instance is %s", ErrInvalidScope, row.Status) + } + if err := s.checkAdmission(ctx, tx, row.ScopeKind, row.WorkspaceID, row.TaskID); err != nil { + return err + } + _, err = tx.ExecContext(ctx, tx.Rebind(`UPDATE plugin_instances SET status = ?, updated_at = ? WHERE id = ? AND status = ?`), StatusActive, time.Now().UTC().Format(time.RFC3339Nano), id, StatusArchived) + if err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) updateStatus(ctx context.Context, id, status string) error { + s.admission.Lock() + defer s.admission.Unlock() + result, err := s.db.ExecContext(ctx, s.db.Rebind( + `UPDATE plugin_instances SET status = ?, updated_at = ? WHERE id = ? AND status <> ?`, + ), status, time.Now().UTC().Format(time.RFC3339Nano), id, StatusRemoved) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + var exists int + if err := s.ro.GetContext(ctx, &exists, s.ro.Rebind(`SELECT COUNT(*) FROM plugin_instances WHERE id = ?`), id); err != nil { + return err + } + if exists == 0 { + return ErrNotFound + } + return ErrNotFound + } + return nil +} + +func (s *Store) SetScope(ctx context.Context, id, scopeKind string, ids ScopeIdentifiers) error { + if err := ValidateScope(scopeKind, ids); err != nil { + return err + } + s.admission.Lock() + defer s.admission.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + var current instanceRow + if err := tx.GetContext(ctx, ¤t, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if current.Status == StatusRemoved { + return ErrNotFound + } + if err := s.checkAdmissionExcept(ctx, tx, scopeKind, ids.WorkspaceID, ids.TaskID, id); err != nil { + return err + } + result, err := tx.ExecContext(ctx, tx.Rebind(`UPDATE plugin_instances SET scope_kind = ?, workspace_id = ?, task_id = ?, session_id = ?, repository_id = ?, grant_generation = grant_generation + 1, updated_at = ? WHERE id = ? AND status <> ?`), scopeKind, ids.WorkspaceID, ids.TaskID, ids.SessionID, ids.RepositoryID, time.Now().UTC().Format(time.RFC3339Nano), id, StatusRemoved) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrNotFound + } + return tx.Commit() +} + +// SetPluginID binds a local instance to the package identity discovered by a +// validated release. Local canvas instances start with a synthetic identity +// because the package does not exist at create time; publishing replaces it +// atomically before the release row is written. +func (s *Store) SetPluginID(ctx context.Context, id, pluginID string) error { + return s.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return s.SetPluginIDTx(ctx, tx, id, pluginID) + }) +} + +// SetPluginIDTx updates the package identity inside an existing lifecycle +// transaction. Pending releases must not call this helper because changing the +// identity also advances the runtime grant generation. +func (s *Store) SetPluginIDTx(ctx context.Context, tx *sqlx.Tx, id, pluginID string) error { + if strings.TrimSpace(id) == "" || strings.TrimSpace(pluginID) == "" { + return ErrInvalidScope + } + result, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE plugin_instances SET plugin_id = ?, grant_generation = grant_generation + 1, updated_at = ? WHERE id = ? AND status <> ?`, + ), pluginID, time.Now().UTC().Format(time.RFC3339Nano), id, StatusRemoved) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) SetActiveRelease(ctx context.Context, instanceID, releaseID string) error { + s.admission.Lock() + defer s.admission.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + var exists int + if err := tx.GetContext(ctx, &exists, tx.Rebind(`SELECT COUNT(*) FROM plugin_releases WHERE id = ? AND instance_id = ? AND validation_status = ?`), releaseID, instanceID, ValidationValid); err != nil { + return err + } + if exists == 0 { + return ErrInvalidRelease + } + var status string + if err := tx.GetContext(ctx, &status, tx.Rebind(`SELECT status FROM plugin_instances WHERE id = ?`), instanceID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if err := activationStateError(status); err != nil { + return err + } + result, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE plugin_instances SET active_release_id = ?, updated_at = ? WHERE id = ? AND status IN (?, ?, ?)`, + ), releaseID, time.Now().UTC().Format(time.RFC3339Nano), instanceID, StatusPending, StatusActive, StatusError) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return activationStateError(status) + } + return tx.Commit() +} + +// ActivateRelease makes a valid release the active release and moves the +// instance into the active state. It is the publish/governance operation for +// local canvas instances; SetActiveRelease remains the narrow historical +// helper used by migration and compatibility tests. +func (s *Store) ActivateRelease(ctx context.Context, instanceID, releaseID string) error { + return s.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return s.ActivateReleaseTx(ctx, tx, instanceID, releaseID) + }) +} + +// ActivateReleaseTx validates and activates a release inside an existing +// lifecycle transaction. The caller can combine it with release insertion or +// another owner-table mutation without exposing a half-published release. +func (s *Store) ActivateReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID string) error { + if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" { + return ErrInvalidRelease + } + var current instanceRow + if err := tx.GetContext(ctx, ¤t, tx.Rebind( + `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`, + ), instanceID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if current.Status == StatusRemoved { + return ErrNotFound + } + if err := activationStateError(current.Status); err != nil { + return err + } + var release struct { + DeclaredPermissionsJSON string `db:"declared_permissions_json"` + ValidationStatus string `db:"validation_status"` + } + if err := tx.GetContext(ctx, &release, tx.Rebind( + `SELECT declared_permissions_json, validation_status FROM plugin_releases WHERE id = ? AND instance_id = ?`, + ), releaseID, instanceID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrInvalidRelease + } + return err + } + if release.ValidationStatus != ValidationValid || !declaredGrantsFitInstance(ctx, tx, instanceID, current.ScopeKind, release.DeclaredPermissionsJSON) { + return ErrInvalidRelease + } + result, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE plugin_instances SET active_release_id = ?, status = ?, updated_at = ? WHERE id = ? AND status IN (?, ?, ?)`, + ), releaseID, StatusActive, time.Now().UTC().Format(time.RFC3339Nano), instanceID, StatusPending, StatusActive, StatusError) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return activationStateError(current.Status) + } + return nil +} + +func activationStateError(status string) error { + switch status { + case StatusPending, StatusActive, StatusError: + return nil + case StatusRemoved: + return ErrNotFound + default: + return fmt.Errorf("%w: instance is %s", ErrInvalidLifecycleState, status) + } +} + +type declaredPermissions struct { + Reads []string `json:"reads"` + APIRead []string `json:"api_read"` + Writes []string `json:"writes"` + APIWrite []string `json:"api_write"` + Events []string `json:"events"` + SharedState bool `json:"shared_state"` + State bool `json:"state"` + ExternalOrigins []string `json:"external_origins"` +} + +func (p declaredPermissions) readResources() []string { + return append(append([]string(nil), p.Reads...), p.APIRead...) +} + +func (p declaredPermissions) writeResources() []string { + return append(append([]string(nil), p.Writes...), p.APIWrite...) +} + +func parseDeclaredPermissions(declaredJSON string) (declaredPermissions, error) { + var declared declaredPermissions + if strings.TrimSpace(declaredJSON) == "" { + return declared, errors.New("declared permissions are empty") + } + trimmed := strings.TrimSpace(declaredJSON) + if strings.HasPrefix(trimmed, "[") { + var legacy []string + if err := json.Unmarshal([]byte(trimmed), &legacy); err != nil { + return declared, err + } + for _, permission := range legacy { + parts := strings.SplitN(permission, ":", 2) + if len(parts) != 2 { + continue + } + switch parts[0] { + case "api_read": + declared.APIRead = append(declared.APIRead, parts[1]) + case "api_write": + declared.APIWrite = append(declared.APIWrite, parts[1]) + case "events": + declared.Events = append(declared.Events, parts[1]) + case "state": + declared.State = true + case "network": + declared.ExternalOrigins = append(declared.ExternalOrigins, parts[1]) + } + } + return declared, nil + } + if err := json.Unmarshal([]byte(trimmed), &declared); err != nil { + return declared, err + } + return declared, nil +} + +func declaredPermissionRequirements(declaredJSON string) ([]Grant, error) { + declared, err := parseDeclaredPermissions(declaredJSON) + if err != nil { + return nil, err + } + requirements := make([]Grant, 0, len(declared.readResources())+len(declared.writeResources())+len(declared.Events)+len(declared.ExternalOrigins)+1) + for _, resource := range declared.readResources() { + requirements = append(requirements, Grant{PermissionKind: "api_read", Resource: resource}) + } + for _, resource := range declared.writeResources() { + requirements = append(requirements, Grant{PermissionKind: "api_write", Resource: resource}) + } + for _, subject := range declared.Events { + requirements = append(requirements, Grant{PermissionKind: "events", Resource: subject}) + } + if declared.SharedState || declared.State { + requirements = append(requirements, Grant{PermissionKind: "state"}) + } + for _, origin := range declared.ExternalOrigins { + requirements = append(requirements, Grant{PermissionKind: "network", NetworkOrigin: origin}) + } + return requirements, nil +} + +func declaredPermissionGrantFits(declaredJSON string, scope string, grant Grant) bool { + declared, err := parseDeclaredPermissions(declaredJSON) + if err != nil || !grantScopeFitsInstance(grant.ScopeCeiling, scope) { + return false + } + switch grant.PermissionKind { + case "api_read": + return containsString(declared.readResources(), grant.Resource) + case "api_write": + return containsString(declared.writeResources(), grant.Resource) + case "events": + return containsString(declared.Events, grant.Resource) + case "state": + return declared.SharedState || declared.State + case "network": + return grant.NetworkOrigin != "" && containsString(declared.ExternalOrigins, grant.NetworkOrigin) + default: + return false + } +} + +func grantScopeFitsInstance(ceiling, scope string) bool { + if ceiling == ScopeInstance { + return true + } + return ceiling == scope +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func grantsCoverDeclaration(declaredJSON string, scope string, grants []Grant) bool { + requirements, err := declaredPermissionRequirements(declaredJSON) + if err != nil { + return false + } + for _, requirement := range requirements { + covered := false + for _, grant := range grants { + if grant.PermissionKind != requirement.PermissionKind || grant.Resource != requirement.Resource || grant.NetworkOrigin != requirement.NetworkOrigin { + continue + } + if grantScopeFitsInstance(grant.ScopeCeiling, scope) { + covered = true + break + } + } + if !covered { + return false + } + } + return true +} + +func declaredGrantsFitInstance(ctx context.Context, tx *sqlx.Tx, instanceID, scope, declaredJSON string) bool { + rows, err := tx.QueryxContext(ctx, tx.Rebind( + `SELECT permission_kind, resource, network_origin, scope_ceiling FROM plugin_instance_grants WHERE plugin_instance_id = ?`, + ), instanceID) + if err != nil { + return false + } + defer func() { _ = rows.Close() }() + grants := make([]Grant, 0) + for rows.Next() { + var row struct { + PermissionKind string `db:"permission_kind"` + Resource string `db:"resource"` + NetworkOrigin string `db:"network_origin"` + ScopeCeiling string `db:"scope_ceiling"` + } + if err := rows.StructScan(&row); err != nil { + return false + } + grant := Grant{PermissionKind: row.PermissionKind, Resource: row.Resource, NetworkOrigin: row.NetworkOrigin, ScopeCeiling: row.ScopeCeiling} + grants = append(grants, grant) + } + if rows.Err() != nil { + return false + } + for _, grant := range grants { + // A release may remove a previously declared permission without a new + // approval. Runtime authorization intersects the release declaration + // with these grants, so only a grant that exceeds the instance scope + // must reject activation here. + if !grantScopeFitsInstance(grant.ScopeCeiling, scope) { + return false + } + } + return grantsCoverDeclaration(declaredJSON, scope, grants) +} + +func validateGrantsForDeclaration(declaredJSON string, scope string, grants []Grant) error { + for _, grant := range grants { + if strings.TrimSpace(grant.PermissionKind) == "" || strings.TrimSpace(grant.ScopeCeiling) == "" || + !declaredPermissionGrantFits(declaredJSON, scope, grant) { + return ErrInvalidScope + } + } + if !grantsCoverDeclaration(declaredJSON, scope, grants) { + return ErrInvalidScope + } + return nil +} + +func loadInstanceGrants(ctx context.Context, tx *sqlx.Tx, instanceID string) ([]Grant, error) { + rows, err := tx.QueryxContext(ctx, tx.Rebind( + `SELECT permission_kind, resource, network_origin, scope_ceiling FROM plugin_instance_grants WHERE plugin_instance_id = ?`, + ), instanceID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + grants := make([]Grant, 0) + for rows.Next() { + var row struct { + PermissionKind string `db:"permission_kind"` + Resource string `db:"resource"` + NetworkOrigin string `db:"network_origin"` + ScopeCeiling string `db:"scope_ceiling"` + } + if err := rows.StructScan(&row); err != nil { + return nil, err + } + grants = append(grants, Grant{ + PermissionKind: row.PermissionKind, + Resource: row.Resource, + NetworkOrigin: row.NetworkOrigin, + ScopeCeiling: row.ScopeCeiling, + }) + } + return grants, rows.Err() +} + +func insertInstanceGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, approvedBy string, grants []Grant) error { + now := time.Now().UTC() + for _, grant := range grants { + normalized, err := normalizeGrant(grant, approvedBy, now) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, tx.Rebind( + `INSERT INTO plugin_instance_grants (plugin_instance_id, permission_kind, resource, network_origin, scope_ceiling, approved_by, approved_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(plugin_instance_id, permission_kind, resource, network_origin) DO UPDATE SET scope_ceiling = excluded.scope_ceiling, approved_by = excluded.approved_by, approved_at = excluded.approved_at`, + ), instanceID, normalized.PermissionKind, normalized.Resource, normalized.NetworkOrigin, normalized.ScopeCeiling, normalized.ApprovedBy, normalized.ApprovedAt.Format(time.RFC3339Nano)); err != nil { + return err + } + } + return nil +} + +func normalizeGrant(grant Grant, approvedBy string, now time.Time) (Grant, error) { + if strings.TrimSpace(grant.PermissionKind) == "" || strings.TrimSpace(grant.ScopeCeiling) == "" { + return Grant{}, ErrInvalidScope + } + if grant.ApprovedBy == "" { + grant.ApprovedBy = approvedBy + } + if grant.ApprovedAt.IsZero() { + grant.ApprovedAt = now + } + return grant, nil +} + +// SetReleaseValidation changes a release's validation state without changing +// the active release. This is used for rejected and pending-permission +// releases, where the currently running application must remain untouched. +func (s *Store) SetReleaseValidation(ctx context.Context, releaseID, validationStatus, validationError string) error { + if strings.TrimSpace(releaseID) == "" || strings.TrimSpace(validationStatus) == "" { + return ErrInvalidRelease + } + result, err := s.db.ExecContext(ctx, s.db.Rebind( + `UPDATE plugin_releases SET validation_status = ?, validation_error = ? WHERE id = ?`, + ), validationStatus, validationError, releaseID) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrNotFound + } + return nil +} + +// RejectReleaseAndPrune records a user rejection and transfers cleanup +// ownership for the rejected artifact in the same transaction. This prevents +// a rejected pending release from consuming durable storage until a later +// publish happens to trigger retention. +func (s *Store) RejectReleaseAndPrune(ctx context.Context, instanceID, releaseID string) error { + if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" { + return ErrInvalidRelease + } + s.admission.Lock() + defer s.admission.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + current, err := loadReleaseRetentionInstance(ctx, tx, instanceID) + if err != nil { + return err + } + if err := activationStateError(current.Status); err != nil { + return err + } + var validationStatus string + if err := tx.GetContext(ctx, &validationStatus, tx.Rebind( + `SELECT validation_status FROM plugin_releases WHERE id = ? AND instance_id = ?`, + ), releaseID, instanceID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if validationStatus != ValidationPendingPermission { + return ErrInvalidRelease + } + if _, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE plugin_releases SET validation_status = ?, validation_error = ? WHERE id = ? AND instance_id = ? AND validation_status = ?`, + ), ValidationInvalid, "rejected_by_user", releaseID, instanceID, ValidationPendingPermission); err != nil { + return err + } + if err := s.pruneReleasesTx(ctx, tx, instanceID); err != nil { + return err + } + return tx.Commit() +} + +// PromoteScopeAndGrants atomically moves an instance to workspace scope and +// records the grants approved by one human. The admission check excludes the +// instance itself, so a full destination workspace can still accept its +// promoted canvas only when it is already counted there (which is not the +// normal task-to-workspace case). +func (s *Store) PromoteScopeAndGrants(ctx context.Context, instanceID, workspaceID, approvedBy string, grants []Grant) error { + return s.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return s.PromoteScopeAndGrantsTx(ctx, tx, instanceID, workspaceID, approvedBy, grants) + }) +} + +// PromoteScopeAndGrantsTx applies workspace scope and grants inside an +// existing transaction. Canvas metadata promotion uses the same transaction +// through this helper to keep the two authorities consistent after a crash. +func (s *Store) PromoteScopeAndGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant) error { + return s.promoteScopeAndGrantsReviewedTx(ctx, tx, instanceID, workspaceID, approvedBy, grants, "", "", 0) +} + +// PromoteScopeAndGrantsReviewedTx applies a human-reviewed promotion only if +// the release, permission declaration, and grant generation are unchanged +// since the review was shown. All checks and mutations share one transaction. +func (s *Store) PromoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { + return s.promoteScopeAndGrantsReviewedTx(ctx, tx, instanceID, workspaceID, approvedBy, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration) +} + +func (s *Store) promoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { + if err := validatePromotionRequest(instanceID, workspaceID, approvedBy); err != nil { + return err + } + current, declaredJSON, err := loadPromotionState(ctx, tx, instanceID) + if err != nil { + return err + } + if err := validatePromotionReview(current, declaredJSON, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil { + return err + } + if err := validateGrantsForDeclaration(declaredJSON, ScopeWorkspace, grants); err != nil { + return err + } + if err := s.checkAdmissionExcept(ctx, tx, ScopeWorkspace, workspaceID, "", instanceID); err != nil { + return err + } + return s.updatePromotedInstance(ctx, tx, instanceID, workspaceID, approvedBy, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration) +} + +func loadPromotionState(ctx context.Context, tx *sqlx.Tx, instanceID string) (instanceRow, string, error) { + var current instanceRow + if err := tx.GetContext(ctx, ¤t, tx.Rebind( + `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`, + ), instanceID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return instanceRow{}, "", ErrNotFound + } + return instanceRow{}, "", err + } + if err := activationStateError(current.Status); err != nil { + return instanceRow{}, "", err + } + if current.ActiveReleaseID == "" { + return instanceRow{}, "", ErrInvalidRelease + } + var declaredJSON string + if err := tx.GetContext(ctx, &declaredJSON, tx.Rebind( + `SELECT declared_permissions_json FROM plugin_releases WHERE id = ? AND instance_id = ?`, + ), current.ActiveReleaseID, instanceID); err != nil { + return instanceRow{}, "", ErrInvalidRelease + } + return current, declaredJSON, nil +} + +func validatePromotionReview(current instanceRow, declaredJSON, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { + if expectedReleaseID != "" && current.ActiveReleaseID != expectedReleaseID { + return ErrStalePromotionReview + } + if (expectedReleaseID != "" || expectedPermissionDigest != "") && current.GrantGeneration != expectedGrantGeneration { + return ErrStalePromotionReview + } + if expectedPermissionDigest != "" && permissionDigest(declaredJSON) != expectedPermissionDigest { + return ErrStalePromotionReview + } + return nil +} + +func (s *Store) updatePromotedInstance(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { + now := time.Now().UTC().Format(time.RFC3339Nano) + updateQuery := `UPDATE plugin_instances SET scope_kind = ?, workspace_id = ?, task_id = '', session_id = '', repository_id = '', grant_generation = grant_generation + 1, updated_at = ? WHERE id = ? AND status <> ?` + updateArgs := []any{ScopeWorkspace, workspaceID, now, instanceID, StatusRemoved} + reviewed := expectedReleaseID != "" || expectedPermissionDigest != "" + if reviewed { + // Recheck the review identity while taking the instance row lock. A + // release activation or grant change that races this statement causes + // the conditional update to affect no rows instead of being silently + // overwritten by the promotion. + updateQuery += ` AND active_release_id = ? AND grant_generation = ?` + updateArgs = append(updateArgs, expectedReleaseID, expectedGrantGeneration) + } + result, err := tx.ExecContext(ctx, tx.Rebind(updateQuery), updateArgs...) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + if reviewed { + return ErrStalePromotionReview + } + return ErrNotFound + } + if err := insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants); err != nil { + return err + } + return nil +} + +func permissionDigest(declaredJSON string) string { + digest := sha256.Sum256([]byte(strings.TrimSpace(declaredJSON))) + return fmt.Sprintf("%x", digest[:]) +} + +func validatePromotionRequest(instanceID, workspaceID, approvedBy string) error { + if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(workspaceID) == "" || strings.TrimSpace(approvedBy) == "" { + return ErrInvalidScope + } + return ValidateScope(ScopeWorkspace, ScopeIdentifiers{WorkspaceID: workspaceID}) +} + +// ApproveRelease atomically grants the requested permission rows, marks the +// release valid, and activates it. It is intentionally separate from +// SetReleaseValidation so a human approval cannot expose a release before +// its grants and active pointer commit together. +func (s *Store) ApproveRelease(ctx context.Context, instanceID, releaseID, approvedBy string, grants []Grant) error { + if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" || strings.TrimSpace(approvedBy) == "" { + return ErrInvalidRelease + } + s.admission.Lock() + defer s.admission.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + release, err := loadPendingReleaseTx(ctx, tx, instanceID, releaseID) + if err != nil { + return err + } + if release.ValidationStatus != ValidationPendingPermission { + return ErrInvalidRelease + } + if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, release.ScopeKind, grants); err != nil { + return err + } + existingGrants, err := loadInstanceGrants(ctx, tx, instanceID) + if err != nil { + return err + } + if !grantsCoverDeclaration(release.DeclaredPermissionsJSON, release.ScopeKind, append(existingGrants, grants...)) { + return ErrInvalidScope + } + if err := insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE plugin_releases SET validation_status = ?, validation_error = '' WHERE id = ? AND instance_id = ?`, + ), ValidationValid, releaseID, instanceID); err != nil { + return err + } + if err := s.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil { + return err + } + if err := activateReleaseTx(ctx, tx, instanceID, releaseID); err != nil { + return err + } + return tx.Commit() +} + +type pendingReleaseRow struct { + PluginID string `db:"plugin_id"` + DeclaredPermissionsJSON string `db:"declared_permissions_json"` + ValidationStatus string `db:"validation_status"` + ScopeKind string `db:"scope_kind"` + Status string `db:"status"` +} + +func loadPendingReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID string) (pendingReleaseRow, error) { + var release pendingReleaseRow + if err := tx.GetContext(ctx, &release, tx.Rebind( + `SELECT r.plugin_id, r.declared_permissions_json, r.validation_status, i.scope_kind, i.status FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE r.id = ? AND r.instance_id = ?`, + ), releaseID, instanceID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return pendingReleaseRow{}, ErrNotFound + } + return pendingReleaseRow{}, err + } + if err := activationStateError(release.Status); err != nil { + return pendingReleaseRow{}, err + } + return release, nil +} + +func activateReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID string) error { + var status string + if err := tx.GetContext(ctx, &status, tx.Rebind(`SELECT status FROM plugin_instances WHERE id = ?`), instanceID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if err := activationStateError(status); err != nil { + return err + } + result, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE plugin_instances SET active_release_id = ?, status = ?, grant_generation = grant_generation + 1, updated_at = ? WHERE id = ? AND status IN (?, ?, ?)`, + ), releaseID, StatusActive, time.Now().UTC().Format(time.RFC3339Nano), instanceID, StatusPending, StatusActive, StatusError) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return activationStateError(status) + } + return nil +} + +func (s *Store) CreateRelease(ctx context.Context, release Release) error { + return s.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return s.CreateReleaseTx(ctx, tx, release) + }) +} + +// CreateReleaseTx inserts an immutable release row in an existing lifecycle +// transaction. +func (s *Store) CreateReleaseTx(ctx context.Context, tx *sqlx.Tx, release Release) error { + if release.ID == "" { + release.ID = uuid.NewString() + } + if release.InstanceID == "" || release.PluginID == "" || release.PackageDigest == "" || release.ArtifactPath == "" || release.ValidationStatus == "" { + return ErrInvalidRelease + } + if len(release.ManifestJSON) == 0 { + release.ManifestJSON = json.RawMessage(`{}`) + } + if len(release.DeclaredPermissionsJSON) == 0 { + release.DeclaredPermissionsJSON = json.RawMessage(`[]`) + } + created := release.CreatedAt + if created.IsZero() { + created = time.Now().UTC() + } + _, err := tx.ExecContext(ctx, tx.Rebind(`INSERT INTO plugin_releases (id, plugin_id, instance_id, package_digest, source_kind, source_actor_kind, source_user_id, source_task_id, source_session_id, manifest_json, declared_permissions_json, artifact_path, artifact_bytes, protocol_version, validation_status, validation_error, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), release.ID, release.PluginID, release.InstanceID, release.PackageDigest, release.SourceKind, release.SourceActorKind, release.SourceUserID, release.SourceTaskID, release.SourceSessionID, string(release.ManifestJSON), string(release.DeclaredPermissionsJSON), release.ArtifactPath, release.ArtifactBytes, release.ProtocolVersion, release.ValidationStatus, release.ValidationError, created.Format(time.RFC3339Nano)) + return err +} + +// CreateReleaseIfActiveReleaseTx inserts a release only when the editor's +// source was materialized from the currently active release. The check and +// insert are intentionally one transaction so two edit sessions cannot +// silently overwrite each other's work. +func (s *Store) CreateReleaseIfActiveReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, expectedReleaseID string, release Release) error { + if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(expectedReleaseID) == "" { + return ErrStaleCanvasEdit + } + // The no-op update both verifies the base release and holds the instance + // row lock until the surrounding edit publish transaction commits. A + // separate SELECT would allow an active release to change between the + // check and the release insert. + result, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE plugin_instances SET updated_at = updated_at WHERE id = ? AND active_release_id = ? AND status <> ?`, + ), instanceID, expectedReleaseID, StatusRemoved) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + var exists int + if err := tx.GetContext(ctx, &exists, tx.Rebind( + `SELECT COUNT(*) FROM plugin_instances WHERE id = ?`, + ), instanceID); err != nil { + return err + } + if exists == 0 { + return ErrNotFound + } + return ErrStaleCanvasEdit + } + return s.CreateReleaseTx(ctx, tx, release) +} + +// CreateReleaseIfAuthorityTx inserts a release only when the complete +// authorizing instance snapshot is still current. The conditional update and +// insert share one transaction, so promotion, archive, restore, or another +// release cannot invalidate an already-streamed source between authorization +// and durable release ownership. +func (s *Store) CreateReleaseIfAuthorityTx(ctx context.Context, tx *sqlx.Tx, instanceID string, expected PublishAuthority, release Release) error { + if strings.TrimSpace(instanceID) == "" || expected.InstanceID != instanceID || expected.ScopeKind == "" || expected.Status == "" { + return ErrStaleCanvasPublish + } + if err := activationStateError(expected.Status); err != nil { + return err + } + if release.InstanceID != instanceID { + return ErrInvalidRelease + } + result, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE plugin_instances SET updated_at = updated_at WHERE id = ? AND scope_kind = ? AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ?`, + ), instanceID, expected.ScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID, expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + var exists int + if err := tx.GetContext(ctx, &exists, tx.Rebind(`SELECT COUNT(*) FROM plugin_instances WHERE id = ?`), instanceID); err != nil { + return err + } + if exists == 0 { + return ErrNotFound + } + return ErrStaleCanvasPublish + } + return s.CreateReleaseTx(ctx, tx, release) +} + +// PruneReleases keeps the active release, the newest non-active valid release, +// and the newest pending release for one instance. Superseded release rows are +// deleted only after cleanup ownership is recorded in the same transaction. +// The active release pointer is never changed by pruning. +func (s *Store) PruneReleases(ctx context.Context, instanceID string) error { + if strings.TrimSpace(instanceID) == "" { + return ErrInvalidRelease + } + s.admission.Lock() + defer s.admission.Unlock() + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if err := s.pruneReleasesTx(ctx, tx, instanceID); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) pruneReleasesTx(ctx context.Context, tx *sqlx.Tx, instanceID string) error { + current, err := loadReleaseRetentionInstance(ctx, tx, instanceID) + if err != nil { + return err + } + releases, err := listReleaseRetentionRows(ctx, tx, instanceID) + if err != nil { + return err + } + retainedPaths, removed := classifyReleaseRetention(current.ActiveReleaseID, releases) + if err := scheduleRemovedReleaseCleanup(ctx, tx, instanceID, current.WorkspaceID, retainedPaths, removed); err != nil { + return err + } + return deleteRemovedReleases(ctx, tx, instanceID, removed) +} + +type releaseRetentionInstance struct { + WorkspaceID string `db:"workspace_id"` + Status string `db:"status"` + ActiveReleaseID string `db:"active_release_id"` +} + +type releaseRetentionRow struct { + ID string `db:"id"` + ArtifactPath string `db:"artifact_path"` + ValidationStatus string `db:"validation_status"` +} + +func loadReleaseRetentionInstance(ctx context.Context, tx *sqlx.Tx, instanceID string) (releaseRetentionInstance, error) { + var current releaseRetentionInstance + if err := tx.GetContext(ctx, ¤t, tx.Rebind( + `SELECT workspace_id, status, active_release_id FROM plugin_instances WHERE id = ?`, + ), instanceID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return releaseRetentionInstance{}, ErrNotFound + } + return releaseRetentionInstance{}, err + } + if current.Status == StatusRemoved { + return releaseRetentionInstance{}, ErrNotFound + } + return current, nil +} + +func listReleaseRetentionRows(ctx context.Context, tx *sqlx.Tx, instanceID string) ([]releaseRetentionRow, error) { + rows, err := tx.QueryxContext(ctx, tx.Rebind( + `SELECT id, artifact_path, validation_status FROM plugin_releases WHERE instance_id = ? ORDER BY created_at DESC, id DESC`, + ), instanceID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var releases []releaseRetentionRow + for rows.Next() { + var release releaseRetentionRow + if err := rows.StructScan(&release); err != nil { + return nil, err + } + releases = append(releases, release) + } + return releases, rows.Err() +} + +func classifyReleaseRetention(activeReleaseID string, releases []releaseRetentionRow) (map[string]bool, []releaseRetentionRow) { + retainedPaths := make(map[string]bool, 3) + removed := make([]releaseRetentionRow, 0, len(releases)) + priorKept, pendingKept := false, false + for _, release := range releases { + if release.ID == activeReleaseID { + retainedPaths[release.ArtifactPath] = true + pendingKept = pendingKept || release.ValidationStatus == ValidationPendingPermission + continue + } + keep := false + switch release.ValidationStatus { + case ValidationValid: + keep, priorKept = !priorKept, true + case ValidationPendingPermission: + keep, pendingKept = !pendingKept, true + } + if keep { + retainedPaths[release.ArtifactPath] = true + continue + } + removed = append(removed, release) + } + return retainedPaths, removed +} + +func scheduleRemovedReleaseCleanup(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID string, retainedPaths map[string]bool, removed []releaseRetentionRow) error { + scheduled := make(map[string]bool, len(removed)) + for _, release := range removed { + if retainedPaths[release.ArtifactPath] || scheduled[release.ArtifactPath] { + continue + } + referenced, err := artifactReferencedByOtherInstanceTx(ctx, tx, instanceID, release.ArtifactPath) + if err != nil { + return err + } + if referenced { + continue + } + if err := enqueueCleanupJobTx(ctx, tx, workspaceID, instanceID, release.ArtifactPath); err != nil { + return err + } + scheduled[release.ArtifactPath] = true + } + return nil +} + +func deleteRemovedReleases(ctx context.Context, tx *sqlx.Tx, instanceID string, removed []releaseRetentionRow) error { + for _, release := range removed { + if _, err := tx.ExecContext(ctx, tx.Rebind( + `DELETE FROM plugin_releases WHERE id = ? AND instance_id = ?`, + ), release.ID, instanceID); err != nil { + return err + } + } + return nil +} + +func (s *Store) ListReleases(ctx context.Context, instanceID string) ([]Release, error) { + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, plugin_id, instance_id, package_digest, source_kind, source_actor_kind, source_user_id, source_task_id, source_session_id, manifest_json, declared_permissions_json, artifact_path, artifact_bytes, protocol_version, validation_status, validation_error, created_at FROM plugin_releases WHERE instance_id = ? ORDER BY created_at DESC, id DESC`), instanceID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var releases []Release + for rows.Next() { + var row releaseRow + if err := rows.StructScan(&row); err != nil { + return nil, err + } + release, err := row.release() + if err != nil { + return nil, err + } + releases = append(releases, release) + } + return releases, rows.Err() +} + +func (s *Store) GetRelease(ctx context.Context, id string) (Release, error) { + var row releaseRow + err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`SELECT id, plugin_id, instance_id, package_digest, source_kind, source_actor_kind, source_user_id, source_task_id, source_session_id, manifest_json, declared_permissions_json, artifact_path, artifact_bytes, protocol_version, validation_status, validation_error, created_at FROM plugin_releases WHERE id = ?`), id) + if errors.Is(err, sql.ErrNoRows) { + return Release{}, ErrNotFound + } + if err != nil { + return Release{}, err + } + return row.release() +} + +// ReconcileArtifacts checks retained release metadata before runtime routes +// are registered. The checker must not execute package code. Unavailable +// artifacts remain in the database for recovery and are marked with a stable +// validation status. +func (s *Store) ReconcileArtifacts(ctx context.Context, checker func(path, digest string, bytes int64) (ArtifactCheck, error)) (int, error) { + if checker == nil { + return 0, errors.New("artifact checker is nil") + } + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, package_digest, artifact_path, artifact_bytes FROM plugin_releases WHERE validation_status IN (?, ?)`), ValidationValid, ValidationPendingPermission) + if err != nil { + return 0, err + } + type candidate struct { + id, digest, path string + bytes int64 + } + var candidates []candidate + for rows.Next() { + var item candidate + if err := rows.Scan(&item.id, &item.digest, &item.path, &item.bytes); err != nil { + _ = rows.Close() + return 0, err + } + candidates = append(candidates, item) + } + if err := rows.Close(); err != nil { + return 0, err + } + marked := 0 + for _, item := range candidates { + check, err := checker(item.path, item.digest, item.bytes) + if err != nil { + return marked, err + } + if check.Available { + continue + } + status := ValidationUnavailable + if check.Reason == "" { + check.Reason = "artifact_unavailable" + } + if _, err := s.db.ExecContext(ctx, s.db.Rebind(`UPDATE plugin_releases SET validation_status = ?, validation_error = ? WHERE id = ? AND validation_status IN (?, ?)`), status, check.Reason, item.id, ValidationValid, ValidationPendingPermission); err != nil { + return marked, err + } + marked++ + } + return marked, nil +} + +func (s *Store) AddGrant(ctx context.Context, grant Grant) error { + if grant.InstanceID == "" || grant.PermissionKind == "" || grant.ScopeCeiling == "" || grant.ApprovedBy == "" { + return ErrInvalidScope + } + if grant.ApprovedAt.IsZero() { + grant.ApprovedAt = time.Now().UTC() + } + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + var exists int + if err := tx.GetContext(ctx, &exists, tx.Rebind(`SELECT COUNT(*) FROM plugin_instances WHERE id = ? AND status <> ?`), grant.InstanceID, StatusRemoved); err != nil { + return err + } + if exists == 0 { + return ErrNotFound + } + if _, err := tx.ExecContext(ctx, tx.Rebind(`INSERT INTO plugin_instance_grants (plugin_instance_id, permission_kind, resource, network_origin, scope_ceiling, approved_by, approved_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(plugin_instance_id, permission_kind, resource, network_origin) DO UPDATE SET scope_ceiling = excluded.scope_ceiling, approved_by = excluded.approved_by, approved_at = excluded.approved_at`), grant.InstanceID, grant.PermissionKind, grant.Resource, grant.NetworkOrigin, grant.ScopeCeiling, grant.ApprovedBy, grant.ApprovedAt.Format(time.RFC3339Nano)); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, tx.Rebind(`UPDATE plugin_instances SET grant_generation = grant_generation + 1, updated_at = ? WHERE id = ? AND status <> ?`), time.Now().UTC().Format(time.RFC3339Nano), grant.InstanceID, StatusRemoved); err != nil { + return err + } + return tx.Commit() +} + +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 { + return nil, err + } + defer func() { _ = rows.Close() }() + var grants []Grant + for rows.Next() { + var row struct { + InstanceID string `db:"plugin_instance_id"` + PermissionKind string `db:"permission_kind"` + Resource string `db:"resource"` + NetworkOrigin string `db:"network_origin"` + ScopeCeiling string `db:"scope_ceiling"` + ApprovedBy string `db:"approved_by"` + ApprovedAt string `db:"approved_at"` + } + if err := rows.StructScan(&row); err != nil { + return nil, err + } + approvedAt, err := time.Parse(time.RFC3339Nano, row.ApprovedAt) + if err != nil { + return nil, err + } + grants = append(grants, Grant{InstanceID: row.InstanceID, PermissionKind: row.PermissionKind, Resource: row.Resource, NetworkOrigin: row.NetworkOrigin, ScopeCeiling: row.ScopeCeiling, ApprovedBy: row.ApprovedBy, ApprovedAt: approvedAt}) + } + return grants, rows.Err() +} + +func (s *Store) ReserveBytes(ctx context.Context, workspaceID string, bytes, workspaceLimit, installationLimit int64) (Reservation, error) { + if strings.TrimSpace(workspaceID) == "" || bytes <= 0 || workspaceLimit <= 0 || installationLimit <= 0 { + return Reservation{}, ErrInvalidRelease + } + s.admission.Lock() + defer s.admission.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return Reservation{}, err + } + defer func() { _ = tx.Rollback() }() + now := time.Now().UTC() + _, _ = tx.ExecContext(ctx, tx.Rebind(`DELETE FROM plugin_storage_reservations WHERE status = ? AND expires_at < ?`), "reserved", now.Format(time.RFC3339Nano)) + var workspaceBytes, installationBytes int64 + if err := tx.GetContext(ctx, &workspaceBytes, tx.Rebind(`SELECT COALESCE(SUM(bytes), 0) FROM plugin_storage_reservations WHERE workspace_id = ? AND status = ?`), workspaceID, "reserved"); err != nil { + return Reservation{}, err + } + if err := tx.GetContext(ctx, &installationBytes, tx.Rebind(`SELECT COALESCE(SUM(bytes), 0) FROM plugin_storage_reservations WHERE status = ?`), "reserved"); err != nil { + return Reservation{}, err + } + var workspaceReleaseBytes, installationReleaseBytes int64 + if err := tx.GetContext(ctx, &workspaceReleaseBytes, tx.Rebind( + `SELECT COALESCE(SUM(artifact_bytes), 0) FROM (SELECT r.artifact_path, MAX(r.artifact_bytes) AS artifact_bytes FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE i.workspace_id = ? GROUP BY r.artifact_path) AS retained_artifacts`, + ), workspaceID); err != nil { + return Reservation{}, err + } + if err := tx.GetContext(ctx, &installationReleaseBytes, tx.Rebind( + `SELECT COALESCE(SUM(artifact_bytes), 0) FROM (SELECT r.artifact_path, MAX(r.artifact_bytes) AS artifact_bytes FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id GROUP BY r.artifact_path) AS retained_artifacts`, + )); err != nil { + return Reservation{}, err + } + workspaceBytes += workspaceReleaseBytes + installationBytes += installationReleaseBytes + if exceedsStorageLimit(workspaceBytes, bytes, workspaceLimit) { + return Reservation{}, ErrWorkspaceStorageLimit + } + if exceedsStorageLimit(installationBytes, bytes, installationLimit) { + return Reservation{}, ErrInstallationStorageLimit + } + reservation := Reservation{ID: uuid.NewString(), WorkspaceID: workspaceID, Bytes: bytes, Status: "reserved", CreatedAt: now, ExpiresAt: now.Add(30 * time.Minute)} + _, err = tx.ExecContext(ctx, tx.Rebind(`INSERT INTO plugin_storage_reservations (id, workspace_id, bytes, status, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)`), reservation.ID, reservation.WorkspaceID, reservation.Bytes, reservation.Status, reservation.CreatedAt.Format(time.RFC3339Nano), reservation.ExpiresAt.Format(time.RFC3339Nano)) + if err != nil { + return Reservation{}, err + } + if err := tx.Commit(); err != nil { + return Reservation{}, err + } + return reservation, nil +} + +func exceedsStorageLimit(current, requested, limit int64) bool { + return current > limit || requested > limit-current +} + +func (s *Store) ReleaseBytes(ctx context.Context, id string) error { + s.admission.Lock() + defer s.admission.Unlock() + result, err := s.db.ExecContext(ctx, s.db.Rebind(`UPDATE plugin_storage_reservations SET status = ? WHERE id = ? AND status = ?`), "released", id, "reserved") + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrNotFound + } + return nil +} + +// ResizeReservation adjusts an active reservation after validation has +// measured the exact artifact size. The admission check remains in the same +// transaction, so a concurrent publish cannot consume the released headroom. +func (s *Store) ResizeReservation(ctx context.Context, id string, bytes, workspaceLimit, installationLimit int64) error { + if strings.TrimSpace(id) == "" || bytes <= 0 || workspaceLimit <= 0 || installationLimit <= 0 { + return ErrInvalidRelease + } + s.admission.Lock() + defer s.admission.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + reservation, err := loadReservationTx(ctx, tx, id) + if err != nil { + return err + } + if reservation.Status != "reserved" { + return ErrNotFound + } + now := time.Now().UTC() + _, _ = tx.ExecContext(ctx, tx.Rebind(`DELETE FROM plugin_storage_reservations WHERE status = ? AND expires_at < ?`), "reserved", now.Format(time.RFC3339Nano)) + workspaceBytes, installationBytes, workspaceReleaseBytes, installationReleaseBytes, err := reservationUsageTx(ctx, tx, id, reservation.WorkspaceID) + if err != nil { + return err + } + if exceedsStorageLimit(workspaceBytes+workspaceReleaseBytes, bytes, workspaceLimit) { + return ErrWorkspaceStorageLimit + } + if exceedsStorageLimit(installationBytes+installationReleaseBytes, bytes, installationLimit) { + return ErrInstallationStorageLimit + } + _, err = tx.ExecContext(ctx, tx.Rebind(`UPDATE plugin_storage_reservations SET bytes = ? WHERE id = ? AND status = ?`), bytes, id, "reserved") + if err != nil { + return err + } + return tx.Commit() +} + +func loadReservationTx(ctx context.Context, tx *sqlx.Tx, id string) (Reservation, error) { + var reservation Reservation + var createdAt, expiresAt string + if err := tx.QueryRowxContext(ctx, tx.Rebind(` + SELECT id, workspace_id, bytes, status, created_at, expires_at + FROM plugin_storage_reservations WHERE id = ? + `), id).Scan(&reservation.ID, &reservation.WorkspaceID, &reservation.Bytes, &reservation.Status, &createdAt, &expiresAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Reservation{}, ErrNotFound + } + return Reservation{}, err + } + return reservation, nil +} + +func reservationUsageTx(ctx context.Context, tx *sqlx.Tx, reservationID, workspaceID string) (int64, int64, int64, int64, error) { + var workspaceBytes, installationBytes int64 + if err := tx.GetContext(ctx, &workspaceBytes, tx.Rebind(`SELECT COALESCE(SUM(bytes), 0) FROM plugin_storage_reservations WHERE workspace_id = ? AND status = ? AND id <> ?`), workspaceID, "reserved", reservationID); err != nil { + return 0, 0, 0, 0, err + } + if err := tx.GetContext(ctx, &installationBytes, tx.Rebind(`SELECT COALESCE(SUM(bytes), 0) FROM plugin_storage_reservations WHERE status = ? AND id <> ?`), "reserved", reservationID); err != nil { + return 0, 0, 0, 0, err + } + var workspaceReleaseBytes, installationReleaseBytes int64 + if err := tx.GetContext(ctx, &workspaceReleaseBytes, tx.Rebind( + `SELECT COALESCE(SUM(artifact_bytes), 0) FROM (SELECT r.artifact_path, MAX(r.artifact_bytes) AS artifact_bytes FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE i.workspace_id = ? GROUP BY r.artifact_path) AS retained_artifacts`, + ), workspaceID); err != nil { + return 0, 0, 0, 0, err + } + if err := tx.GetContext(ctx, &installationReleaseBytes, tx.Rebind( + `SELECT COALESCE(SUM(artifact_bytes), 0) FROM (SELECT r.artifact_path, MAX(r.artifact_bytes) AS artifact_bytes FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id GROUP BY r.artifact_path) AS retained_artifacts`, + )); err != nil { + return 0, 0, 0, 0, err + } + return workspaceBytes, installationBytes, workspaceReleaseBytes, installationReleaseBytes, nil +} + +func (s *Store) AddCleanupJob(ctx context.Context, job CleanupJob) error { + if job.ID == "" { + job.ID = uuid.NewString() + } + if job.ArtifactPath == "" { + return ErrInvalidRelease + } + now := time.Now().UTC() + if job.CreatedAt.IsZero() { + job.CreatedAt = now + } + if job.NextAttemptAt.IsZero() { + job.NextAttemptAt = now + } + if job.Status == "" { + job.Status = CleanupPending + } + if job.Status != CleanupPending && job.Status != CleanupRetryWait { + return ErrInvalidRelease + } + _, err := s.db.ExecContext(ctx, s.db.Rebind(`INSERT INTO plugin_artifact_cleanup_jobs (id, workspace_id, instance_id, artifact_path, status, attempts, last_error, created_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), job.ID, job.WorkspaceID, job.InstanceID, job.ArtifactPath, job.Status, job.Attempts, job.LastError, job.CreatedAt.Format(time.RFC3339Nano), job.NextAttemptAt.Format(time.RFC3339Nano)) + return err +} + +// ClaimCleanupJob atomically claims the oldest due cleanup job. The store +// mutex protects callers in one process; the transaction and conditional +// update keep a second backend process from claiming the same row. +func (s *Store) ClaimCleanupJob(ctx context.Context, now time.Time) (CleanupJob, bool, error) { + if now.IsZero() { + now = time.Now().UTC() + } + s.admission.Lock() + defer s.admission.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return CleanupJob{}, false, err + } + defer func() { _ = tx.Rollback() }() + var row cleanupRow + err = tx.GetContext(ctx, &row, tx.Rebind(` + SELECT id, workspace_id, instance_id, artifact_path, status, attempts, + last_error, created_at, next_attempt_at + FROM plugin_artifact_cleanup_jobs + WHERE status IN (?, ?) AND next_attempt_at <= ? + ORDER BY next_attempt_at, created_at, id + LIMIT 1 + `), CleanupPending, CleanupRetryWait, now.UTC().Format(time.RFC3339Nano)) + if errors.Is(err, sql.ErrNoRows) { + return CleanupJob{}, false, nil + } + if err != nil { + return CleanupJob{}, false, err + } + result, err := tx.ExecContext(ctx, tx.Rebind(` + UPDATE plugin_artifact_cleanup_jobs + SET status = ?, attempts = attempts + 1 + WHERE id = ? AND status IN (?, ?) AND next_attempt_at <= ? + `), CleanupRunning, row.ID, CleanupPending, CleanupRetryWait, now.UTC().Format(time.RFC3339Nano)) + if err != nil { + return CleanupJob{}, false, err + } + affected, err := result.RowsAffected() + if err != nil { + return CleanupJob{}, false, err + } + if affected == 0 { + return CleanupJob{}, false, nil + } + row.Status = CleanupRunning + row.Attempts++ + job, err := row.cleanupJob() + if err != nil { + return CleanupJob{}, false, err + } + if err := tx.Commit(); err != nil { + return CleanupJob{}, false, err + } + return job, true, nil +} + +// RetryCleanupJob returns a claimed job to the due queue with durable +// diagnostic text. The next-attempt timestamp is supplied by the worker so +// retry backoff remains explicit and testable. +func (s *Store) RetryCleanupJob(ctx context.Context, id string, nextAttemptAt time.Time, cause error) error { + if strings.TrimSpace(id) == "" { + return ErrNotFound + } + if nextAttemptAt.IsZero() { + nextAttemptAt = time.Now().UTC() + } + lastError := "" + if cause != nil { + lastError = cause.Error() + if len(lastError) > 4096 { + lastError = lastError[:4096] + } + } + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE plugin_artifact_cleanup_jobs + SET status = ?, last_error = ?, next_attempt_at = ? + WHERE id = ? AND status = ? + `), CleanupRetryWait, lastError, nextAttemptAt.UTC().Format(time.RFC3339Nano), id, CleanupRunning) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrNotFound + } + return nil +} + +// CompleteCleanupJob marks a claimed artifact cleanup as finished. Completion +// is idempotent so a worker can safely repeat its final acknowledgement. +func (s *Store) CompleteCleanupJob(ctx context.Context, id string) error { + if strings.TrimSpace(id) == "" { + return ErrNotFound + } + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE plugin_artifact_cleanup_jobs + SET status = ? + WHERE id = ? AND status = ? + `), CleanupCompleted, id, CleanupRunning) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected > 0 { + return nil + } + var status string + if err := s.ro.GetContext(ctx, &status, s.ro.Rebind(`SELECT status FROM plugin_artifact_cleanup_jobs WHERE id = ?`), id); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } else if status == CleanupCompleted { + return nil + } + return ErrNotFound +} + +// RemoveArtifactIfUnreferenced rechecks artifact ownership and completes a +// claimed cleanup job in one writer transaction. The removal callback runs +// while the admission lock and SQLite write transaction are held, so a +// concurrent release cannot republish the same artifact path between the +// ownership check and filesystem removal. +func (s *Store) RemoveArtifactIfUnreferenced(ctx context.Context, id, artifactPath string, remove func() error) (bool, error) { + if strings.TrimSpace(id) == "" || strings.TrimSpace(artifactPath) == "" || remove == nil { + return false, ErrInvalidRelease + } + s.admission.Lock() + defer s.admission.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, tx.Rebind(` + UPDATE plugin_artifact_cleanup_jobs + SET status = ? + WHERE id = ? AND artifact_path = ? AND status = ? + `), CleanupRunning, id, artifactPath, CleanupRunning) + if err != nil { + return false, err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return false, ErrNotFound + } + var references int + if err := tx.GetContext(ctx, &references, tx.Rebind(`SELECT COUNT(*) FROM plugin_releases WHERE artifact_path = ?`), artifactPath); err != nil { + return false, err + } + if references > 0 { + if _, err := tx.ExecContext(ctx, tx.Rebind(`UPDATE plugin_artifact_cleanup_jobs SET status = ? WHERE id = ? AND status = ?`), CleanupCompleted, id, CleanupRunning); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return false, nil + } + if err := remove(); err != nil { + return false, err + } + if _, err := tx.ExecContext(ctx, tx.Rebind(`UPDATE plugin_artifact_cleanup_jobs SET status = ? WHERE id = ? AND status = ?`), CleanupCompleted, id, CleanupRunning); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} + +// ListCleanupJobs returns the durable cleanup inventory in stable order. +func (s *Store) ListCleanupJobs(ctx context.Context) ([]CleanupJob, error) { + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(` + SELECT id, workspace_id, instance_id, artifact_path, status, attempts, + last_error, created_at, next_attempt_at + FROM plugin_artifact_cleanup_jobs + ORDER BY created_at, id + `)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + jobs := make([]CleanupJob, 0) + for rows.Next() { + var row cleanupRow + if err := rows.StructScan(&row); err != nil { + return nil, err + } + job, err := row.cleanupJob() + if err != nil { + return nil, err + } + jobs = append(jobs, job) + } + return jobs, rows.Err() +} + +// RequeueRunningCleanupJobs makes jobs recoverable after a process restart. +func (s *Store) RequeueRunningCleanupJobs(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE plugin_artifact_cleanup_jobs + SET status = ?, next_attempt_at = ? + WHERE status = ? + `), CleanupPending, time.Now().UTC().Format(time.RFC3339Nano), CleanupRunning) + return err +} + +type cleanupRow struct { + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + InstanceID string `db:"instance_id"` + ArtifactPath string `db:"artifact_path"` + Status string `db:"status"` + Attempts int `db:"attempts"` + LastError string `db:"last_error"` + CreatedAt string `db:"created_at"` + NextAttemptAt string `db:"next_attempt_at"` +} + +func (r cleanupRow) cleanupJob() (CleanupJob, error) { + createdAt, err := time.Parse(time.RFC3339Nano, r.CreatedAt) + if err != nil { + return CleanupJob{}, fmt.Errorf("parse cleanup created_at: %w", err) + } + nextAttemptAt, err := time.Parse(time.RFC3339Nano, r.NextAttemptAt) + if err != nil { + return CleanupJob{}, fmt.Errorf("parse cleanup next_attempt_at: %w", err) + } + return CleanupJob{ + ID: r.ID, + WorkspaceID: r.WorkspaceID, + InstanceID: r.InstanceID, + ArtifactPath: r.ArtifactPath, + Status: r.Status, + Attempts: r.Attempts, + LastError: r.LastError, + CreatedAt: createdAt, + NextAttemptAt: nextAttemptAt, + }, nil +} + +func enqueueCleanupJobTx(ctx context.Context, tx *sqlx.Tx, workspaceID, instanceID, artifactPath string) error { + if strings.TrimSpace(artifactPath) == "" { + return ErrInvalidRelease + } + var existing int + if err := tx.GetContext(ctx, &existing, tx.Rebind( + `SELECT COUNT(*) FROM plugin_artifact_cleanup_jobs WHERE instance_id = ? AND artifact_path = ? AND status <> ?`, + ), instanceID, artifactPath, "completed"); err != nil { + return err + } + if existing > 0 { + return nil + } + now := time.Now().UTC() + _, err := tx.ExecContext(ctx, tx.Rebind( + `INSERT INTO plugin_artifact_cleanup_jobs (id, workspace_id, instance_id, artifact_path, status, attempts, last_error, created_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ), uuid.NewString(), workspaceID, instanceID, artifactPath, "pending", 0, "", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + return err +} + +func artifactReferencedByOtherInstanceTx(ctx context.Context, tx *sqlx.Tx, instanceID, artifactPath string) (bool, error) { + var references int + if err := tx.GetContext(ctx, &references, tx.Rebind( + `SELECT COUNT(*) FROM plugin_releases WHERE artifact_path = ? AND instance_id <> ?`, + ), artifactPath, instanceID); err != nil { + return false, err + } + return references > 0, nil +} + +func (s *Store) RemoveInstance(ctx context.Context, id string) error { + return s.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return s.RemoveInstanceTx(ctx, tx, id) + }) +} + +// RemoveInstanceTx records cleanup ownership, removes release and grant rows, +// and marks an instance removed in an existing lifecycle transaction. +func (s *Store) RemoveInstanceTx(ctx context.Context, tx *sqlx.Tx, id string) error { + var workspaceID string + if err := tx.GetContext(ctx, &workspaceID, tx.Rebind(`SELECT workspace_id FROM plugin_instances WHERE id = ?`), id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + var paths []string + rows, err := tx.QueryxContext(ctx, tx.Rebind(`SELECT artifact_path FROM plugin_releases WHERE instance_id = ?`), id) + if err != nil { + return err + } + for rows.Next() { + var path string + if err := rows.Scan(&path); err != nil { + _ = rows.Close() + return err + } + paths = append(paths, path) + } + if err := rows.Close(); err != nil { + return err + } + for _, artifactPath := range paths { + referenced, err := artifactReferencedByOtherInstanceTx(ctx, tx, id, artifactPath) + if err != nil { + return err + } + if referenced { + continue + } + if err := enqueueCleanupJobTx(ctx, tx, workspaceID, id, artifactPath); err != nil { + return err + } + } + result, err := tx.ExecContext(ctx, tx.Rebind(`UPDATE plugin_instances SET status = ?, active_release_id = '', updated_at = ? WHERE id = ? AND status <> ?`), StatusRemoved, time.Now().UTC().Format(time.RFC3339Nano), id, StatusRemoved) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrNotFound + } + if _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM plugin_releases WHERE instance_id = ?`), id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM plugin_instance_grants WHERE plugin_instance_id = ?`), id); err != nil { + return err + } + return nil +} + +type instanceRow struct { + ID string `db:"id"` + PluginID string `db:"plugin_id"` + SourceKind string `db:"source_kind"` + ScopeKind string `db:"scope_kind"` + WorkspaceID string `db:"workspace_id"` + TaskID string `db:"task_id"` + SessionID string `db:"session_id"` + RepositoryID string `db:"repository_id"` + Status string `db:"status"` + ActiveReleaseID string `db:"active_release_id"` + GrantGeneration int64 `db:"grant_generation"` + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` +} + +func (r instanceRow) instance() (Instance, error) { + created, err := time.Parse(time.RFC3339Nano, r.CreatedAt) + if err != nil { + return Instance{}, err + } + updated, err := time.Parse(time.RFC3339Nano, r.UpdatedAt) + if err != nil { + return Instance{}, err + } + return Instance{ID: r.ID, PluginID: r.PluginID, SourceKind: r.SourceKind, ScopeKind: r.ScopeKind, WorkspaceID: r.WorkspaceID, TaskID: r.TaskID, SessionID: r.SessionID, RepositoryID: r.RepositoryID, Status: r.Status, ActiveReleaseID: r.ActiveReleaseID, GrantGeneration: r.GrantGeneration, CreatedAt: created, UpdatedAt: updated}, nil +} + +type releaseRow struct { + ID string `db:"id"` + PluginID string `db:"plugin_id"` + InstanceID string `db:"instance_id"` + PackageDigest string `db:"package_digest"` + SourceKind string `db:"source_kind"` + SourceActorKind string `db:"source_actor_kind"` + SourceUserID string `db:"source_user_id"` + SourceTaskID string `db:"source_task_id"` + SourceSessionID string `db:"source_session_id"` + ManifestJSON string `db:"manifest_json"` + DeclaredPermissionsJSON string `db:"declared_permissions_json"` + ArtifactPath string `db:"artifact_path"` + ArtifactBytes int64 `db:"artifact_bytes"` + ProtocolVersion int `db:"protocol_version"` + ValidationStatus string `db:"validation_status"` + ValidationError string `db:"validation_error"` + CreatedAt string `db:"created_at"` +} + +func (r releaseRow) release() (Release, error) { + created, err := time.Parse(time.RFC3339Nano, r.CreatedAt) + if err != nil { + return Release{}, err + } + return Release{ID: r.ID, PluginID: r.PluginID, InstanceID: r.InstanceID, PackageDigest: r.PackageDigest, SourceKind: r.SourceKind, SourceActorKind: r.SourceActorKind, SourceUserID: r.SourceUserID, SourceTaskID: r.SourceTaskID, SourceSessionID: r.SourceSessionID, ManifestJSON: json.RawMessage(r.ManifestJSON), DeclaredPermissionsJSON: json.RawMessage(r.DeclaredPermissionsJSON), ArtifactPath: r.ArtifactPath, ArtifactBytes: r.ArtifactBytes, ProtocolVersion: r.ProtocolVersion, ValidationStatus: r.ValidationStatus, ValidationError: r.ValidationError, CreatedAt: created}, nil +} diff --git a/apps/backend/internal/plugins/instances/store_cleanup_test.go b/apps/backend/internal/plugins/instances/store_cleanup_test.go new file mode 100644 index 0000000000..106ec225f3 --- /dev/null +++ b/apps/backend/internal/plugins/instances/store_cleanup_test.go @@ -0,0 +1,54 @@ +package instances + +import ( + "context" + "testing" + "time" +) + +func TestRemoveArtifactIfUnreferencedRechecksReleaseOwnership(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.Create(ctx, Instance{ + ID: "instance-1", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, + ScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1", Status: StatusActive, + }); err != nil { + t.Fatalf("create instance: %v", err) + } + if err := store.AddCleanupJob(ctx, CleanupJob{ID: "cleanup-1", WorkspaceID: "workspace-1", InstanceID: "instance-1", ArtifactPath: "releases/shared"}); err != nil { + t.Fatalf("add cleanup job: %v", err) + } + job, claimed, err := store.ClaimCleanupJob(ctx, nowForCleanupTest()) + if err != nil || !claimed { + t.Fatalf("claim cleanup job = %#v, %v, want claimed", job, err) + } + if err := store.CreateRelease(ctx, Release{ + ID: "release-1", PluginID: "canvas-board", InstanceID: "instance-1", PackageDigest: "digest-1", + SourceKind: SourceLocalCanvas, ArtifactPath: "releases/shared", ValidationStatus: ValidationValid, + }); err != nil { + t.Fatalf("republish artifact: %v", err) + } + removed := false + if got, err := store.RemoveArtifactIfUnreferenced(ctx, job.ID, job.ArtifactPath, func() error { + removed = true + return nil + }); err != nil { + t.Fatalf("remove artifact: %v", err) + } else if got { + t.Fatal("remove artifact reported removal for a referenced path") + } + if removed { + t.Fatal("remove callback ran for a referenced path") + } + jobs, err := store.ListCleanupJobs(ctx) + if err != nil { + t.Fatalf("list cleanup jobs: %v", err) + } + if len(jobs) != 1 || jobs[0].Status != CleanupCompleted { + t.Fatalf("cleanup jobs = %#v, want completed job", jobs) + } +} + +func nowForCleanupTest() (now time.Time) { + return time.Now().UTC().Add(time.Second) +} diff --git a/apps/backend/internal/plugins/instances/store_test.go b/apps/backend/internal/plugins/instances/store_test.go new file mode 100644 index 0000000000..835ed0fd62 --- /dev/null +++ b/apps/backend/internal/plugins/instances/store_test.go @@ -0,0 +1,462 @@ +package instances + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + conn, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "instances.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + pool := db.NewPool(sqlx.NewDb(conn, "sqlite3"), sqlx.NewDb(conn, "sqlite3")) + t.Cleanup(func() { _ = pool.Close() }) + store, err := NewStore(pool) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + return store +} + +func TestValidateScopeRequiresOnlyIdentifiersForScope(t *testing.T) { + if err := ValidateScope(ScopeTask, ScopeIdentifiers{WorkspaceID: "workspace-1", TaskID: "task-1"}); err != nil { + t.Fatalf("task scope unexpectedly invalid: %v", err) + } + if err := ValidateScope(ScopeTask, ScopeIdentifiers{TaskID: "task-1"}); !errors.Is(err, ErrInvalidScope) { + t.Fatalf("missing workspace error = %v, want ErrInvalidScope", err) + } + if err := ValidateScope(ScopeWorkspace, ScopeIdentifiers{WorkspaceID: "workspace-1", TaskID: "task-1"}); !errors.Is(err, ErrInvalidScope) { + t.Fatalf("mixed workspace error = %v, want ErrInvalidScope", err) + } +} + +func TestCreateInstanceAdmissionIsAtomicAtTaskAndWorkspaceLimits(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + var ( + wg sync.WaitGroup + mu sync.Mutex + created int + lastErr error + ) + for i := 0; i < MaxTaskInstances+5; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + instance := Instance{ + ID: "instance-" + time.Now().Format("150405.000000000") + "-" + string(rune('a'+i)), + PluginID: "canvas-board", + SourceKind: SourceLocalCanvas, + ScopeKind: ScopeTask, + WorkspaceID: "workspace-1", + TaskID: "task-1", + Status: StatusActive, + } + if err := store.Create(ctx, instance); err != nil { + mu.Lock() + lastErr = err + mu.Unlock() + return + } + mu.Lock() + created++ + mu.Unlock() + }(i) + } + wg.Wait() + if created != MaxTaskInstances { + t.Fatalf("created = %d, want %d (last error %v)", created, MaxTaskInstances, lastErr) + } + count, err := store.CountActive(ctx, ScopeTask, "task-1") + if err != nil { + t.Fatalf("CountActive: %v", err) + } + if count != MaxTaskInstances { + t.Fatalf("task count = %d, want %d", count, MaxTaskInstances) + } +} + +func TestArchiveRestoreUsesTheSameAdmission(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + for i := 0; i < MaxTaskInstances-1; i++ { + if err := store.Create(ctx, Instance{ID: "instance-" + string(rune('a'+i)), PluginID: "p", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, WorkspaceID: "w", TaskID: "t", Status: StatusActive}); err != nil { + t.Fatalf("Create(%d): %v", i, err) + } + } + if err := store.Archive(ctx, "instance-a"); err != nil { + t.Fatalf("Archive: %v", err) + } + if err := store.Restore(ctx, "instance-a"); err != nil { + t.Fatalf("Restore: %v", err) + } + if err := store.Create(ctx, Instance{ID: "instance-z", PluginID: "p", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, WorkspaceID: "w", TaskID: "t", Status: StatusActive}); err != nil { + t.Fatalf("fill task limit: %v", err) + } + if err := store.Archive(ctx, "instance-b"); err != nil { + t.Fatalf("second Archive: %v", err) + } + if err := store.Restore(ctx, "instance-b"); !errors.Is(err, ErrTaskCanvasLimit) { + t.Fatalf("second Restore = %v, want ErrTaskCanvasLimit", err) + } +} + +func TestReserveBytesRejectsWorkspaceAndInstallationOverages(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + first, err := store.ReserveBytes(ctx, "workspace-1", 8, 10, 100) + if err != nil { + t.Fatalf("first reservation: %v", err) + } + if _, err := store.ReserveBytes(ctx, "workspace-1", 3, 10, 100); !errors.Is(err, ErrWorkspaceStorageLimit) { + t.Fatalf("workspace overage = %v, want ErrWorkspaceStorageLimit", err) + } + if _, err := store.ReserveBytes(ctx, "workspace-2", 93, 100, 100); !errors.Is(err, ErrInstallationStorageLimit) { + t.Fatalf("installation overage = %v, want ErrInstallationStorageLimit", err) + } + if err := store.ReleaseBytes(ctx, first.ID); err != nil { + t.Fatalf("release reservation: %v", err) + } +} + +func TestReserveBytesCountsRetainedReleaseArtifactsAfterReservationRelease(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.Create(ctx, Instance{ + ID: "instance-1", + PluginID: "canvas-board", + SourceKind: SourceLocalCanvas, + ScopeKind: ScopeWorkspace, + WorkspaceID: "workspace-1", + Status: StatusActive, + }); err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.CreateRelease(ctx, Release{ + ID: "release-1", + PluginID: "canvas-board", + InstanceID: "instance-1", + PackageDigest: "digest-1", + SourceKind: SourceLocalCanvas, + SourceActorKind: "agent", + ArtifactPath: "releases/digest-1", + ArtifactBytes: 8, + ValidationStatus: ValidationValid, + }); err != nil { + t.Fatalf("CreateRelease: %v", err) + } + + reservation, err := store.ReserveBytes(ctx, "workspace-1", 2, 10, 10) + if err != nil { + t.Fatalf("reserve remaining capacity: %v", err) + } + if err := store.ReleaseBytes(ctx, reservation.ID); err != nil { + t.Fatalf("release reservation: %v", err) + } + if _, err := store.ReserveBytes(ctx, "workspace-1", 3, 10, 10); !errors.Is(err, ErrWorkspaceStorageLimit) { + t.Fatalf("workspace reservation after release = %v, want ErrWorkspaceStorageLimit", err) + } + if _, err := store.ReserveBytes(ctx, "workspace-2", 3, 10, 10); !errors.Is(err, ErrInstallationStorageLimit) { + t.Fatalf("installation reservation after release = %v, want ErrInstallationStorageLimit", err) + } +} + +func TestReserveBytesDoesNotDoubleCountSharedRetainedArtifacts(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + for _, instance := range []Instance{ + {ID: "instance-1", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, ScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1", Status: StatusActive}, + {ID: "instance-2", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, ScopeKind: ScopeWorkspace, WorkspaceID: "workspace-2", Status: StatusActive}, + } { + if err := store.Create(ctx, instance); err != nil { + t.Fatalf("Create(%s): %v", instance.ID, err) + } + } + for _, release := range []Release{ + {ID: "release-1", PluginID: "canvas-board", InstanceID: "instance-1", PackageDigest: "digest-shared", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", ArtifactPath: "releases/shared", ArtifactBytes: 8, ValidationStatus: ValidationValid}, + {ID: "release-2", PluginID: "canvas-board", InstanceID: "instance-1", PackageDigest: "digest-shared", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", ArtifactPath: "releases/shared", ArtifactBytes: 8, ValidationStatus: ValidationValid}, + {ID: "release-3", PluginID: "canvas-board", InstanceID: "instance-2", PackageDigest: "digest-shared", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", ArtifactPath: "releases/shared", ArtifactBytes: 8, ValidationStatus: ValidationValid}, + } { + if err := store.CreateRelease(ctx, release); err != nil { + t.Fatalf("CreateRelease(%s): %v", release.ID, err) + } + } + + workspaceReservation, err := store.ReserveBytes(ctx, "workspace-1", 2, 10, 10) + if err != nil { + t.Fatalf("reserve shared workspace capacity: %v", err) + } + if err := store.ReleaseBytes(ctx, workspaceReservation.ID); err != nil { + t.Fatalf("release workspace reservation: %v", err) + } + installationReservation, err := store.ReserveBytes(ctx, "workspace-2", 2, 10, 10) + if err != nil { + t.Fatalf("reserve shared installation capacity: %v", err) + } + if err := store.ReleaseBytes(ctx, installationReservation.ID); err != nil { + t.Fatalf("release installation reservation: %v", err) + } +} + +func TestPruneReleasesFreesQuotaAfterRemovingSupersededArtifact(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.Create(ctx, Instance{ + ID: "instance-1", + PluginID: "canvas-board", + SourceKind: SourceLocalCanvas, + ScopeKind: ScopeWorkspace, + WorkspaceID: "workspace-1", + Status: StatusActive, + }); err != nil { + t.Fatalf("Create: %v", err) + } + base := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + for _, release := range []Release{ + {ID: "release-old", PluginID: "canvas-board", InstanceID: "instance-1", PackageDigest: "digest-old", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", ArtifactPath: "releases/old", ArtifactBytes: 4, ValidationStatus: ValidationValid, CreatedAt: base.Add(time.Second)}, + {ID: "release-prior", PluginID: "canvas-board", InstanceID: "instance-1", PackageDigest: "digest-prior", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", ArtifactPath: "releases/prior", ArtifactBytes: 4, ValidationStatus: ValidationValid, CreatedAt: base.Add(2 * time.Second)}, + {ID: "release-active", PluginID: "canvas-board", InstanceID: "instance-1", PackageDigest: "digest-active", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", ArtifactPath: "releases/active", ArtifactBytes: 4, ValidationStatus: ValidationValid, CreatedAt: base.Add(3 * time.Second)}, + } { + if err := store.CreateRelease(ctx, release); err != nil { + t.Fatalf("CreateRelease(%s): %v", release.ID, err) + } + } + if err := store.SetActiveRelease(ctx, "instance-1", "release-active"); err != nil { + t.Fatalf("SetActiveRelease: %v", err) + } + if _, err := store.ReserveBytes(ctx, "workspace-1", 1, 10, 10); !errors.Is(err, ErrWorkspaceStorageLimit) { + t.Fatalf("reservation before prune = %v, want ErrWorkspaceStorageLimit", err) + } + if err := store.PruneReleases(ctx, "instance-1"); err != nil { + t.Fatalf("PruneReleases: %v", err) + } + reservation, err := store.ReserveBytes(ctx, "workspace-1", 1, 10, 10) + if err != nil { + t.Fatalf("reservation after prune: %v", err) + } + if err := store.ReleaseBytes(ctx, reservation.ID); err != nil { + t.Fatalf("release reservation: %v", err) + } +} + +func TestReconcileArtifactsMarksRetainedReleaseUnavailable(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.Create(ctx, Instance{ID: "instance-1", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, ScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1", Status: StatusActive}); err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.CreateRelease(ctx, Release{ID: "release-1", PluginID: "canvas-board", InstanceID: "instance-1", PackageDigest: "digest-1", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", ArtifactPath: "releases/digest-1", ValidationStatus: ValidationValid}); err != nil { + t.Fatalf("CreateRelease: %v", err) + } + marked, err := store.ReconcileArtifacts(ctx, func(path, digest string, bytes int64) (ArtifactCheck, error) { + if path != "releases/digest-1" || digest != "digest-1" || bytes != 0 { + t.Fatalf("checker arguments = %q, %q, %d", path, digest, bytes) + } + return ArtifactCheck{Reason: "missing"}, nil + }) + if err != nil || marked != 1 { + t.Fatalf("ReconcileArtifacts() = %d, %v; want one mark", marked, err) + } + release, err := store.GetRelease(ctx, "release-1") + if err != nil { + t.Fatalf("GetRelease: %v", err) + } + if release.ValidationStatus != ValidationUnavailable || release.ValidationError != "missing" { + t.Fatalf("release after reconcile = %+v", release) + } +} + +func TestCleanupJobsCanBeClaimedRetriedAndCompleted(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.AddCleanupJob(ctx, CleanupJob{ID: "cleanup-1", InstanceID: "instance-1", ArtifactPath: "releases/artifact"}); err != nil { + t.Fatalf("AddCleanupJob: %v", err) + } + + claimed, ok, err := store.ClaimCleanupJob(ctx, time.Now().UTC()) + if err != nil || !ok { + t.Fatalf("ClaimCleanupJob() = %+v, %v, %t; want a claim", claimed, err, ok) + } + if claimed.Status != CleanupRunning || claimed.Attempts != 1 { + t.Fatalf("claimed job = %+v, want running attempt 1", claimed) + } + if _, ok, err := store.ClaimCleanupJob(ctx, time.Now().UTC()); err != nil || ok { + t.Fatalf("second ClaimCleanupJob() = %v, %t; want no duplicate claim", err, ok) + } + + next := time.Now().UTC().Add(time.Hour) + if err := store.RetryCleanupJob(ctx, claimed.ID, next, errors.New("remove failed")); err != nil { + t.Fatalf("RetryCleanupJob: %v", err) + } + if _, ok, err := store.ClaimCleanupJob(ctx, time.Now().UTC()); err != nil || ok { + t.Fatalf("early retry claim = %v, %t; want no claim", err, ok) + } + retried, ok, err := store.ClaimCleanupJob(ctx, next) + if err != nil || !ok || retried.Attempts != 2 { + t.Fatalf("retry claim = %+v, %v, %t; want attempt 2", retried, err, ok) + } + if err := store.CompleteCleanupJob(ctx, retried.ID); err != nil { + t.Fatalf("CompleteCleanupJob: %v", err) + } + jobs, err := store.ListCleanupJobs(ctx) + if err != nil { + t.Fatalf("ListCleanupJobs: %v", err) + } + if len(jobs) != 1 || jobs[0].Status != CleanupCompleted || jobs[0].LastError != "remove failed" { + t.Fatalf("cleanup jobs = %+v, want one completed job with diagnostic", jobs) + } +} + +func TestReleaseActivationRejectsBroaderGrants(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.Create(ctx, Instance{ID: "instance-1", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive}); err != nil { + t.Fatalf("Create: %v", err) + } + declared := json.RawMessage(`{"reads":["tasks"],"shared_state":false,"external_origins":["https://api.example.com"]}`) + if err := store.CreateRelease(ctx, Release{ID: "release-1", PluginID: "canvas-board", InstanceID: "instance-1", PackageDigest: "digest-1", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", ManifestJSON: json.RawMessage(`{}`), DeclaredPermissionsJSON: declared, ArtifactPath: "releases/digest-1", ArtifactBytes: 1, ValidationStatus: ValidationValid}); err != nil { + t.Fatalf("CreateRelease: %v", err) + } + if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeTask, ApprovedBy: "user-1"}); err != nil { + t.Fatalf("AddGrant: %v", err) + } + if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "network", NetworkOrigin: "https://api.example.com", ScopeCeiling: ScopeWorkspace, ApprovedBy: "user-1"}); err != nil { + t.Fatalf("AddGrant network preapproval: %v", err) + } + if err := store.ActivateRelease(ctx, "instance-1", "release-1"); !errors.Is(err, ErrInvalidRelease) { + t.Fatalf("ActivateRelease with broader grant = %v, want ErrInvalidRelease", err) + } + instance, err := store.Get(ctx, "instance-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if instance.ActiveReleaseID != "" { + t.Fatalf("active release = %q after rejected activation", instance.ActiveReleaseID) + } +} + +func TestReleaseActivationContractsRemovedPermissions(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.Create(ctx, Instance{ID: "instance-1", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive}); err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeTask, ApprovedBy: "user-1"}); err != nil { + t.Fatalf("AddGrant: %v", err) + } + if err := store.CreateRelease(ctx, Release{ID: "release-1", PluginID: "canvas-board", InstanceID: "instance-1", PackageDigest: "digest-1", SourceKind: SourceLocalCanvas, ManifestJSON: json.RawMessage(`{}`), DeclaredPermissionsJSON: json.RawMessage(`{}`), ArtifactPath: "releases/digest-1", ArtifactBytes: 1, ValidationStatus: ValidationValid}); err != nil { + t.Fatalf("CreateRelease: %v", err) + } + if err := store.ActivateRelease(ctx, "instance-1", "release-1"); err != nil { + t.Fatalf("ActivateRelease: %v", err) + } + instance, err := store.Get(ctx, "instance-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if instance.ActiveReleaseID != "release-1" { + t.Fatalf("active release = %q, want release-1", instance.ActiveReleaseID) + } +} + +func TestCreateReleaseIfActiveReleaseTxRejectsChangedAuthority(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.Create(ctx, Instance{ + ID: "instance-authority", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, + ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive, + }); err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.CreateRelease(ctx, Release{ + ID: "release-authority-base", PluginID: "canvas-board", InstanceID: "instance-authority", + PackageDigest: "digest-base", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", + ManifestJSON: json.RawMessage(`{}`), DeclaredPermissionsJSON: json.RawMessage(`{}`), + ArtifactPath: "releases/base", ArtifactBytes: 1, ValidationStatus: ValidationValid, + }); err != nil { + t.Fatalf("CreateRelease: %v", err) + } + if err := store.SetActiveRelease(ctx, "instance-authority", "release-authority-base"); err != nil { + t.Fatalf("SetActiveRelease: %v", err) + } + captured, err := store.Get(ctx, "instance-authority") + if err != nil { + t.Fatalf("capture publish authority: %v", err) + } + expected := captured.PublishAuthority() + if err := store.SetScope(ctx, "instance-authority", ScopeWorkspace, ScopeIdentifiers{WorkspaceID: "workspace-1"}); err != nil { + t.Fatalf("promote scope: %v", err) + } + + err = store.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return store.CreateReleaseIfAuthorityTx(ctx, tx, "instance-authority", expected, Release{ + ID: "release-authority-stale", PluginID: "canvas-board", InstanceID: "instance-authority", + PackageDigest: "digest-stale", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", + ManifestJSON: json.RawMessage(`{}`), DeclaredPermissionsJSON: json.RawMessage(`{}`), + ArtifactPath: "releases/stale", ArtifactBytes: 1, ValidationStatus: ValidationValid, + }) + }) + if !errors.Is(err, ErrStaleCanvasPublish) { + t.Fatalf("stale task authority error = %v, want ErrStaleCanvasPublish", err) + } +} + +func TestActivateReleaseRejectsArchivedInstance(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.Create(ctx, Instance{ + ID: "instance-archived-activate", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, + ScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1", Status: StatusArchived, + }); err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.CreateRelease(ctx, Release{ + ID: "release-archived-activate", PluginID: "canvas-board", InstanceID: "instance-archived-activate", + PackageDigest: "digest-archived-activate", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", + ManifestJSON: json.RawMessage(`{}`), DeclaredPermissionsJSON: json.RawMessage(`{}`), + ArtifactPath: "releases/archived-activate", ArtifactBytes: 1, ValidationStatus: ValidationValid, + }); err != nil { + t.Fatalf("CreateRelease: %v", err) + } + if err := store.ActivateRelease(ctx, "instance-archived-activate", "release-archived-activate"); err == nil { + t.Fatal("archived instance was reactivated without Restore") + } +} + +func TestApproveReleaseRejectsArchivedInstance(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.Create(ctx, Instance{ + ID: "instance-archived-approve", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, + ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusArchived, + }); err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.CreateRelease(ctx, Release{ + ID: "release-archived-approve", PluginID: "canvas-board", InstanceID: "instance-archived-approve", + PackageDigest: "digest-archived-approve", SourceKind: SourceLocalCanvas, SourceActorKind: "agent", + ManifestJSON: json.RawMessage(`{}`), DeclaredPermissionsJSON: json.RawMessage(`{"reads":["tasks"]}`), + ArtifactPath: "releases/archived-approve", ArtifactBytes: 1, ValidationStatus: ValidationPendingPermission, + }); err != nil { + t.Fatalf("CreateRelease: %v", err) + } + if err := store.ApproveRelease(ctx, "instance-archived-approve", "release-archived-approve", "user-1", []Grant{{ + PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeTask, + }}); err == nil { + t.Fatal("archived instance was approved and activated without Restore") + } +} diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go index 2744737df2..3e0cfc8ae6 100644 --- a/apps/backend/internal/plugins/manifest/manifest.go +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -271,6 +271,23 @@ type UISection struct { Bundle string `yaml:"bundle,omitempty" json:"bundle,omitempty"` Styles []string `yaml:"styles,omitempty" json:"styles,omitempty"` Keybindings []UIKeybinding `yaml:"keybindings,omitempty" json:"keybindings,omitempty"` + WebApps []WebApp `yaml:"web_apps,omitempty" json:"web_apps,omitempty"` +} + +const ( + WebAppPlacementTask = "task-canvas" + WebAppPlacementWorkspace = "workspace-canvas" +) + +// WebApp declares one packaged static web application. The host owns its +// placement, navigation, and permissions; the package only supplies the +// immutable entry document and its supported host placements. +type WebApp struct { + Key string `yaml:"key" json:"key"` + Title string `yaml:"title" json:"title"` + Entry string `yaml:"entry" json:"entry"` + Placements []string `yaml:"placements" json:"placements"` + NetworkOrigins []string `yaml:"network_origins,omitempty" json:"network_origins,omitempty"` } // UIPage is a single UI page contributed by the plugin. diff --git a/apps/backend/internal/plugins/manifest/manifest_test.go b/apps/backend/internal/plugins/manifest/manifest_test.go index 2491ead1a4..af16e6dbe4 100644 --- a/apps/backend/internal/plugins/manifest/manifest_test.go +++ b/apps/backend/internal/plugins/manifest/manifest_test.go @@ -59,6 +59,68 @@ func TestParse_ValidManifestParsesID(t *testing.T) { } } +func TestValidate_StaticWebAppManifestPassesWithoutBackend(t *testing.T) { + m, err := Parse([]byte(` +id: canvas-board +api_version: 2 +version: 1.0.0 +display_name: Canvas Board +description: A static board +author: test +ui: + web_apps: + - key: main + title: Task board + entry: ui/index.html + placements: [task-canvas, workspace-canvas] +`)) + if err != nil { + t.Fatalf("Parse() unexpected error: %v", err) + } + if err := m.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + if !m.IsStaticWebAppOnly() { + t.Fatal("IsStaticWebAppOnly() = false, want true") + } +} + +func TestValidate_WebAppRejectsUnsafeEntryAndUnknownPlacement(t *testing.T) { + m := validManifest(t) + m.UI.WebApps = []WebApp{{Key: "main", Title: "Board", Entry: "../index.html", Placements: []string{"admin"}}} + err := m.Validate() + if err == nil || !strings.Contains(err.Error(), "ui.web_apps") || !strings.Contains(err.Error(), "unsupported placement") { + t.Fatalf("Validate() error = %v, want web-app entry and placement errors", err) + } +} + +func TestValidate_WebAppNormalizesNetworkOrigins(t *testing.T) { + m := validManifest(t) + m.UI.WebApps = []WebApp{{ + Key: "main", Title: "Board", Entry: "ui/index.html", + Placements: []string{WebAppPlacementTask}, + NetworkOrigins: []string{"https://API.example.com"}, + }} + if err := m.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + if got := m.UI.WebApps[0].NetworkOrigins; len(got) != 1 || got[0] != "https://api.example.com" { + t.Fatalf("network origins = %#v, want canonical HTTPS origin", got) + } +} + +func TestValidate_WebAppRejectsInvalidNetworkOrigin(t *testing.T) { + m := validManifest(t) + m.UI.WebApps = []WebApp{{ + Key: "main", Title: "Board", Entry: "ui/index.html", + Placements: []string{WebAppPlacementTask}, + NetworkOrigins: []string{"https://api.example.com/v1"}, + }} + if err := m.Validate(); err == nil || !strings.Contains(err.Error(), "network_origins") { + t.Fatalf("Validate() error = %v, want network origin error", err) + } +} + // TestParse_UserStateCapabilityRoundTrips pins capabilities.user_state // (Approach D1 / AC17): a plugin declaring host-provided per-user storage // parses with Capabilities.UserState set. diff --git a/apps/backend/internal/plugins/manifest/runtime.go b/apps/backend/internal/plugins/manifest/runtime.go index d732134028..0ad414689d 100644 --- a/apps/backend/internal/plugins/manifest/runtime.go +++ b/apps/backend/internal/plugins/manifest/runtime.go @@ -10,6 +10,18 @@ func (m *Manifest) IsManaged() bool { return m.Runtime.Type == runtimeTypeBinary } +// HasWebApps reports whether the manifest declares one or more isolated +// static web applications. +func (m *Manifest) HasWebApps() bool { + return m != nil && len(m.UI.WebApps) > 0 +} + +// IsStaticWebAppOnly reports the package form that has no managed backend and +// contributes only isolated static web applications. +func (m *Manifest) IsStaticWebAppOnly() bool { + return m != nil && m.HasWebApps() && !m.IsManaged() && m.BaseURL == "" && m.Endpoints == (Endpoints{}) +} + // ExecutableFor returns the package-relative executable path declared for // the given host platform (goos/goarch, e.g. "linux", "amd64"), and whether // an entry exists. The returned path is relative to the extracted package diff --git a/apps/backend/internal/plugins/manifest/validate.go b/apps/backend/internal/plugins/manifest/validate.go index c35a1a6c31..153dde08cf 100644 --- a/apps/backend/internal/plugins/manifest/validate.go +++ b/apps/backend/internal/plugins/manifest/validate.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "path" "regexp" "strings" @@ -136,6 +137,7 @@ func (m *Manifest) Validate() error { errs = append(errs, m.validateRepoURL()...) errs = append(errs, m.validateUIPages()...) errs = append(errs, m.validateUIBundle()...) + errs = append(errs, m.validateWebApps()...) errs = append(errs, m.validateUIKeybindings()...) errs = append(errs, m.validateWebhooks()...) errs = append(errs, m.validateActions()...) @@ -341,6 +343,11 @@ func validateRelativePackagePath(p string) error { // validateEndpoints checks base_url and the required endpoint paths. func (m *Manifest) validateEndpoints() []error { + // A static web-application-only package intentionally has no managed + // backend and therefore no legacy base_url or endpoint contract. + if m.HasWebApps() && m.BaseURL == "" && m.Endpoints == (Endpoints{}) { + return nil + } var errs []error if m.BaseURL == "" { errs = append(errs, errors.New("base_url is required")) @@ -357,6 +364,78 @@ func (m *Manifest) validateEndpoints() []error { return errs } +var webAppKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + +func (m *Manifest) validateWebApps() []error { + if len(m.UI.WebApps) == 0 { + return nil + } + seen := make(map[string]struct{}, len(m.UI.WebApps)) + var errs []error + for i, app := range m.UI.WebApps { + prefix := fmt.Sprintf("ui.web_apps[%d]", i) + if !webAppKeyPattern.MatchString(app.Key) { + errs = append(errs, fmt.Errorf("%s.key %q must match %s", prefix, app.Key, webAppKeyPattern.String())) + } + if _, ok := seen[app.Key]; ok { + errs = append(errs, fmt.Errorf("%s.key duplicates %q", prefix, app.Key)) + } + seen[app.Key] = struct{}{} + if strings.TrimSpace(app.Title) == "" || len(app.Title) > 200 { + errs = append(errs, fmt.Errorf("%s.title must be 1-200 bytes", prefix)) + } + if err := validateRelativePackagePath(app.Entry); err != nil { + errs = append(errs, fmt.Errorf("%s.entry: %w", prefix, err)) + } + if len(app.Placements) == 0 { + errs = append(errs, fmt.Errorf("%s.placements must not be empty", prefix)) + } + placementSeen := make(map[string]struct{}, len(app.Placements)) + for _, placement := range app.Placements { + if placement != WebAppPlacementTask && placement != WebAppPlacementWorkspace { + errs = append(errs, fmt.Errorf("%s.placements contains unsupported placement %q", prefix, placement)) + } + if _, ok := placementSeen[placement]; ok { + errs = append(errs, fmt.Errorf("%s.placements duplicates %q", prefix, placement)) + } + placementSeen[placement] = struct{}{} + } + networkOrigins, originErrs := normalizeWebAppNetworkOrigins(prefix, app.NetworkOrigins) + errs = append(errs, originErrs...) + m.UI.WebApps[i].NetworkOrigins = networkOrigins + } + return errs +} + +// normalizeWebAppNetworkOrigins validates the exact HTTPS origins that a +// packaged web application may request from its sandbox. The host later uses +// this canonical form for grants and CSP, so equivalent host casing cannot +// create two permission entries. +func normalizeWebAppNetworkOrigins(prefix string, origins []string) ([]string, []error) { + if len(origins) == 0 { + return nil, nil + } + result := make([]string, 0, len(origins)) + seen := make(map[string]struct{}, len(origins)) + var errs []error + for i, raw := range origins { + trimmed := strings.TrimSpace(raw) + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.ContainsAny(parsed.Host, " \t\r\n") || parsed.Hostname() == "" { + errs = append(errs, fmt.Errorf("%s.network_origins[%d] must be an exact HTTPS origin", prefix, i)) + continue + } + origin := parsed.Scheme + "://" + strings.ToLower(parsed.Host) + if _, exists := seen[origin]; exists { + errs = append(errs, fmt.Errorf("%s.network_origins duplicates %q", prefix, origin)) + continue + } + seen[origin] = struct{}{} + result = append(result, origin) + } + return result, errs +} + // validateCategories checks each category against the known enum. func (m *Manifest) validateCategories() []error { var errs []error diff --git a/apps/backend/internal/plugins/provider.go b/apps/backend/internal/plugins/provider.go index 0528892d46..2ad8230f0e 100644 --- a/apps/backend/internal/plugins/provider.go +++ b/apps/backend/internal/plugins/provider.go @@ -1,6 +1,7 @@ package plugins import ( + "context" "fmt" "os" "path/filepath" @@ -9,12 +10,15 @@ import ( "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/ports" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins/instances" "github.com/kandev/kandev/internal/plugins/marketplace" "github.com/kandev/kandev/internal/plugins/runtime" "github.com/kandev/kandev/internal/plugins/state" "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/internal/plugins/webapp" ) // marketplaceURLEnv overrides the built-in official marketplace source URL at @@ -66,15 +70,59 @@ func Provide(cfg *config.Config, dbPool *db.Pool, secrets SecretVault, eventBus return nil, nil, fmt.Errorf("plugins: init user state store: %w", err) } + instanceStore, err := instances.NewStore(dbPool) + if err != nil { + return nil, nil, fmt.Errorf("plugins: init web-app instance store: %w", err) + } + instanceState, err := state.NewInstanceStore(dbPool) + if err != nil { + return nil, nil, fmt.Errorf("plugins: init web-app instance state: %w", err) + } + artifactStore, err := webapp.NewArtifactStore(filepath.Join(cfg.ResolvedHomeDir(), pluginsSubdir, "webapps")) + if err != nil { + return nil, nil, fmt.Errorf("plugins: init web-app artifact store: %w", err) + } + if cfg.Features.Canvases { + if _, err := instanceStore.ReconcileArtifacts(context.Background(), func(path, digest string, bytes int64) (instances.ArtifactCheck, error) { + artifact, err := artifactStore.Reconcile(webapp.Artifact{Digest: digest, RelativePath: path, Bytes: bytes}) + if err != nil { + return instances.ArtifactCheck{}, err + } + return instances.ArtifactCheck{Available: artifact.Available, Reason: artifact.Reason}, nil + }); err != nil { + return nil, nil, fmt.Errorf("plugins: reconcile web-app artifacts: %w", err) + } + } + registry := NewRegistry() if err := registry.Load(pluginStore); err != nil { return nil, nil, fmt.Errorf("plugins: load registry: %w", err) } svc := NewService(pluginStore, registry, eventBus, log) + if cfg.Features.Canvases { + svc.subscribeWebAppEvents() + } svc.warnLoadedWebhookAccessIssues() svc.SetState(stateStore) svc.SetUserState(userStateStore) + if cfg.Features.Canvases { + svc.SetWebAppStorage(instanceStore, artifactStore) + svc.SetInstanceState(instanceState) + backendPort := cfg.Server.Port + if backendPort == 0 { + backendPort = ports.Backend + } + webPort := cfg.Launcher.WebPort + if webPort == 0 { + webPort = backendPort + } + frameAncestors, frameErr := webapp.FrameAncestorsForConfig(backendPort, webPort, fmt.Sprintf("http://localhost:%d", webPort)) + if frameErr != nil { + return nil, nil, fmt.Errorf("plugins: configure web-app frame origins: %w", frameErr) + } + svc.SetWebRuntime(webapp.NewRuntime(webapp.NewTokenManager(nil), artifactStore, svc.validateWebAppBinding, frameAncestors)) + } svc.SetSecrets(secrets) svc.SetPluginsDir(dir) @@ -95,8 +143,14 @@ func Provide(cfg *config.Config, dbPool *db.Pool, secrets SecretVault, eventBus rt := runtime.NewManager(dir, svc.handleStatusChange, log) svc.SetRuntime(rt) + stopArtifactCleanup := func() {} + if cfg.Features.Canvases { + stopArtifactCleanup = svc.StartWebAppArtifactCleanupWorker(context.Background()) + } cleanup := func() error { + stopArtifactCleanup() + svc.closeWebAppEvents() rt.StopAll() return nil } diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index 0b922963e1..e1170bdec5 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -13,10 +13,12 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/mcp/plugintools" + "github.com/kandev/kandev/internal/plugins/instances" "github.com/kandev/kandev/internal/plugins/manifest" "github.com/kandev/kandev/internal/plugins/marketplace" "github.com/kandev/kandev/internal/plugins/state" "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/internal/plugins/webapp" "github.com/kandev/kandev/pkg/pluginsdk" ) @@ -87,14 +89,20 @@ type Service struct { // directory belongs to an install still waiting for it. extractingPaths map[string]int - pluginsDir string - store store.Store - registry *Registry - state *state.Store - userState *state.UserStore - userStateCleanup userStateCleanupStore - eventBus bus.EventBus - log *logger.Logger + pluginsDir string + store store.Store + registry *Registry + state *state.Store + userState *state.UserStore + instances *instances.Store + instanceState *state.InstanceStore + webArtifacts *webapp.ArtifactStore + webRuntime *webapp.Runtime + eventHub *webapp.EventHub + eventSubscription bus.Subscription + userStateCleanup userStateCleanupStore + eventBus bus.EventBus + log *logger.Logger deliverer Deliverer agentToolCatalogListener AgentToolCatalogListener @@ -195,6 +203,7 @@ func NewService(pluginStore store.Store, registry *Registry, eventBus bus.EventB lifecycleLocks: newKeyedMutex(), dispatchLocks: newKeyedRWMutex(), agentToolGeneration: uuid.NewString(), + eventHub: webapp.NewEventHub(), } } @@ -385,6 +394,83 @@ func (s *Service) UserState() *state.UserStore { return s.userState } +// SetWebAppStorage wires the durable instance metadata and immutable static +// artifact stores used by isolated web applications. Native plugin lifecycle +// remains independent of these stores. +func (s *Service) SetWebAppStorage(instanceStore *instances.Store, artifactStore *webapp.ArtifactStore) { + s.mu.Lock() + defer s.mu.Unlock() + s.instances = instanceStore + s.webArtifacts = artifactStore +} + +// SetInstanceState wires revisioned state owned by isolated web-app +// instances. It is separate from plugin_state, which belongs to a managed +// plugin process and has no instance identity. +func (s *Service) SetInstanceState(instanceState *state.InstanceStore) { + s.mu.Lock() + defer s.mu.Unlock() + s.instanceState = instanceState +} + +func (s *Service) InstanceState() *state.InstanceStore { + s.mu.Lock() + defer s.mu.Unlock() + return s.instanceState +} + +// Instances returns the scoped web-application instance store. +func (s *Service) Instances() *instances.Store { + s.mu.Lock() + defer s.mu.Unlock() + return s.instances +} + +// WebArtifacts returns immutable static web-application artifact storage. +func (s *Service) WebArtifacts() *webapp.ArtifactStore { + s.mu.Lock() + defer s.mu.Unlock() + return s.webArtifacts +} + +// SetWebRuntime attaches the capability-bound static web-app runtime. The +// backend registers its route only when the canvas release gate is enabled. +func (s *Service) SetWebRuntime(runtime *webapp.Runtime) { + s.mu.Lock() + defer s.mu.Unlock() + s.webRuntime = runtime + if runtime != nil { + runtime.SetProtocolHandler(s.handleWebAppProtocol) + } +} + +func (s *Service) WebRuntime() *webapp.Runtime { + s.mu.Lock() + defer s.mu.Unlock() + return s.webRuntime +} + +// SetWebAppEventHub replaces the in-process public event transport. It is +// primarily useful for deterministic tests; production creates the hub in +// NewService and forwards the shared Kandev event bus into it. +func (s *Service) SetWebAppEventHub(hub *webapp.EventHub) { + s.mu.Lock() + previous := s.eventHub + s.eventHub = hub + s.mu.Unlock() + if previous != nil && previous != hub { + previous.Close() + } +} + +// WebAppEventHub returns the bounded SSE transport for capability-bound web +// applications. +func (s *Service) WebAppEventHub() *webapp.EventHub { + s.mu.Lock() + defer s.mu.Unlock() + return s.eventHub +} + // SetSecrets wires the secret vault Provide was constructed with. func (s *Service) SetSecrets(v SecretVault) { s.secrets = v diff --git a/apps/backend/internal/plugins/state/instance_store.go b/apps/backend/internal/plugins/state/instance_store.go new file mode 100644 index 0000000000..7592a2ca67 --- /dev/null +++ b/apps/backend/internal/plugins/state/instance_store.go @@ -0,0 +1,333 @@ +package state + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/db/dialect" +) + +// InstanceStateEntry is a revisioned value owned by one plugin instance. +// Deleted entries are retained as tombstones so a delete followed by a stale +// recreate cannot reset the revision back to one. +type InstanceStateEntry struct { + InstanceID string + Key string + Value json.RawMessage + Revision int64 + WriterKind string + UpdatedAt time.Time + Deleted bool +} + +// ConflictError reports the revision that won a conditional state write. +// The current value is intentionally not included. Browser callers only get +// it when they still have a separate read permission. +type ConflictError struct { + CurrentRevision int64 +} + +func (e *ConflictError) Error() string { + return fmt.Sprintf("plugin instance state: revision conflict (current revision %d)", e.CurrentRevision) +} + +func (e *ConflictError) Unwrap() error { return ErrConflict } + +// InstanceStore persists browser and agent-authored state for a plugin +// instance. The mutex makes the read/check/write sequence atomic for every +// caller in this process; the PostgreSQL row lock covers concurrent processes. +type InstanceStore struct { + db *sqlx.DB + ro *sqlx.DB + mu sync.Mutex +} + +// NewInstanceStore creates the revisioned instance-state table if needed. +func NewInstanceStore(pool *db.Pool) (*InstanceStore, error) { + if pool == nil || pool.Writer() == nil || pool.Reader() == nil { + return nil, errors.New("plugin instance state: database pool is required") + } + s := &InstanceStore{db: pool.Writer(), ro: pool.Reader()} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("plugin instance state schema init: %w", err) + } + return s, nil +} + +func (s *InstanceStore) initSchema() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS plugin_instance_state ( + id TEXT PRIMARY KEY, + plugin_instance_id TEXT NOT NULL, + state_key TEXT NOT NULL, + value_json TEXT NOT NULL, + revision INTEGER NOT NULL, + writer_kind TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + UNIQUE (plugin_instance_id, state_key) + ); + `) + return err +} + +// Get returns the current value. found is false for a missing key or a +// tombstone, but the returned entry still carries its revision when a +// tombstone exists so callers can report a precise conflict. +func (s *InstanceStore) Get(ctx context.Context, instanceID, key string) (InstanceStateEntry, bool, error) { + var entry InstanceStateEntry + var raw, updatedAt string + var deleted int + err := s.ro.QueryRowContext(ctx, s.ro.Rebind(` + SELECT plugin_instance_id, state_key, value_json, revision, writer_kind, updated_at, deleted + FROM plugin_instance_state + WHERE plugin_instance_id = ? AND state_key = ? + `), instanceID, key).Scan( + &entry.InstanceID, &entry.Key, &raw, &entry.Revision, &entry.WriterKind, &updatedAt, &deleted, + ) + if errors.Is(err, sql.ErrNoRows) { + return InstanceStateEntry{}, false, nil + } + if err != nil { + return InstanceStateEntry{}, false, err + } + entry.Value = json.RawMessage(raw) + entry.Deleted = deleted != 0 + entry.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt) + if err != nil { + return InstanceStateEntry{}, false, fmt.Errorf("parse instance state updated_at: %w", err) + } + return entry, !entry.Deleted, nil +} + +// List returns live values in stable key order. Tombstones are omitted. +func (s *InstanceStore) List(ctx context.Context, instanceID string) ([]InstanceStateEntry, error) { + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(` + SELECT plugin_instance_id, state_key, value_json, revision, writer_kind, updated_at, deleted + FROM plugin_instance_state + WHERE plugin_instance_id = ? AND deleted = 0 + ORDER BY state_key + `), instanceID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var entries []InstanceStateEntry + for rows.Next() { + var entry InstanceStateEntry + var raw, updatedAt string + var deleted int + if err := rows.Scan(&entry.InstanceID, &entry.Key, &raw, &entry.Revision, &entry.WriterKind, &updatedAt, &deleted); err != nil { + return nil, err + } + entry.Value = json.RawMessage(raw) + entry.Deleted = deleted != 0 + entry.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt) + if err != nil { + return nil, fmt.Errorf("parse instance state updated_at: %w", err) + } + entries = append(entries, entry) + } + if err := rows.Err(); err != nil { + return nil, err + } + return entries, nil +} + +// Set writes a value at the expected revision. A nil expectation is an +// unconditional internal write; a pointer to zero creates a new key, and a +// positive value must match the current revision. +func (s *InstanceStore) Set( + ctx context.Context, + instanceID, key string, + value json.RawMessage, + expectedRevision *int64, + writerKind string, +) (InstanceStateEntry, error) { + if instanceID == "" || key == "" { + return InstanceStateEntry{}, errors.New("plugin instance state: instance id and key are required") + } + if !json.Valid(value) { + return InstanceStateEntry{}, errors.New("plugin instance state: value must be valid JSON") + } + if writerKind == "" { + writerKind = "unknown" + } + + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return InstanceStateEntry{}, err + } + defer func() { _ = tx.Rollback() }() + + current, found, err := selectInstanceState(ctx, tx, s.db.DriverName(), instanceID, key) + if err != nil { + return InstanceStateEntry{}, err + } + if err := checkExpectedRevision(expectedRevision, current, found); err != nil { + return InstanceStateEntry{}, err + } + now := time.Now().UTC() + if !found { + current = InstanceStateEntry{ + InstanceID: instanceID, + Key: key, + Value: append(json.RawMessage(nil), value...), + Revision: 1, + WriterKind: writerKind, + UpdatedAt: now, + } + _, err = tx.ExecContext(ctx, tx.Rebind(` + INSERT INTO plugin_instance_state + (id, plugin_instance_id, state_key, value_json, revision, writer_kind, updated_at, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, 0) + `), uuid.New().String(), instanceID, key, string(value), current.Revision, writerKind, now.Format(time.RFC3339Nano)) + } else { + current.Value = append(json.RawMessage(nil), value...) + current.Revision++ + current.WriterKind = writerKind + current.UpdatedAt = now + current.Deleted = false + _, err = tx.ExecContext(ctx, tx.Rebind(` + UPDATE plugin_instance_state + SET value_json = ?, revision = ?, writer_kind = ?, updated_at = ?, deleted = 0 + WHERE plugin_instance_id = ? AND state_key = ? + `), string(value), current.Revision, writerKind, now.Format(time.RFC3339Nano), instanceID, key) + } + if err != nil { + return InstanceStateEntry{}, err + } + if err := tx.Commit(); err != nil { + return InstanceStateEntry{}, err + } + return current, nil +} + +// Delete removes the visible value and advances its revision. The tombstone +// keeps the revision monotonic. Deleting an already-deleted key is idempotent. +func (s *InstanceStore) Delete(ctx context.Context, instanceID, key string, expectedRevision *int64, writerKind string) (int64, error) { + if instanceID == "" || key == "" { + return 0, errors.New("plugin instance state: instance id and key are required") + } + if writerKind == "" { + writerKind = "unknown" + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + current, found, err := selectInstanceState(ctx, tx, s.db.DriverName(), instanceID, key) + if err != nil { + return 0, err + } + if err := checkExpectedRevision(expectedRevision, current, found); err != nil { + return 0, err + } + if found && current.Deleted { + return current.Revision, tx.Commit() + } + now := time.Now().UTC() + if !found { + _, err = tx.ExecContext(ctx, tx.Rebind(` + INSERT INTO plugin_instance_state + (id, plugin_instance_id, state_key, value_json, revision, writer_kind, updated_at, deleted) + VALUES (?, ?, ?, 'null', 1, ?, ?, 1) + `), uuid.New().String(), instanceID, key, writerKind, now.Format(time.RFC3339Nano)) + if err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return 1, nil + } + current.Revision++ + _, err = tx.ExecContext(ctx, tx.Rebind(` + UPDATE plugin_instance_state + SET value_json = 'null', revision = ?, writer_kind = ?, updated_at = ?, deleted = 1 + WHERE plugin_instance_id = ? AND state_key = ? + `), current.Revision, writerKind, now.Format(time.RFC3339Nano), instanceID, key) + if err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return current.Revision, nil +} + +// DeleteInstance removes all state owned by an isolated web-app instance. +// Instance deletion is a lifecycle boundary, so unlike Delete it removes the +// complete key/tombstone set instead of retaining per-key revisions. +func (s *InstanceStore) DeleteInstance(ctx context.Context, instanceID string) error { + if strings.TrimSpace(instanceID) == "" { + return errors.New("plugin instance state: instance id is required") + } + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM plugin_instance_state WHERE plugin_instance_id = ?`), instanceID) + return err +} + +func selectInstanceState( + ctx context.Context, + tx *sqlx.Tx, + driver, instanceID, key string, +) (InstanceStateEntry, bool, error) { + query := ` + SELECT plugin_instance_id, state_key, value_json, revision, writer_kind, updated_at, deleted + FROM plugin_instance_state + WHERE plugin_instance_id = ? AND state_key = ? + ` + if dialect.IsPostgres(driver) { + query += " FOR UPDATE" + } + var entry InstanceStateEntry + var raw, updatedAt string + var deleted int + err := tx.QueryRowxContext(ctx, tx.Rebind(query), instanceID, key).Scan( + &entry.InstanceID, &entry.Key, &raw, &entry.Revision, &entry.WriterKind, &updatedAt, &deleted, + ) + if errors.Is(err, sql.ErrNoRows) { + return InstanceStateEntry{}, false, nil + } + if err != nil { + return InstanceStateEntry{}, false, err + } + entry.Value = json.RawMessage(raw) + entry.Deleted = deleted != 0 + entry.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt) + if err != nil { + return InstanceStateEntry{}, false, fmt.Errorf("parse instance state updated_at: %w", err) + } + return entry, true, nil +} + +func checkExpectedRevision(expected *int64, current InstanceStateEntry, found bool) error { + if expected == nil { + return nil + } + currentRevision := int64(0) + if found { + currentRevision = current.Revision + } + if *expected != currentRevision { + return &ConflictError{CurrentRevision: currentRevision} + } + return nil +} diff --git a/apps/backend/internal/plugins/state/instance_store_test.go b/apps/backend/internal/plugins/state/instance_store_test.go new file mode 100644 index 0000000000..d838348087 --- /dev/null +++ b/apps/backend/internal/plugins/state/instance_store_test.go @@ -0,0 +1,101 @@ +package state + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" +) + +func newInstanceStoreTest(t *testing.T) *InstanceStore { + t.Helper() + conn, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "instance-state.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + pool := db.NewPool(sqlx.NewDb(conn, "sqlite3"), sqlx.NewDb(conn, "sqlite3")) + t.Cleanup(func() { _ = pool.Close() }) + store, err := NewInstanceStore(pool) + if err != nil { + t.Fatalf("NewInstanceStore: %v", err) + } + return store +} + +func TestInstanceStoreUsesRevisionedConditionalWrites(t *testing.T) { + store := newInstanceStoreTest(t) + ctx := context.Background() + first, err := store.Set(ctx, "instance-1", "filters", json.RawMessage(`{"group":"step"}`), ptrInt64(0), "browser") + if err != nil { + t.Fatalf("initial Set: %v", err) + } + if first.Revision != 1 { + t.Fatalf("initial revision = %d, want 1", first.Revision) + } + second, err := store.Set(ctx, "instance-1", "filters", json.RawMessage(`{"group":"workflow"}`), ptrInt64(1), "agent") + if err != nil { + t.Fatalf("conditional Set: %v", err) + } + if second.Revision != 2 { + t.Fatalf("second revision = %d, want 2", second.Revision) + } + _, err = store.Set(ctx, "instance-1", "filters", json.RawMessage(`{"group":"stale"}`), ptrInt64(1), "browser") + var conflict *ConflictError + if !errors.As(err, &conflict) || conflict.CurrentRevision != 2 { + t.Fatalf("stale Set = %v, conflict = %+v", err, conflict) + } + got, found, err := store.Get(ctx, "instance-1", "filters") + if err != nil || !found || string(got.Value) != `{"group":"workflow"}` || got.Revision != 2 { + t.Fatalf("Get = %+v, found=%v, err=%v", got, found, err) + } +} + +func TestInstanceStoreDeleteRetainsTombstoneRevision(t *testing.T) { + store := newInstanceStoreTest(t) + ctx := context.Background() + if _, err := store.Set(ctx, "instance-1", "draft", json.RawMessage(`1`), ptrInt64(0), "browser"); err != nil { + t.Fatalf("Set: %v", err) + } + revision, err := store.Delete(ctx, "instance-1", "draft", ptrInt64(1), "browser") + if err != nil || revision != 2 { + t.Fatalf("Delete = %d, %v; want revision 2", revision, err) + } + entry, found, err := store.Get(ctx, "instance-1", "draft") + if err != nil || found || entry.Revision != 2 { + t.Fatalf("Get tombstone = %+v, found=%v, err=%v", entry, found, err) + } + var conflict *ConflictError + if _, err := store.Set(ctx, "instance-1", "draft", json.RawMessage(`2`), ptrInt64(1), "browser"); !errors.As(err, &conflict) { + t.Fatalf("stale recreate = %v, want conflict", err) + } +} + +func TestInstanceStoreDeleteInstanceRemovesAllState(t *testing.T) { + store := newInstanceStoreTest(t) + ctx := context.Background() + for _, key := range []string{"one", "two"} { + if _, err := store.Set(ctx, "instance-1", key, json.RawMessage(`{"ok":true}`), ptrInt64(0), "agent"); err != nil { + t.Fatalf("Set(%s): %v", key, err) + } + } + if err := store.DeleteInstance(ctx, "instance-1"); err != nil { + t.Fatalf("DeleteInstance: %v", err) + } + entries, err := store.List(ctx, "instance-1") + if err != nil { + t.Fatalf("List after DeleteInstance: %v", err) + } + if len(entries) != 0 { + t.Fatalf("state after DeleteInstance = %+v, want empty", entries) + } +} + +func ptrInt64(value int64) *int64 { return &value } diff --git a/apps/backend/internal/plugins/webapp/events.go b/apps/backend/internal/plugins/webapp/events.go new file mode 100644 index 0000000000..23e431f214 --- /dev/null +++ b/apps/backend/internal/plugins/webapp/events.go @@ -0,0 +1,763 @@ +package webapp + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +const ( + DefaultEventCapacity = 1000 + DefaultEventReplayWindow = 5 * time.Minute + DefaultEventHistoryTTL = DefaultEventReplayWindow + DefaultEventHeartbeatInterval = 15 * time.Second + DefaultEventSubscriberQueueSize = 32 + DefaultEventMaxStreamsPerUser = 20 + DefaultEventMaxStreamsPerInstance = 2 + DefaultEventMaxPayloadBytes = 256 << 10 + DefaultEventStreamLifetime = 15 * time.Minute + + RuntimeResyncRequired = "runtime.resync_required" + + ResyncReasonHistoryGap = "history_gap" + ResyncReasonGenerationMismatch = "generation_mismatch" + ResyncReasonInvalidCursor = "invalid_cursor" + ResyncReasonCursorAhead = "cursor_ahead" +) + +var ( + ErrEventHubClosed = errors.New("webapp: event hub is closed") + ErrInvalidEvent = errors.New("webapp: invalid event") + ErrEventPayloadTooLarge = errors.New("webapp: event payload exceeds limit") + ErrInvalidSubscription = errors.New("webapp: invalid event subscription") + ErrTooManyEventStreams = errors.New("webapp: too many event streams") + ErrSlowSubscriber = errors.New("webapp: slow event subscriber") +) + +// EventScope identifies the resources represented by an event. InstanceID is +// always populated by EventHub; the remaining fields are optional and are +// retained as public scope metadata for the web application. +type EventScope struct { + InstanceID string `json:"instance_id"` + WorkspaceID string `json:"workspace_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` +} + +// EventInput is the public, bounded input accepted by Publish. Data is +// marshaled before the event is retained, so callers cannot mutate history by +// changing a map or slice after Publish returns. +type EventInput struct { + Type string + Scope EventScope + Data any +} + +// Event is the immutable public event delivered to subscribers and encoded in +// an SSE data field. ID is generation:sequence and is stable for replay. +type Event struct { + ID string `json:"id,omitempty"` + Generation string `json:"generation"` + Sequence uint64 `json:"sequence,omitempty"` + Type string `json:"type"` + Scope EventScope `json:"scope"` + Data json.RawMessage `json:"data"` + published time.Time `json:"-"` +} + +// EventFilter is evaluated for every live and replayed event. A parent +// runtime can close over current grants and scope checks so authorization is +// re-evaluated without making the hub depend on the Service layer. +type EventFilter func(Event) bool + +// ResyncInfo is the payload of RuntimeResyncRequired. The empty SSE id sent +// with this event resets the browser's Last-Event-ID cursor. +type ResyncInfo struct { + Reason string `json:"reason"` + Generation string `json:"generation"` + Reset bool `json:"reset"` +} + +// EventSubscriptionRequest binds a stream to one authorized user and plugin +// instance. The request is intended to be created by the capability runtime, +// not from browser-supplied scope fields. +type EventSubscriptionRequest struct { + InstanceID string + UserID string + LastEventID string + Filter EventFilter +} + +// EventHubConfig provides bounded operational limits. Zero values use the +// production defaults. Now is injectable for deterministic replay expiry +// tests. +type EventHubConfig struct { + Generation string + MaxEvents int + ReplayWindow time.Duration + SubscriberQueueSize int + HeartbeatInterval time.Duration + StreamLifetime time.Duration + MaxStreamsPerUser int + MaxStreamsPerUserInstance int + MaxPayloadBytes int + Now func() time.Time +} + +// EventHub is an in-memory, per-process event transport. Event history is +// partitioned by plugin instance, while the generation identifies this +// process lifetime for reconnect recovery. +type EventHub struct { + mu sync.Mutex + instances map[string]*eventInstance + userStreams map[string]int + userInstanceStreams map[eventStreamKey]int + closed bool + generation string + config EventHubConfig + now func() time.Time +} + +type eventInstance struct { + lastSequence uint64 + events []Event + subscribers map[*EventSubscription]struct{} +} + +type eventStreamKey struct { + userID string + instanceID string +} + +// EventSubscription owns one bounded subscriber queue. Closing the +// subscription is idempotent and safe to race with Publish, Close, and +// request cancellation. +type EventSubscription struct { + hub *EventHub + instanceID string + userID string + filter EventFilter + events chan Event + done chan struct{} + + stateMu sync.RWMutex + closed bool + err error +} + +// NewEventHub creates a hub with production defaults. At most one config is +// used; the variadic form permits both NewEventHub() and concise test setup. +func NewEventHub(configs ...EventHubConfig) *EventHub { + config := EventHubConfig{} + if len(configs) > 0 { + config = configs[0] + } + applyEventHubDefaults(&config) + generation := strings.TrimSpace(config.Generation) + if !validGeneration(generation) { + generation = newEventGeneration() + } + now := config.Now + if now == nil { + now = time.Now + } + return &EventHub{ + instances: make(map[string]*eventInstance), + userStreams: make(map[string]int), + userInstanceStreams: make(map[eventStreamKey]int), + generation: generation, + config: config, + now: now, + } +} + +func applyEventHubDefaults(config *EventHubConfig) { + if config.MaxEvents <= 0 { + config.MaxEvents = DefaultEventCapacity + } + if config.ReplayWindow <= 0 { + config.ReplayWindow = DefaultEventReplayWindow + } + if config.SubscriberQueueSize <= 0 { + config.SubscriberQueueSize = DefaultEventSubscriberQueueSize + } + if config.HeartbeatInterval <= 0 { + config.HeartbeatInterval = DefaultEventHeartbeatInterval + } + if config.StreamLifetime <= 0 { + config.StreamLifetime = DefaultEventStreamLifetime + } + if config.MaxStreamsPerUser <= 0 { + config.MaxStreamsPerUser = DefaultEventMaxStreamsPerUser + } + if config.MaxStreamsPerUserInstance <= 0 { + config.MaxStreamsPerUserInstance = DefaultEventMaxStreamsPerInstance + } + if config.MaxPayloadBytes <= 0 { + config.MaxPayloadBytes = DefaultEventMaxPayloadBytes + } +} + +func newEventGeneration() string { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err == nil { + return hex.EncodeToString(buf) + } + return strconv.FormatInt(time.Now().UnixNano(), 10) +} + +func validGeneration(generation string) bool { + return generation != "" && !strings.ContainsAny(generation, "\r\n\t ") +} + +// Generation returns the process generation used by this hub. +func (h *EventHub) Generation() string { + if h == nil { + return "" + } + return h.generation +} + +// Publish assigns a per-instance monotonic sequence and makes the event +// available to future reconnects. Subscriber delivery is always non-blocking; +// a full subscriber queue is disconnected instead. +func (h *EventHub) Publish(instanceID string, input EventInput) (Event, error) { + if h == nil { + return Event{}, ErrEventHubClosed + } + instanceID = strings.TrimSpace(instanceID) + if err := validatePublishedEvent(instanceID, input); err != nil { + return Event{}, err + } + data, err := marshalEventData(input.Data, h.config.MaxPayloadBytes) + if err != nil { + return Event{}, err + } + scope := input.Scope + if scope.InstanceID == "" { + scope.InstanceID = instanceID + } + if scope.InstanceID != instanceID { + return Event{}, fmt.Errorf("%w: event instance does not match publish instance", ErrInvalidEvent) + } + + now := h.now().UTC() + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return Event{}, ErrEventHubClosed + } + instance := h.instanceLocked(instanceID) + instance.purgeExpired(now, h.config.ReplayWindow) + if instance.lastSequence == ^uint64(0) { + h.mu.Unlock() + return Event{}, fmt.Errorf("%w: event sequence exhausted", ErrInvalidEvent) + } + instance.lastSequence++ + event := Event{ + ID: formatEventID(h.generation, instance.lastSequence), + Generation: h.generation, + Sequence: instance.lastSequence, + Type: strings.TrimSpace(input.Type), + Scope: scope, + Data: cloneRawMessage(data), + published: now, + } + instance.events = append(instance.events, event) + if len(instance.events) > h.config.MaxEvents { + instance.events = instance.events[len(instance.events)-h.config.MaxEvents:] + } + subscribers := make([]*EventSubscription, 0, len(instance.subscribers)) + for subscriber := range instance.subscribers { + subscribers = append(subscribers, subscriber) + } + for _, subscriber := range subscribers { + candidate := cloneEvent(event) + if subscriber.filter != nil && !subscriber.filter(candidate) { + continue + } + h.enqueueLocked(subscriber, candidate) + } + h.mu.Unlock() + + event.Data = cloneRawMessage(event.Data) + return event, nil +} + +func validatePublishedEvent(instanceID string, input EventInput) error { + if instanceID == "" || strings.ContainsAny(instanceID, "\r\n") { + return fmt.Errorf("%w: instance id is required", ErrInvalidEvent) + } + typ := strings.TrimSpace(input.Type) + if typ == "" || strings.ContainsAny(typ, "\r\n") { + return fmt.Errorf("%w: event type is required", ErrInvalidEvent) + } + return nil +} + +func marshalEventData(data any, maxBytes int) (json.RawMessage, error) { + encoded, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("%w: marshal data: %v", ErrInvalidEvent, err) + } + if len(encoded) > maxBytes { + return nil, fmt.Errorf("%w: got %d bytes, limit is %d", ErrEventPayloadTooLarge, len(encoded), maxBytes) + } + return json.RawMessage(encoded), nil +} + +func (h *EventHub) instanceLocked(instanceID string) *eventInstance { + instance := h.instances[instanceID] + if instance == nil { + instance = &eventInstance{subscribers: make(map[*EventSubscription]struct{})} + h.instances[instanceID] = instance + } + return instance +} + +func (i *eventInstance) purgeExpired(now time.Time, window time.Duration) { + cutoff := now.Add(-window) + index := 0 + for index < len(i.events) && i.events[index].publishedAt().Before(cutoff) { + index++ + } + if index > 0 { + i.events = i.events[index:] + } +} + +func (e Event) publishedAt() time.Time { + return e.published +} + +func (h *EventHub) enqueueLocked(subscriber *EventSubscription, event Event) { + select { + case subscriber.events <- event: + default: + h.removeSubscriptionLocked(subscriber, ErrSlowSubscriber) + } +} + +// Subscribe creates a bounded stream and queues any complete replay range +// before returning. A cancellable context owns the subscription and releases +// it without requiring a timeout or a separate cleanup call. +func (h *EventHub) Subscribe(ctx context.Context, request EventSubscriptionRequest) (*EventSubscription, error) { + if h == nil { + return nil, ErrEventHubClosed + } + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return nil, err + } + request.InstanceID = strings.TrimSpace(request.InstanceID) + request.UserID = strings.TrimSpace(request.UserID) + if request.InstanceID == "" || request.UserID == "" || strings.ContainsAny(request.InstanceID+request.UserID, "\r\n") { + return nil, ErrInvalidSubscription + } + + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return nil, ErrEventHubClosed + } + key := eventStreamKey{userID: request.UserID, instanceID: request.InstanceID} + if h.userStreams[request.UserID] >= h.config.MaxStreamsPerUser || h.userInstanceStreams[key] >= h.config.MaxStreamsPerUserInstance { + h.mu.Unlock() + return nil, ErrTooManyEventStreams + } + instance := h.instanceLocked(request.InstanceID) + instance.purgeExpired(h.now().UTC(), h.config.ReplayWindow) + replay, resyncReason := h.replayLocked(instance, request) + queueSize := h.config.SubscriberQueueSize + initialEvents := len(replay) + if resyncReason != "" { + initialEvents++ + } + if queueSize <= initialEvents { + queueSize = initialEvents + 1 + } + subscriber := &EventSubscription{ + hub: h, + instanceID: request.InstanceID, + userID: request.UserID, + events: make(chan Event, queueSize), + done: make(chan struct{}), + filter: request.Filter, + } + instance.subscribers[subscriber] = struct{}{} + h.userStreams[request.UserID]++ + h.userInstanceStreams[key]++ + if resyncReason != "" { + h.enqueueLocked(subscriber, newResyncEvent(request.InstanceID, h.generation, resyncReason)) + } else { + for _, event := range replay { + h.enqueueLocked(subscriber, event) + } + } + h.mu.Unlock() + + watchSubscriptionContext(ctx, subscriber) + return subscriber, nil +} + +func (h *EventHub) replayLocked(instance *eventInstance, request EventSubscriptionRequest) ([]Event, string) { + lastID := strings.TrimSpace(request.LastEventID) + if lastID == "" { + return nil, "" + } + generation, sequence, ok := ParseEventID(lastID) + if !ok { + return nil, ResyncReasonInvalidCursor + } + if generation != h.generation { + return nil, ResyncReasonGenerationMismatch + } + if sequence > instance.lastSequence { + return nil, ResyncReasonCursorAhead + } + if len(instance.events) == 0 { + if sequence < instance.lastSequence { + return nil, ResyncReasonHistoryGap + } + return nil, "" + } + oldest := instance.events[0].Sequence + if sequence < oldest-1 { + return nil, ResyncReasonHistoryGap + } + replay := make([]Event, 0, len(instance.events)) + for _, event := range instance.events { + if event.Sequence <= sequence { + continue + } + candidate := cloneEvent(event) + if request.Filter == nil || request.Filter(candidate) { + replay = append(replay, candidate) + } + } + return replay, "" +} + +func newResyncEvent(instanceID, generation, reason string) Event { + data, _ := json.Marshal(ResyncInfo{Reason: reason, Generation: generation, Reset: true}) + return Event{ + Generation: generation, + Type: RuntimeResyncRequired, + Scope: EventScope{InstanceID: instanceID}, + Data: data, + } +} + +func watchSubscriptionContext(ctx context.Context, subscriber *EventSubscription) { + if ctx.Done() == nil { + return + } + go func() { + select { + case <-ctx.Done(): + _ = subscriber.closeWithError(ctx.Err()) + case <-subscriber.done: + } + }() +} + +// Events returns the replay and live event queue. It is closed when the +// subscription is canceled, explicitly closed, the hub closes, or a slow +// consumer is disconnected. +func (s *EventSubscription) Events() <-chan Event { + if s == nil { + return nil + } + return s.events +} + +// Done closes with the event queue and is useful to join cancellation cleanup +// in callers that consume Events directly. +func (s *EventSubscription) Done() <-chan struct{} { + if s == nil { + return nil + } + return s.done +} + +// Err reports why the subscription was closed, if it was closed abnormally. +func (s *EventSubscription) Err() error { + if s == nil { + return nil + } + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.err +} + +// IsClosed reports whether the subscription no longer accepts events. +func (s *EventSubscription) IsClosed() bool { + if s == nil { + return true + } + s.stateMu.RLock() + defer s.stateMu.RUnlock() + return s.closed +} + +// Close releases the subscription. It is safe to call more than once. +func (s *EventSubscription) Close() error { + return s.closeWithError(nil) +} + +// Unsubscribe is an alias for Close for callers that use subscription-style +// terminology. +func (s *EventSubscription) Unsubscribe() error { + return s.Close() +} + +func (s *EventSubscription) closeWithError(err error) error { + if s == nil || s.hub == nil { + return nil + } + s.hub.mu.Lock() + s.hub.removeSubscriptionLocked(s, err) + s.hub.mu.Unlock() + return nil +} + +func (h *EventHub) removeSubscriptionLocked(subscriber *EventSubscription, err error) { + instance := h.instances[subscriber.instanceID] + if instance != nil { + if _, active := instance.subscribers[subscriber]; active { + delete(instance.subscribers, subscriber) + key := eventStreamKey{userID: subscriber.userID, instanceID: subscriber.instanceID} + decrementCount(h.userStreams, subscriber.userID) + decrementCount(h.userInstanceStreams, key) + } + } + subscriber.closeLocked(err) +} + +func decrementCount[K comparable](counts map[K]int, key K) { + if counts[key] <= 1 { + delete(counts, key) + return + } + counts[key]-- +} + +func (s *EventSubscription) closeLocked(err error) { + s.stateMu.Lock() + defer s.stateMu.Unlock() + if s.closed { + return + } + s.closed = true + s.err = err + close(s.events) + close(s.done) +} + +// Close terminates the hub and all of its subscriptions. It does not wait on +// consumers or publishers because no hub operation performs a blocking send. +func (h *EventHub) Close() { + if h == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + return + } + h.closed = true + for _, instance := range h.instances { + for subscriber := range instance.subscribers { + subscriber.closeLocked(ErrEventHubClosed) + } + } + h.instances = make(map[string]*eventInstance) + h.userStreams = make(map[string]int) + h.userInstanceStreams = make(map[eventStreamKey]int) +} + +// Handler binds an authorized instance/user/filter tuple to a standard +// net/http handler. The request's Last-Event-ID header is preferred over the +// bound request value, and no query-token handling is provided here. +func (h *EventHub) Handler(request EventSubscriptionRequest) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.Serve(w, r, request) + }) +} + +// Serve writes one SSE stream for an authorized subscription request. +func (h *EventHub) Serve(w http.ResponseWriter, r *http.Request, request EventSubscriptionRequest) { + if h == nil { + writeEventStreamError(w, http.StatusServiceUnavailable, ErrEventHubClosed) + return + } + if !validEventStreamRequest(w, r) { + return + } + flusher, ok := eventStreamFlusher(w) + if !ok { + return + } + request = eventStreamRequest(r, request) + subscriber, err := h.Subscribe(r.Context(), request) + if err != nil { + writeEventStreamError(w, eventStreamErrorStatus(err), err) + return + } + defer func() { _ = subscriber.Close() }() + h.stream(w, r, flusher, subscriber) +} + +func validEventStreamRequest(w http.ResponseWriter, r *http.Request) bool { + if r != nil && r.Method == http.MethodGet { + return true + } + if w != nil { + w.Header().Set("Allow", http.MethodGet) + } + writeEventStreamError(w, http.StatusMethodNotAllowed, ErrInvalidSubscription) + return false +} + +func eventStreamFlusher(w http.ResponseWriter) (http.Flusher, bool) { + flusher, ok := w.(http.Flusher) + if !ok { + writeEventStreamError(w, http.StatusInternalServerError, errors.New("webapp: SSE requires response flushing")) + return nil, false + } + return flusher, true +} + +func eventStreamRequest(r *http.Request, request EventSubscriptionRequest) EventSubscriptionRequest { + if headerID := strings.TrimSpace(r.Header.Get("Last-Event-ID")); headerID != "" { + request.LastEventID = headerID + } + return request +} + +func (h *EventHub) stream(w http.ResponseWriter, r *http.Request, flusher http.Flusher, subscriber *EventSubscription) { + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + ticker := time.NewTicker(h.config.HeartbeatInterval) + defer ticker.Stop() + var lifetime <-chan time.Time + var timer *time.Timer + if h.config.StreamLifetime > 0 { + timer = time.NewTimer(h.config.StreamLifetime) + defer timer.Stop() + lifetime = timer.C + } + for { + select { + case event, open := <-subscriber.Events(): + if !open { + return + } + if err := writeSSE(w, event); err != nil { + return + } + flusher.Flush() + case <-ticker.C: + if _, err := io.WriteString(w, ": heartbeat\n\n"); err != nil { + return + } + flusher.Flush() + case <-r.Context().Done(): + return + case <-lifetime: + return + } + } +} + +func writeSSE(w io.Writer, event Event) error { + data, err := json.Marshal(event) + if err != nil { + return err + } + var stream strings.Builder + if event.ID == "" && event.Type == RuntimeResyncRequired { + stream.WriteString("id:\n") + } else if event.ID != "" { + stream.WriteString("id: ") + stream.WriteString(event.ID) + stream.WriteByte('\n') + } + stream.WriteString("event: ") + stream.WriteString(event.Type) + stream.WriteString("\ndata: ") + stream.Write(data) + stream.WriteString("\n\n") + _, err = io.WriteString(w, stream.String()) + return err +} + +func eventStreamErrorStatus(err error) int { + switch { + case errors.Is(err, ErrEventHubClosed): + return http.StatusServiceUnavailable + case errors.Is(err, ErrTooManyEventStreams): + return http.StatusTooManyRequests + case errors.Is(err, ErrInvalidSubscription): + return http.StatusBadRequest + default: + return http.StatusInternalServerError + } +} + +func writeEventStreamError(w http.ResponseWriter, status int, err error) { + if w == nil { + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + http.Error(w, err.Error(), status) +} + +// ParseEventID decodes the generation and sequence assigned by Publish. +func ParseEventID(id string) (generation string, sequence uint64, ok bool) { + id = strings.TrimSpace(id) + separator := strings.LastIndexByte(id, ':') + if separator <= 0 || separator == len(id)-1 { + return "", 0, false + } + generation = id[:separator] + sequence, err := strconv.ParseUint(id[separator+1:], 10, 64) + if err != nil || sequence == 0 || !validGeneration(generation) { + return "", 0, false + } + return generation, sequence, true +} + +func formatEventID(generation string, sequence uint64) string { + return generation + ":" + strconv.FormatUint(sequence, 10) +} + +func cloneEvent(event Event) Event { + event.Data = cloneRawMessage(event.Data) + return event +} + +func cloneRawMessage(data json.RawMessage) json.RawMessage { + if data == nil { + return nil + } + return append(json.RawMessage(nil), data...) +} diff --git a/apps/backend/internal/plugins/webapp/events_test.go b/apps/backend/internal/plugins/webapp/events_test.go new file mode 100644 index 0000000000..8abd74ef5b --- /dev/null +++ b/apps/backend/internal/plugins/webapp/events_test.go @@ -0,0 +1,436 @@ +package webapp + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sort" + "strings" + "sync" + "testing" + "time" +) + +// @covers AC-PLUGINS-ISOLATED-WEB-APPS-006.2 AC-PLUGINS-ISOLATED-WEB-APPS-006.3 AC-PLUGINS-ISOLATED-WEB-APPS-006.4 +func TestEventHubReplaysAuthorizedEventsAfterLastEventID(t *testing.T) { + hub := NewEventHub(EventHubConfig{Generation: "generation-1", SubscriberQueueSize: 4}) + t.Cleanup(func() { hub.Close() }) + + first := publishEvent(t, hub, EventInput{ + Type: "task.updated", + Scope: EventScope{TaskID: "task-1"}, + Data: map[string]string{"state": "started"}, + }) + publishEvent(t, hub, EventInput{ + Type: "task.updated", + Scope: EventScope{TaskID: "task-2"}, + Data: map[string]string{"state": "hidden"}, + }) + third := publishEvent(t, hub, EventInput{ + Type: "task.updated", + Scope: EventScope{TaskID: "task-1"}, + Data: map[string]string{"state": "completed"}, + }) + + sub, err := hub.Subscribe(context.Background(), EventSubscriptionRequest{ + InstanceID: "instance-1", + UserID: "user-1", + LastEventID: first.ID, + Filter: func(event Event) bool { + return event.Scope.TaskID == "task-1" + }, + }) + if err != nil { + t.Fatalf("Subscribe() unexpected error: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + + got := receiveEvent(t, sub) + if got.ID != third.ID || got.Scope.InstanceID != "instance-1" { + t.Fatalf("replayed event = %+v, want id %q for instance-1", got, third.ID) + } + if got.Type != "task.updated" || string(got.Data) != `{"state":"completed"}` { + t.Fatalf("replayed event payload = %+v", got) + } +} + +// @covers AC-PLUGINS-ISOLATED-WEB-APPS-006.1 AC-PLUGINS-ISOLATED-WEB-APPS-006.3 +func TestEventHubHandlerStreamsReplayAsSSE(t *testing.T) { + hub := NewEventHub(EventHubConfig{ + Generation: "generation-1", + HeartbeatInterval: time.Hour, + StreamLifetime: time.Minute, + SubscriberQueueSize: 4, + }) + t.Cleanup(func() { hub.Close() }) + + first := publishEvent(t, hub, EventInput{Type: "state.updated", Data: map[string]string{"value": "old"}}) + second := publishEvent(t, hub, EventInput{Type: "state.updated", Data: map[string]string{"value": "new"}}) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + server := httptest.NewServer(hub.Handler(EventSubscriptionRequest{ + InstanceID: "instance-1", + UserID: "user-1", + })) + t.Cleanup(server.Close) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/_kandev/v1/events", nil) + if err != nil { + t.Fatalf("NewRequestWithContext() unexpected error: %v", err) + } + request.Header.Set("Last-Event-ID", first.ID) + response, err := server.Client().Do(request) + if err != nil { + t.Fatalf("event request failed: %v", err) + } + t.Cleanup(func() { _ = response.Body.Close() }) + if got := response.Header.Get("Content-Type"); got != "text/event-stream" { + t.Fatalf("Content-Type = %q, want text/event-stream", got) + } + if got := response.Header.Get("X-Accel-Buffering"); got != "no" { + t.Fatalf("X-Accel-Buffering = %q, want no", got) + } + + streamEvent := receiveSSEEvent(t, bufio.NewReader(response.Body)) + if streamEvent["id"] != second.ID || streamEvent["event"] != second.Type { + t.Fatalf("SSE event = %+v, want id %q and type %q", streamEvent, second.ID, second.Type) + } + var payload Event + if err := json.Unmarshal([]byte(streamEvent["data"]), &payload); err != nil { + t.Fatalf("SSE data is not an event: %v", err) + } + if payload.Scope.InstanceID != "instance-1" || string(payload.Data) != `{"value":"new"}` { + t.Fatalf("SSE payload = %+v", payload) + } + cancel() +} + +// @covers AC-PLUGINS-ISOLATED-WEB-APPS-006.3 +func TestEventHubSendsResyncForHistoryGapAndGenerationMismatch(t *testing.T) { + tests := []struct { + name string + lastID string + publish int + wantReason string + }{ + {name: "history gap", lastID: "generation-1:1", publish: 4, wantReason: ResyncReasonHistoryGap}, + {name: "generation mismatch", lastID: "old-generation:1", publish: 1, wantReason: ResyncReasonGenerationMismatch}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + hub := NewEventHub(EventHubConfig{ + Generation: "generation-1", + MaxEvents: 2, + SubscriberQueueSize: 4, + }) + t.Cleanup(func() { hub.Close() }) + var last Event + for range tc.publish { + last = publishEvent(t, hub, EventInput{Type: "task.updated", Data: map[string]string{"ok": "true"}}) + } + if tc.name == "generation mismatch" && last.ID == "" { + t.Fatal("expected a published event") + } + + sub, err := hub.Subscribe(context.Background(), EventSubscriptionRequest{ + InstanceID: "instance-1", + UserID: "user-1", + LastEventID: tc.lastID, + }) + if err != nil { + t.Fatalf("Subscribe() unexpected error: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + + got := receiveEvent(t, sub) + if got.Type != RuntimeResyncRequired || got.ID != "" { + t.Fatalf("resync event = %+v", got) + } + var resync ResyncInfo + if err := json.Unmarshal(got.Data, &resync); err != nil { + t.Fatalf("resync payload is not JSON: %v", err) + } + if resync.Reason != tc.wantReason || resync.Generation != "generation-1" || !resync.Reset { + t.Fatalf("resync payload = %+v, want reason %q and reset", resync, tc.wantReason) + } + }) + } +} + +// @covers AC-PLUGINS-ISOLATED-WEB-APPS-006.5 AC-PLUGINS-ISOLATED-WEB-APPS-006.6 +func TestEventHubExpiresReplayHistory(t *testing.T) { + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + hub := NewEventHub(EventHubConfig{ + Generation: "generation-1", + ReplayWindow: 5 * time.Minute, + Now: func() time.Time { return now }, + SubscriberQueueSize: 4, + }) + t.Cleanup(func() { hub.Close() }) + + old := publishEvent(t, hub, EventInput{Type: "task.updated", Data: map[string]string{"value": "old"}}) + publishEvent(t, hub, EventInput{Type: "task.updated", Data: map[string]string{"value": "expired-too"}}) + now = now.Add(6 * time.Minute) + publishEvent(t, hub, EventInput{Type: "task.updated", Data: map[string]string{"value": "fresh-1"}}) + publishEvent(t, hub, EventInput{Type: "task.updated", Data: map[string]string{"value": "fresh-2"}}) + + sub, err := hub.Subscribe(context.Background(), EventSubscriptionRequest{ + InstanceID: "instance-1", + UserID: "user-1", + LastEventID: old.ID, + }) + if err != nil { + t.Fatalf("Subscribe() unexpected error: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + got := receiveEvent(t, sub) + if got.Type != RuntimeResyncRequired { + t.Fatalf("expired history event = %+v, want resync", got) + } +} + +// @covers AC-PLUGINS-ISOLATED-WEB-APPS-006.6 +func TestEventHubHandlerFlushesHeadersAndSendsHeartbeat(t *testing.T) { + hub := NewEventHub(EventHubConfig{ + Generation: "generation-1", + HeartbeatInterval: 20 * time.Millisecond, + StreamLifetime: time.Minute, + }) + t.Cleanup(func() { hub.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + server := httptest.NewServer(hub.Handler(EventSubscriptionRequest{ + InstanceID: "instance-1", + UserID: "user-1", + })) + t.Cleanup(server.Close) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) + if err != nil { + t.Fatalf("NewRequestWithContext() unexpected error: %v", err) + } + response, err := server.Client().Do(request) + if err != nil { + t.Fatalf("event request failed: %v", err) + } + t.Cleanup(func() { _ = response.Body.Close() }) + + reader := bufio.NewReader(response.Body) + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("heartbeat read failed: %v", err) + } + if strings.TrimSpace(line) != ": heartbeat" { + t.Fatalf("heartbeat line = %q, want ': heartbeat'", line) + } + line, err = reader.ReadString('\n') + if err != nil { + t.Fatalf("heartbeat terminator read failed: %v", err) + } + if strings.TrimSpace(line) != "" { + t.Fatalf("heartbeat terminator = %q, want blank line", line) + } + cancel() +} + +// @covers AC-PLUGINS-ISOLATED-WEB-APPS-006.6 +func TestEventHubCancellationReleasesConnectionAdmission(t *testing.T) { + hub := NewEventHub(EventHubConfig{ + MaxStreamsPerUser: 1, + MaxStreamsPerUserInstance: 1, + }) + t.Cleanup(func() { hub.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + first, err := hub.Subscribe(ctx, EventSubscriptionRequest{InstanceID: "instance-1", UserID: "user-1"}) + if err != nil { + t.Fatalf("first Subscribe() unexpected error: %v", err) + } + cancel() + waitForClosed(t, first.Done()) + + second, err := hub.Subscribe(context.Background(), EventSubscriptionRequest{InstanceID: "instance-1", UserID: "user-1"}) + if err != nil { + t.Fatalf("second Subscribe() after cancellation: %v", err) + } + if err := second.Close(); err != nil { + t.Fatalf("Close() unexpected error: %v", err) + } +} + +// @covers AC-PLUGINS-ISOLATED-WEB-APPS-006.6 +func TestEventHubDisconnectsSlowSubscriberWithoutBlockingPublish(t *testing.T) { + hub := NewEventHub(EventHubConfig{ + SubscriberQueueSize: 1, + MaxStreamsPerUser: 1, + MaxStreamsPerUserInstance: 1, + }) + t.Cleanup(func() { hub.Close() }) + + sub, err := hub.Subscribe(context.Background(), EventSubscriptionRequest{InstanceID: "instance-1", UserID: "user-1"}) + if err != nil { + t.Fatalf("Subscribe() unexpected error: %v", err) + } + publishEvent(t, hub, EventInput{Type: "task.updated", Data: map[string]string{"n": "1"}}) + + finished := make(chan struct{}) + go func() { + publishEvent(t, hub, EventInput{Type: "task.updated", Data: map[string]string{"n": "2"}}) + close(finished) + }() + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("Publish() blocked on a full subscriber queue") + } + if !errors.Is(sub.Err(), ErrSlowSubscriber) { + t.Fatalf("subscriber error = %v, want ErrSlowSubscriber", sub.Err()) + } + waitForClosed(t, sub.Done()) + + replacement, err := hub.Subscribe(context.Background(), EventSubscriptionRequest{InstanceID: "instance-1", UserID: "user-1"}) + if err != nil { + t.Fatalf("replacement Subscribe() unexpected error: %v", err) + } + t.Cleanup(func() { _ = replacement.Close() }) +} + +// @covers AC-PLUGINS-ISOLATED-WEB-APPS-006.2 AC-PLUGINS-ISOLATED-WEB-APPS-006.6 +func TestEventHubConcurrentPublishSubscribeAndClose(t *testing.T) { + hub := NewEventHub(EventHubConfig{ + Generation: "generation-1", + MaxEvents: 1000, + SubscriberQueueSize: 128, + MaxStreamsPerUser: 64, + }) + + const publishers = 8 + const eventsPerPublisher = 40 + var wg sync.WaitGroup + var mu sync.Mutex + ids := make(map[string]struct{}, publishers*eventsPerPublisher) + start := make(chan struct{}) + for i := 0; i < publishers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + for j := 0; j < eventsPerPublisher; j++ { + event, err := hub.Publish("instance-1", EventInput{Type: "task.updated", Data: map[string]int{"n": j}}) + if err != nil { + if errors.Is(err, ErrEventHubClosed) { + return + } + t.Errorf("Publish() unexpected error: %v", err) + return + } + mu.Lock() + if _, exists := ids[event.ID]; exists { + t.Errorf("duplicate event id %q", event.ID) + } + ids[event.ID] = struct{}{} + mu.Unlock() + } + }() + } + close(start) + var subscriberWG sync.WaitGroup + subscriberWG.Add(1) + go func() { + defer subscriberWG.Done() + for i := 0; i < 16; i++ { + sub, err := hub.Subscribe(context.Background(), EventSubscriptionRequest{ + InstanceID: "instance-1", + UserID: "user-1", + }) + if err == nil { + _ = sub.Close() + } + } + }() + wg.Wait() + subscriberWG.Wait() + hub.Close() + + mu.Lock() + defer mu.Unlock() + if len(ids) != publishers*eventsPerPublisher { + t.Fatalf("published event count = %d, want %d", len(ids), publishers*eventsPerPublisher) + } + sequences := make([]uint64, 0, len(ids)) + for id := range ids { + _, sequence, ok := ParseEventID(id) + if !ok { + t.Fatalf("event id %q is not parseable", id) + } + sequences = append(sequences, sequence) + } + sort.Slice(sequences, func(i, j int) bool { return sequences[i] < sequences[j] }) + for i, sequence := range sequences { + if sequence != uint64(i+1) { + t.Fatalf("event sequence[%d] = %d, want %d", i, sequence, i+1) + } + } +} + +func publishEvent(t *testing.T, hub *EventHub, input EventInput) Event { + t.Helper() + event, err := hub.Publish("instance-1", input) + if err != nil { + t.Fatalf("Publish() unexpected error: %v", err) + } + return event +} + +func receiveEvent(t *testing.T, sub *EventSubscription) Event { + t.Helper() + select { + case event, ok := <-sub.Events(): + if !ok { + t.Fatalf("subscription closed with error %v", sub.Err()) + } + return event + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + return Event{} + } +} + +func waitForClosed(t *testing.T, done <-chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("subscription did not close") + } +} + +func receiveSSEEvent(t *testing.T, reader *bufio.Reader) map[string]string { + t.Helper() + fields := make(map[string]string) + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read SSE event: %v", err) + } + line = strings.TrimSuffix(line, "\n") + line = strings.TrimSuffix(line, "\r") + if line == "" { + return fields + } + if strings.HasPrefix(line, ":") { + continue + } + key, value, ok := strings.Cut(line, ": ") + if !ok { + key, value, ok = strings.Cut(line, ":") + } + if ok { + fields[key] = value + } + } +} diff --git a/apps/backend/internal/plugins/webapp/goleak_test.go b/apps/backend/internal/plugins/webapp/goleak_test.go new file mode 100644 index 0000000000..5eeb6d6a00 --- /dev/null +++ b/apps/backend/internal/plugins/webapp/goleak_test.go @@ -0,0 +1,11 @@ +package webapp + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/apps/backend/internal/plugins/webapp/package.go b/apps/backend/internal/plugins/webapp/package.go new file mode 100644 index 0000000000..65c12804b1 --- /dev/null +++ b/apps/backend/internal/plugins/webapp/package.go @@ -0,0 +1,634 @@ +// Package webapp validates and stores immutable static plugin web applications. +// It is deliberately separate from pkgtar: a static web application does not +// need a managed backend executable, and its files are served from a release +// directory rather than installed as a native plugin. +package webapp + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/kandev/kandev/internal/plugins/manifest" +) + +const ( + MaxCompressedBytes = int64(10 << 20) + MaxExpandedBytes = int64(25 << 20) + MaxFiles = 512 + MaxFileBytes = int64(5 << 20) + MaxManifestBytes = int64(64 << 10) + MaxPathBytes = 240 +) + +var ( + ErrCompressedTooLarge = errors.New("webapp: compressed package exceeds limit") + ErrExpandedTooLarge = errors.New("webapp: expanded package exceeds limit") + ErrTooManyFiles = errors.New("webapp: package contains too many files") + ErrFileTooLarge = errors.New("webapp: file exceeds limit") + ErrManifestTooLarge = errors.New("webapp: manifest exceeds limit") + ErrUnsafePath = errors.New("webapp: unsafe package path") + ErrUnsafeEntry = errors.New("webapp: unsafe archive entry") + ErrDuplicatePath = errors.New("webapp: duplicate normalized package path") + ErrUnsupportedFile = errors.New("webapp: unsupported file type") + ErrEntryMissing = errors.New("webapp: declared entry document is missing") + ErrNotStaticWebApp = errors.New("webapp: manifest does not declare a static web application") + ErrArtifactUnavailable = errors.New("webapp: artifact unavailable") +) + +// Limits is injectable so package tests and operators can exercise the same +// validation code with smaller bounds. Zero values use DefaultLimits. +type Limits struct { + MaxCompressedBytes int64 + MaxExpandedBytes int64 + MaxFiles int + MaxFileBytes int64 + MaxManifestBytes int64 + MaxPathBytes int +} + +func DefaultLimits() Limits { + return Limits{ + MaxCompressedBytes: MaxCompressedBytes, + MaxExpandedBytes: MaxExpandedBytes, + MaxFiles: MaxFiles, + MaxFileBytes: MaxFileBytes, + MaxManifestBytes: MaxManifestBytes, + MaxPathBytes: MaxPathBytes, + } +} + +// Package is a fully validated release candidate. Files are keyed by +// normalized package-relative slash paths and are safe to copy to immutable +// artifact storage. +type Package struct { + Manifest *manifest.Manifest + Files map[string][]byte + Digest string + CompressedBytes int64 + ExpandedBytes int64 +} + +// ValidatePackage validates a gzip-compressed tar package using the +// production limits. +func ValidatePackage(r io.Reader) (*Package, error) { + return ValidatePackageWithLimits(r, DefaultLimits()) +} + +// Validate is a short alias used by callers that already own a package +// validator abstraction. +func Validate(r io.Reader) (*Package, error) { + return ValidatePackage(r) +} + +// ValidatePackageWithLimits validates all archive entries before returning a +// release candidate. It does not write to disk or execute package content. +func ValidatePackageWithLimits(r io.Reader, limits Limits) (*Package, error) { + limits = withDefaultLimits(limits) + compressed, err := readCompressed(r, limits.MaxCompressedBytes) + if err != nil { + return nil, err + } + reader, err := gzip.NewReader(bytes.NewReader(compressed)) + if err != nil { + return nil, fmt.Errorf("webapp: open gzip package: %w", err) + } + defer func() { _ = reader.Close() }() + files, expanded, err := readArchiveFiles(reader, limits) + if err != nil { + return nil, err + } + m, err := parseStaticManifest(files, limits) + if err != nil { + return nil, err + } + + return &Package{ + Manifest: m, + Files: files, + Digest: digestFiles(files), + CompressedBytes: int64(len(compressed)), + ExpandedBytes: expanded, + }, nil +} + +// ValidateTarPackageWithLimits validates an uncompressed tar stream such as +// the bounded source stream returned by an agentctl workspace. Directory +// entries are accepted and discarded because tar walkers commonly emit them; +// the extracted release still contains only regular files. +func ValidateTarPackageWithLimits(r io.Reader, limits Limits, wireBytes int64) (*Package, error) { + limits = withDefaultLimits(limits) + if r == nil { + return nil, errors.New("webapp: package reader is nil") + } + if wireBytes < 0 || wireBytes > limits.MaxCompressedBytes { + return nil, ErrCompressedTooLarge + } + files, expanded, err := readArchiveFilesWithOptions(r, limits, true) + if err != nil { + return nil, err + } + m, err := parseStaticManifest(files, limits) + if err != nil { + return nil, err + } + return &Package{ + Manifest: m, + Files: files, + Digest: digestFiles(files), + CompressedBytes: wireBytes, + ExpandedBytes: expanded, + }, nil +} + +// ValidateTarPackage validates an uncompressed tar source stream using the +// normal static web-app limits. +func ValidateTarPackage(r io.Reader, wireBytes int64) (*Package, error) { + return ValidateTarPackageWithLimits(r, DefaultLimits(), wireBytes) +} + +func readArchiveFiles(reader io.Reader, limits Limits) (map[string][]byte, int64, error) { + return readArchiveFilesWithOptions(reader, limits, false) +} + +func readArchiveFilesWithOptions(reader io.Reader, limits Limits, allowDirectories bool) (map[string][]byte, int64, error) { + files := make(map[string][]byte) + var expanded int64 + tarReader := tar.NewReader(reader) + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + return files, expanded, nil + } + if err != nil { + return nil, 0, fmt.Errorf("webapp: read archive: %w", err) + } + name, data, nextExpanded, err := readArchiveEntry(tarReader, header, files, expanded, limits, allowDirectories) + if err != nil { + return nil, 0, err + } + if name == "" { + continue + } + files[name] = data + expanded = nextExpanded + } +} + +func readArchiveEntry(reader *tar.Reader, header *tar.Header, files map[string][]byte, expanded int64, limits Limits, allowDirectories bool) (string, []byte, int64, error) { + if len(files) >= limits.MaxFiles && (!allowDirectories || header.Typeflag != tar.TypeDir) { + return "", nil, 0, ErrTooManyFiles + } + if allowDirectories && header.Typeflag == tar.TypeDir { + return readArchiveDirectory(header, files, expanded, limits) + } + if header.Typeflag != tar.TypeReg && header.Typeflag != 0 { + return "", nil, 0, fmt.Errorf("%w: %s", ErrUnsafeEntry, header.Name) + } + return readArchiveRegularFile(reader, header, files, expanded, limits) +} + +func readArchiveDirectory(header *tar.Header, files map[string][]byte, expanded int64, limits Limits) (string, []byte, int64, error) { + name, err := normalizePackagePath(strings.TrimSuffix(header.Name, "/"), limits.MaxPathBytes) + if err != nil { + return "", nil, 0, err + } + if _, exists := files[name]; exists { + return "", nil, 0, fmt.Errorf("%w: %s", ErrDuplicatePath, name) + } + return "", nil, expanded, nil +} + +func readArchiveRegularFile(reader *tar.Reader, header *tar.Header, files map[string][]byte, expanded int64, limits Limits) (string, []byte, int64, error) { + name, err := normalizePackagePath(header.Name, limits.MaxPathBytes) + if err != nil { + return "", nil, 0, err + } + if _, exists := files[name]; exists { + return "", nil, 0, fmt.Errorf("%w: %s", ErrDuplicatePath, name) + } + if !supportedFile(name) { + return "", nil, 0, fmt.Errorf("%w: %s", ErrUnsupportedFile, name) + } + if header.Size < 0 || header.Size > limits.MaxFileBytes { + return "", nil, 0, fmt.Errorf("%w: %s", ErrFileTooLarge, name) + } + if expanded > limits.MaxExpandedBytes-header.Size { + return "", nil, 0, ErrExpandedTooLarge + } + data, err := io.ReadAll(io.LimitReader(reader, header.Size+1)) + if err != nil { + return "", nil, 0, fmt.Errorf("webapp: read %s: %w", name, err) + } + if int64(len(data)) != header.Size { + return "", nil, 0, fmt.Errorf("webapp: short file %s", name) + } + return name, data, expanded + int64(len(data)), nil +} + +func parseStaticManifest(files map[string][]byte, limits Limits) (*manifest.Manifest, error) { + manifestData, ok := files["manifest.yaml"] + if !ok { + return nil, fmt.Errorf("%w: manifest.yaml", ErrManifestInvalid) + } + if int64(len(manifestData)) > limits.MaxManifestBytes { + return nil, ErrManifestTooLarge + } + m, err := manifest.Parse(manifestData) + if err != nil { + return nil, fmt.Errorf("webapp: parse manifest: %w", err) + } + if err := m.Validate(); err != nil { + return nil, fmt.Errorf("webapp: invalid manifest: %w", err) + } + if !m.IsStaticWebAppOnly() { + return nil, ErrNotStaticWebApp + } + for _, app := range m.UI.WebApps { + if _, ok := files[app.Entry]; !ok { + return nil, fmt.Errorf("%w: %s", ErrEntryMissing, app.Entry) + } + } + return m, nil +} + +// ErrManifestInvalid is kept separate so callers can map malformed package +// input to one stable validation result without exposing file contents. +var ErrManifestInvalid = errors.New("webapp: invalid manifest") + +func withDefaultLimits(limits Limits) Limits { + defaults := DefaultLimits() + if limits.MaxCompressedBytes <= 0 { + limits.MaxCompressedBytes = defaults.MaxCompressedBytes + } + if limits.MaxExpandedBytes <= 0 { + limits.MaxExpandedBytes = defaults.MaxExpandedBytes + } + if limits.MaxFiles <= 0 { + limits.MaxFiles = defaults.MaxFiles + } + if limits.MaxFileBytes <= 0 { + limits.MaxFileBytes = defaults.MaxFileBytes + } + if limits.MaxManifestBytes <= 0 { + limits.MaxManifestBytes = defaults.MaxManifestBytes + } + if limits.MaxPathBytes <= 0 { + limits.MaxPathBytes = defaults.MaxPathBytes + } + return limits +} + +func readCompressed(r io.Reader, max int64) ([]byte, error) { + if r == nil { + return nil, errors.New("webapp: package reader is nil") + } + data, err := io.ReadAll(io.LimitReader(r, max+1)) + if err != nil { + return nil, fmt.Errorf("webapp: read compressed package: %w", err) + } + if int64(len(data)) > max { + return nil, ErrCompressedTooLarge + } + return data, nil +} + +func normalizePackagePath(name string, maxBytes int) (string, error) { + if name == "" || len(name) > maxBytes || strings.ContainsRune(name, '\x00') || strings.Contains(name, "\\") { + return "", fmt.Errorf("%w: %q", ErrUnsafePath, name) + } + if path.IsAbs(name) { + return "", fmt.Errorf("%w: %q", ErrUnsafePath, name) + } + clean := path.Clean(name) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("%w: %q", ErrUnsafePath, name) + } + return clean, nil +} + +func supportedFile(name string) bool { + if name == "manifest.yaml" { + return true + } + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map", + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico", + ".woff", ".woff2", ".ttf", ".otf", ".eot", ".md", ".txt": + return true + default: + return false + } +} + +func digestFiles(files map[string][]byte) string { + paths := make([]string, 0, len(files)) + for name := range files { + paths = append(paths, name) + } + sort.Strings(paths) + h := sha256.New() + for _, name := range paths { + _, _ = io.WriteString(h, name) + _, _ = h.Write([]byte{0}) + _, _ = h.Write(files[name]) + _, _ = h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// Artifact describes one immutable extracted release directory. +type Artifact struct { + Digest string + RelativePath string + Bytes int64 + Available bool + Reason string +} + +// ArtifactStore owns release files below one Kandev-home recovery boundary. +type ArtifactStore struct{ root string } + +func NewArtifactStore(root string) (*ArtifactStore, error) { + if strings.TrimSpace(root) == "" { + return nil, errors.New("webapp: artifact root is empty") + } + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, fmt.Errorf("webapp: create artifact root: %w", err) + } + return &ArtifactStore{root: root}, nil +} + +// Path returns the absolute path for an artifact after validating its stored +// relative path. An invalid artifact returns an empty path. +func (s *ArtifactStore) Path(artifact Artifact) string { + if s == nil || artifact.RelativePath == "" { + return "" + } + clean, err := normalizePackagePath(artifact.RelativePath, MaxPathBytes*4) + if err != nil { + return "" + } + return filepath.Join(s.root, filepath.FromSlash(clean)) +} + +// Open opens one regular file below an immutable artifact. The caller must +// still close the returned file. No path supplied by a web application is +// accepted here; runtime handlers pass the manifest entry or a package path +// that has already been normalized. +func (s *ArtifactStore) Open(artifact Artifact, name string) (*os.File, error) { + root := s.Path(artifact) + clean, err := normalizePackagePath(name, MaxPathBytes) + if root == "" || err != nil { + return nil, fmt.Errorf("%w: %q", ErrUnsafePath, name) + } + filePath := filepath.Join(root, filepath.FromSlash(clean)) + relative, err := filepath.Rel(root, filePath) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("%w: %q", ErrUnsafePath, name) + } + info, err := os.Lstat(filePath) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%w: %q", ErrUnsafeEntry, name) + } + return os.Open(filePath) +} + +// Remove deletes one validated release directory. Lifecycle services call +// this only after the corresponding database cleanup job commits. +func (s *ArtifactStore) Remove(artifact Artifact) error { + return s.RemoveRelativePath(artifact.RelativePath) +} + +// RemoveRelativePath removes one release directory identified by durable +// cleanup metadata. Only descendants of releases/ are accepted, so a corrupt +// cleanup row cannot remove the artifact-store root or another store. +func (s *ArtifactStore) RemoveRelativePath(relativePath string) error { + clean, err := normalizePackagePath(relativePath, MaxPathBytes*4) + if s == nil || err != nil || clean == "releases" || !strings.HasPrefix(clean, "releases/") { + return fmt.Errorf("%w: artifact path", ErrUnsafePath) + } + return os.RemoveAll(filepath.Join(s.root, filepath.FromSlash(clean))) +} + +// Put extracts a validated package into a temporary directory and atomically +// renames it into the digest-addressed release directory. +func (s *ArtifactStore) Put(pkg *Package) (Artifact, error) { + artifact, _, err := s.PutWithCreated(pkg) + return artifact, err +} + +// PutWithCreated is the lifecycle-aware form of Put. created is true only +// when this call installed the digest directory; callers can then remove that +// directory if the following database commit fails without deleting an +// artifact another release already owns. +func (s *ArtifactStore) PutWithCreated(pkg *Package) (Artifact, bool, error) { + artifact, target, err := s.artifactTarget(pkg) + if err != nil { + return Artifact{}, false, err + } + exists, err := artifactExists(target) + if err != nil { + return Artifact{}, false, err + } + if exists { + return artifact, false, nil + } + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return Artifact{}, false, fmt.Errorf("webapp: create release parent: %w", err) + } + tmp, err := os.MkdirTemp(filepath.Dir(target), ".release-") + if err != nil { + return Artifact{}, false, fmt.Errorf("webapp: create release temp: %w", err) + } + defer func() { _ = os.RemoveAll(tmp) }() + if err := writeArtifactFiles(tmp, pkg.Files); err != nil { + return Artifact{}, false, err + } + created, err := commitArtifact(tmp, target) + if err != nil { + return Artifact{}, false, err + } + return artifact, created, nil +} + +func (s *ArtifactStore) artifactTarget(pkg *Package) (Artifact, string, error) { + if s == nil || pkg == nil || pkg.Digest == "" { + return Artifact{}, "", errors.New("webapp: package or artifact store is nil") + } + if !isSafeDigest(pkg.Digest) { + return Artifact{}, "", fmt.Errorf("%w: invalid digest", ErrUnsafePath) + } + artifact := Artifact{Digest: pkg.Digest, RelativePath: filepath.ToSlash(filepath.Join("releases", pkg.Digest)), Bytes: pkg.ExpandedBytes, Available: true} + target := s.Path(artifact) + if target == "" { + return Artifact{}, "", fmt.Errorf("%w: artifact path", ErrUnsafePath) + } + return artifact, target, nil +} + +func artifactExists(target string) (bool, error) { + info, err := os.Stat(target) + if err == nil { + if !info.IsDir() { + return false, fmt.Errorf("%w: artifact target is not a directory", ErrArtifactUnavailable) + } + return true, nil + } + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("webapp: inspect artifact: %w", err) +} + +func writeArtifactFiles(root string, files map[string][]byte) error { + for name, data := range files { + clean, err := normalizePackagePath(name, MaxPathBytes) + if err != nil { + return err + } + filePath := filepath.Join(root, filepath.FromSlash(clean)) + if err := os.MkdirAll(filepath.Dir(filePath), 0o700); err != nil { + return fmt.Errorf("webapp: create release directory: %w", err) + } + if err := os.WriteFile(filePath, data, 0o600); err != nil { + return fmt.Errorf("webapp: write release file: %w", err) + } + } + return nil +} + +func commitArtifact(tmp, target string) (bool, error) { + if err := os.Rename(tmp, target); err != nil { + if exists, statErr := artifactExists(target); statErr == nil && exists { + return false, nil + } + return false, fmt.Errorf("webapp: commit release artifact: %w", err) + } + return true, nil +} + +// Reconcile verifies the digest-addressed artifact without removing it. A +// mismatch, unsafe entry, or missing directory becomes unavailable so callers +// can preserve recovery metadata and avoid executing it. +func (s *ArtifactStore) Reconcile(artifact Artifact) (Artifact, error) { + artifact.Available = false + artifact.Reason = "missing" + root := s.Path(artifact) + if root == "" { + artifact.Reason = "unsafe_path" + return artifact, nil + } + info, err := os.Stat(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return artifact, nil + } + return artifact, fmt.Errorf("%w: stat artifact: %v", ErrArtifactUnavailable, err) + } + if !info.IsDir() { + artifact.Reason = "not_directory" + return artifact, nil + } + files, err := readArtifactFiles(root) + if err != nil { + artifact.Reason = "unsafe_artifact" + return artifact, nil + } + if digestFiles(files) != artifact.Digest { + artifact.Reason = "digest_mismatch" + return artifact, nil + } + artifact.Bytes = 0 + artifact.Bytes = artifactBytes(files) + artifact.Available = true + artifact.Reason = "" + return artifact, nil +} + +// ReadFiles verifies and reads an immutable release artifact for a trusted +// backend caller. The returned paths are package-relative and the method does +// not expose the artifact root to the caller, so callers can transfer the +// bytes to a remote agent workspace without assuming that workspace is on the +// backend host. +func (s *ArtifactStore) ReadFiles(artifact Artifact) (map[string][]byte, error) { + reconciled, err := s.Reconcile(artifact) + if err != nil { + return nil, err + } + if !reconciled.Available { + if reconciled.Reason == "" { + reconciled.Reason = "unavailable" + } + return nil, fmt.Errorf("%w: %s", ErrArtifactUnavailable, reconciled.Reason) + } + root := s.Path(reconciled) + if root == "" { + return nil, fmt.Errorf("%w: unsafe artifact path", ErrArtifactUnavailable) + } + files, err := readArtifactFiles(root) + if err != nil { + return nil, fmt.Errorf("%w: read artifact: %v", ErrArtifactUnavailable, err) + } + if digestFiles(files) != reconciled.Digest { + return nil, fmt.Errorf("%w: digest mismatch", ErrArtifactUnavailable) + } + return files, nil +} + +func readArtifactFiles(root string) (map[string][]byte, error) { + files := make(map[string][]byte) + err := filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error { + if walkErr != nil || filePath == root || entry.IsDir() { + return walkErr + } + relative, err := filepath.Rel(root, filePath) + if err != nil { + return err + } + name, err := normalizePackagePath(filepath.ToSlash(relative), MaxPathBytes) + if err != nil { + return err + } + if entry.Type()&os.ModeSymlink != 0 || !entry.Type().IsRegular() { + return ErrUnsafeEntry + } + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + files[name] = data + return nil + }) + return files, err +} + +func artifactBytes(files map[string][]byte) int64 { + var total int64 + for _, data := range files { + total += int64(len(data)) + } + return total +} + +func isSafeDigest(value string) bool { + if len(value) != sha256.Size*2 { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} diff --git a/apps/backend/internal/plugins/webapp/package_test.go b/apps/backend/internal/plugins/webapp/package_test.go new file mode 100644 index 0000000000..67cc6cc510 --- /dev/null +++ b/apps/backend/internal/plugins/webapp/package_test.go @@ -0,0 +1,202 @@ +package webapp + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidatePackageAcceptsStaticCanvas(t *testing.T) { + archive := canvasArchive(t, map[string]string{ + "manifest.yaml": `id: canvas-board +api_version: 2 +version: 1.0.0 +display_name: Canvas Board +description: Static board +author: test +ui: + web_apps: + - key: main + title: Task board + entry: ui/index.html + placements: [task-canvas, workspace-canvas] +`, + "ui/index.html": "board", + "ui/app.js": "document.body.dataset.ready = 'true';", + "ui/app.css": "body { color: black; }", + }) + + pkg, err := ValidatePackage(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("ValidatePackage() unexpected error: %v", err) + } + if pkg.Manifest.ID != "canvas-board" || pkg.Manifest.UI.WebApps[0].Entry != "ui/index.html" { + t.Fatalf("validated manifest = %+v", pkg.Manifest) + } + if pkg.Digest == "" || pkg.ExpandedBytes == 0 || pkg.CompressedBytes != int64(len(archive)) { + t.Fatalf("package metadata = %+v", pkg) + } +} + +func TestValidatePackageRejectsTraversalSymlinkDuplicateAndUnsupported(t *testing.T) { + cases := []struct { + name string + entry tar.Header + want error + }{ + {name: "traversal", entry: tar.Header{Name: "../escape.js", Mode: 0o600, Size: 1, Typeflag: tar.TypeReg}, want: ErrUnsafePath}, + {name: "symlink", entry: tar.Header{Name: "ui/link.js", Linkname: "index.html", Typeflag: tar.TypeSymlink}, want: ErrUnsafeEntry}, + {name: "unsupported", entry: tar.Header{Name: "ui/app.wasm", Mode: 0o600, Size: 1, Typeflag: tar.TypeReg}, want: ErrUnsupportedFile}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + archive := archiveWithHeaders(t, []archiveEntry{ + {header: tar.Header{Name: "manifest.yaml", Mode: 0o600, Size: int64(len(staticManifestYAML)), Typeflag: tar.TypeReg}, data: staticManifestYAML}, + {header: tc.entry, data: "x"}, + }) + _, err := ValidatePackage(bytes.NewReader(archive)) + if !errors.Is(err, tc.want) { + t.Fatalf("ValidatePackage() error = %v, want %v", err, tc.want) + } + }) + } + + duplicate := archiveWithHeaders(t, []archiveEntry{ + {header: tar.Header{Name: "manifest.yaml", Mode: 0o600, Size: int64(len(staticManifestYAML)), Typeflag: tar.TypeReg}, data: staticManifestYAML}, + {header: tar.Header{Name: "ui/index.html", Mode: 0o600, Size: 1, Typeflag: tar.TypeReg}, data: "x"}, + {header: tar.Header{Name: "ui/./index.html", Mode: 0o600, Size: 1, Typeflag: tar.TypeReg}, data: "y"}, + }) + if _, err := ValidatePackage(bytes.NewReader(duplicate)); !errors.Is(err, ErrDuplicatePath) { + t.Fatalf("duplicate error = %v, want %v", err, ErrDuplicatePath) + } +} + +func TestValidatePackageRejectsOversizedFileWithoutRetainingIt(t *testing.T) { + limits := DefaultLimits() + limits.MaxFileBytes = 4 + archive := canvasArchive(t, map[string]string{ + "manifest.yaml": staticManifestYAML, + "ui/index.html": "12345", + }) + _, err := ValidatePackageWithLimits(bytes.NewReader(archive), limits) + if !errors.Is(err, ErrFileTooLarge) { + t.Fatalf("ValidatePackageWithLimits() error = %v, want %v", err, ErrFileTooLarge) + } +} + +func TestValidatePackageRejectsManagedWebAppPackage(t *testing.T) { + archive := canvasArchive(t, map[string]string{ + "manifest.yaml": `id: managed-board +api_version: 2 +version: 1.0.0 +display_name: Managed Board +description: Managed web app +author: test +base_url: https://plugin.example.test +endpoints: + health: /health + events: /events + webhooks: /webhooks +ui: + web_apps: + - key: main + title: Board + entry: ui/index.html + placements: [task-canvas] +`, + "ui/index.html": "managed", + }) + if _, err := ValidatePackage(bytes.NewReader(archive)); !errors.Is(err, ErrNotStaticWebApp) { + t.Fatalf("ValidatePackage() error = %v, want %v", err, ErrNotStaticWebApp) + } +} + +func TestArtifactStorePutAndReconcileMarksChangedArtifactUnavailable(t *testing.T) { + archive := canvasArchive(t, map[string]string{ + "manifest.yaml": staticManifestYAML, + "ui/index.html": "ok", + }) + pkg, err := ValidatePackage(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("ValidatePackage() unexpected error: %v", err) + } + store, err := NewArtifactStore(filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatalf("NewArtifactStore() unexpected error: %v", err) + } + artifact, err := store.Put(pkg) + if err != nil { + t.Fatalf("Put() unexpected error: %v", err) + } + if !artifact.Available || artifact.RelativePath == "" { + t.Fatalf("artifact = %+v", artifact) + } + if err := os.WriteFile(filepath.Join(store.Path(artifact), "ui", "index.html"), []byte("tampered"), 0o600); err != nil { + t.Fatalf("tamper artifact: %v", err) + } + status, err := store.Reconcile(artifact) + if err != nil { + t.Fatalf("Reconcile() unexpected error: %v", err) + } + if status.Available || status.Reason != "digest_mismatch" { + t.Fatalf("reconciled artifact = %+v", status) + } +} + +const staticManifestYAML = `id: canvas-board +api_version: 2 +version: 1.0.0 +display_name: Canvas Board +description: Static board +author: test +ui: + web_apps: + - key: main + title: Task board + entry: ui/index.html + placements: [task-canvas] +` + +type archiveEntry struct { + header tar.Header + data string +} + +func canvasArchive(t *testing.T, files map[string]string) []byte { + t.Helper() + entries := make([]archiveEntry, 0, len(files)) + for name, data := range files { + entries = append(entries, archiveEntry{header: tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), Typeflag: tar.TypeReg}, data: data}) + } + return archiveWithHeaders(t, entries) +} + +func archiveWithHeaders(t *testing.T, entries []archiveEntry) []byte { + t.Helper() + var compressed bytes.Buffer + zw := gzip.NewWriter(&compressed) + tw := tar.NewWriter(zw) + for _, entry := range entries { + if err := tw.WriteHeader(&entry.header); err != nil { + t.Fatalf("WriteHeader() unexpected error: %v", err) + } + if entry.header.Typeflag == tar.TypeReg { + if _, err := io.Copy(tw, strings.NewReader(entry.data)); err != nil { + t.Fatalf("write archive data: %v", err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return compressed.Bytes() +} diff --git a/apps/backend/internal/plugins/webapp/policy.go b/apps/backend/internal/plugins/webapp/policy.go new file mode 100644 index 0000000000..2f6bb0e82e --- /dev/null +++ b/apps/backend/internal/plugins/webapp/policy.go @@ -0,0 +1,96 @@ +package webapp + +import ( + "errors" + "fmt" + "net/url" + "sort" + "strings" +) + +var ErrInvalidNetworkOrigin = errors.New("webapp: invalid network origin") + +// NormalizeNetworkOrigins accepts only exact HTTPS origins. Paths, query +// strings, credentials, wildcards, and non-HTTPS schemes cannot be granted. +func NormalizeNetworkOrigins(origins []string) ([]string, error) { + seen := make(map[string]struct{}, len(origins)) + for _, raw := range origins { + origin, err := normalizeOrigin(raw, "https") + if err != nil { + return nil, err + } + seen[origin] = struct{}{} + } + result := make([]string, 0, len(seen)) + for origin := range seen { + result = append(result, origin) + } + sort.Strings(result) + return result, nil +} + +// BuildContentSecurityPolicy creates the complete response policy from +// normalized, user-approved network origins and the configured Kandev host +// origins. Manifest text is never copied directly into this header. +func BuildContentSecurityPolicy(networkOrigins, frameAncestors []string) (string, error) { + network, err := NormalizeNetworkOrigins(networkOrigins) + if err != nil { + return "", err + } + frames, err := normalizeFrameAncestors(frameAncestors) + if err != nil { + return "", err + } + connect := append([]string{"'self'"}, network...) + images := append([]string{"'self'", "data:"}, network...) + fonts := append([]string{"'self'", "data:"}, network...) + return strings.Join([]string{ + "sandbox allow-scripts allow-forms", + "default-src 'none'", + "form-action 'none'", + "base-uri 'none'", + "object-src 'none'", + // Packaged canvases may be a single HTML file. Inline scripts are + // allowed by the approved canvas contract, while the absence of + // unsafe-eval and remote script sources keeps code execution bounded. + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src " + strings.Join(images, " "), + "font-src " + strings.Join(fonts, " "), + "connect-src " + strings.Join(connect, " "), + "frame-ancestors " + strings.Join(frames, " "), + }, "; "), nil +} + +func normalizeOrigin(raw, scheme string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != scheme || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("%w: %q", ErrInvalidNetworkOrigin, raw) + } + if strings.ContainsAny(parsed.Host, " \t\r\n") || parsed.Hostname() == "" { + return "", fmt.Errorf("%w: %q", ErrInvalidNetworkOrigin, raw) + } + return parsed.Scheme + "://" + strings.ToLower(parsed.Host), nil +} + +func normalizeFrameAncestors(origins []string) ([]string, error) { + seen := make(map[string]struct{}, len(origins)) + result := make([]string, 0, len(origins)) + for _, raw := range origins { + trimmed := strings.TrimSpace(raw) + parsed, err := url.Parse(trimmed) + if err != nil || strings.Contains(trimmed, "*") || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("%w: frame ancestor %q", ErrInvalidNetworkOrigin, raw) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" && parsed.Scheme != "tauri" { + return nil, fmt.Errorf("%w: frame ancestor %q", ErrInvalidNetworkOrigin, raw) + } + origin := parsed.Scheme + "://" + strings.ToLower(parsed.Host) + if _, exists := seen[origin]; exists { + continue + } + seen[origin] = struct{}{} + result = append(result, origin) + } + return result, nil +} diff --git a/apps/backend/internal/plugins/webapp/policy_test.go b/apps/backend/internal/plugins/webapp/policy_test.go new file mode 100644 index 0000000000..742abcc38f --- /dev/null +++ b/apps/backend/internal/plugins/webapp/policy_test.go @@ -0,0 +1,50 @@ +package webapp + +import ( + "strings" + "testing" +) + +func TestBuildContentSecurityPolicyIsGrantBoundAndOpaque(t *testing.T) { + policy, err := BuildContentSecurityPolicy([]string{"https://api.example.com:443", "https://api.example.com:443"}, []string{"http://127.0.0.1:38429", "tauri://localhost", "http://tauri.localhost"}) + if err != nil { + t.Fatalf("BuildContentSecurityPolicy() unexpected error: %v", err) + } + for _, required := range []string{ + "sandbox allow-scripts allow-forms", + "default-src 'none'", + "form-action 'none'", + "base-uri 'none'", + "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", + } { + if !strings.Contains(policy, required) { + t.Fatalf("policy %q missing %q", policy, required) + } + } + if strings.Count(policy, "api.example.com:443") != 3 { + t.Fatalf("policy includes a duplicate or missing origin: %q", policy) + } + if strings.Contains(policy, "'unsafe-eval'") || strings.Contains(policy, "script-src https://") { + t.Fatalf("policy enables eval or remote scripts: %q", policy) + } +} + +func TestNormalizeNetworkOriginsRejectsNonHTTPSAndPaths(t *testing.T) { + if _, err := NormalizeNetworkOrigins([]string{"http://example.com"}); err == nil { + t.Fatal("NormalizeNetworkOrigins(http) succeeded, want error") + } + if _, err := NormalizeNetworkOrigins([]string{"https://example.com/path"}); err == nil { + t.Fatal("NormalizeNetworkOrigins(path) succeeded, want error") + } +} + +func TestBuildContentSecurityPolicyRejectsWildcardFrameAncestors(t *testing.T) { + for _, origin := range []string{"http://localhost:*", "http://*:38429", "http://*"} { + if _, err := BuildContentSecurityPolicy(nil, []string{origin}); err == nil { + t.Fatalf("BuildContentSecurityPolicy(%q) succeeded, want wildcard rejection", origin) + } + } +} diff --git a/apps/backend/internal/plugins/webapp/runtime.go b/apps/backend/internal/plugins/webapp/runtime.go new file mode 100644 index 0000000000..3fabc60b12 --- /dev/null +++ b/apps/backend/internal/plugins/webapp/runtime.go @@ -0,0 +1,271 @@ +package webapp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +// Runtime serves only validated immutable release files through capability +// URLs. It has no dependency on the ambient Kandev session middleware. +type Runtime struct { + tokens *TokenManager + artifacts *ArtifactStore + validate BindingValidator + protocol ProtocolHandler + frameAncestors []string +} + +// ProtocolHandler serves the relative application API after Runtime has +// validated the bearer capability. path is relative to the `_kandev/` root; +// the handler must apply its own response-size and method limits. +type ProtocolHandler func(http.ResponseWriter, *http.Request, string, CapabilityBinding, string) + +const runtimeCapabilityPath = "/api/v1/plugins/web-apps/runtime/" + +func NewRuntime(tokens *TokenManager, artifacts *ArtifactStore, validate BindingValidator, frameAncestors []string) *Runtime { + if tokens == nil { + tokens = NewTokenManager(nil) + } + if len(frameAncestors) == 0 { + frameAncestors = []string{"http://127.0.0.1:38429", "tauri://localhost", "http://tauri.localhost"} + } + return &Runtime{tokens: tokens, artifacts: artifacts, validate: validate, frameAncestors: append([]string(nil), frameAncestors...)} +} + +// FrameAncestorsForConfig returns the exact browser and desktop shell origins +// that may embed a capability runtime. Ports are explicit because a wildcard +// frame-src/frame-ancestors rule would let an unrelated local service frame a +// canvas. webOrigin is the launcher-provided SPA origin when it differs from +// the backend origin. +func FrameAncestorsForConfig(backendPort, webPort int, webOrigin string) ([]string, error) { + if backendPort < 1 || backendPort > 65535 || webPort < 1 || webPort > 65535 { + return nil, fmt.Errorf("webapp: invalid runtime origin port") + } + origins := []string{ + fmt.Sprintf("http://localhost:%d", backendPort), + fmt.Sprintf("http://127.0.0.1:%d", backendPort), + fmt.Sprintf("http://localhost:%d", webPort), + fmt.Sprintf("http://127.0.0.1:%d", webPort), + "tauri://localhost", + "http://tauri.localhost", + } + if strings.TrimSpace(webOrigin) != "" { + origins = append(origins, webOrigin) + } + return normalizeFrameAncestors(origins) +} + +// SetProtocolHandler attaches the versioned browser data protocol. It is +// configured during startup before the HTTP server accepts requests. +func (rt *Runtime) SetProtocolHandler(handler ProtocolHandler) { + if rt == nil { + return + } + rt.protocol = handler +} + +// IssueCapabilityPath creates a short-lived capability URL path. The caller +// supplies the already-authorized binding; the path is relative so the host +// can use it from either the browser SPA or the desktop shell without trusting +// an inbound Host header. +func (rt *Runtime) IssueCapabilityPath(binding CapabilityBinding, ttl time.Duration) (string, error) { + if rt == nil || rt.tokens == nil { + return "", ErrRuntimeTokenInvalid + } + token, err := rt.tokens.Issue(binding, ttl) + if err != nil { + return "", err + } + return runtimeCapabilityPath + url.PathEscape(token) + "/", nil +} + +// Serve handles one capability URL request. path is the path below the +// capability segment; an empty path serves the declared entry document. +func (rt *Runtime) Serve(w http.ResponseWriter, r *http.Request, token, requestPath string) { + if rt == nil || rt.tokens == nil { + writeRuntimeError(w, http.StatusNotFound, ErrRuntimeTokenInvalid) + return + } + binding, err := rt.tokens.Validate(token) + if err != nil { + writeRuntimeError(w, http.StatusNotFound, err) + return + } + if rt.validate != nil { + if err := rt.validate(r.Context(), binding); err != nil { + writeRuntimeError(w, runtimeAuthorizationStatus(err), err) + return + } + } + if protocolPath, ok := runtimeProtocolPath(requestPath); ok { + if rt.protocol == nil { + writeRuntimeError(w, http.StatusNotFound, ErrArtifactUnavailable) + return + } + rt.protocol(w, r, token, binding, protocolPath) + return + } + name, err := runtimeFilePath(requestPath, binding.Entry) + if err != nil { + writeRuntimeError(w, http.StatusNotFound, err) + return + } + if rt.artifacts == nil { + writeRuntimeError(w, http.StatusNotFound, ErrArtifactUnavailable) + return + } + file, err := rt.artifacts.Open(binding.Artifact, name) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + writeRuntimeError(w, http.StatusNotFound, ErrArtifactUnavailable) + return + } + writeRuntimeError(w, http.StatusNotFound, err) + return + } + defer func() { _ = file.Close() }() + + policy, err := BuildContentSecurityPolicy(binding.NetworkOrigins, rt.frameAncestors) + if err != nil { + writeRuntimeError(w, http.StatusInternalServerError, err) + return + } + setRuntimeHeaders(w, policy, r.Header.Get("Origin")) + contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))) + if contentType == "" { + 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())) + } + _, _ = io.Copy(w, file) +} + +// Handler returns a standard-library handler bound to one capability token. +// Backend routing uses this helper when its router already extracted the +// token and the remainder path. +func (rt *Runtime) Handler(token, requestPath string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rt.Serve(w, r, token, requestPath) + }) +} + +func runtimeFilePath(requestPath, entry string) (string, error) { + entryName, err := normalizePackagePath(strings.TrimPrefix(strings.TrimSpace(entry), "/"), MaxPathBytes) + if err != nil { + return "", err + } + name := strings.TrimPrefix(strings.TrimSpace(requestPath), "/") + if name == "" { + return entryName, nil + } + if strings.HasPrefix(name, "_kandev/") || name == "_kandev" { + return "", ErrUnsafePath + } + name, err = normalizePackagePath(name, MaxPathBytes) + if err != nil { + return "", err + } + // The capability URL is rooted at the application entry directory. This + // keeps ordinary relative references such as ./app.js and ./app.css + // working when the manifest entry is nested (for example ui/index.html), + // while still allowing an explicit package-root path such as ui/app.js. + entryDir := path.Dir(entryName) + if entryDir != "." && !strings.HasPrefix(name, entryDir+"/") { + name = path.Join(entryDir, name) + } + return normalizePackagePath(name, MaxPathBytes) +} + +func runtimeProtocolPath(requestPath string) (string, bool) { + name := strings.TrimPrefix(strings.TrimSpace(requestPath), "/") + if name == "_kandev" { + return "", true + } + if strings.HasPrefix(name, "_kandev/") { + return strings.TrimPrefix(name, "_kandev/"), true + } + return "", false +} + +func setRuntimeHeaders(w http.ResponseWriter, policy, origin string) { + w.Header().Set("Content-Security-Policy", policy) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin") + if origin == "null" { + w.Header().Set("Access-Control-Allow-Origin", "null") + w.Header().Add("Vary", "Origin") + } +} + +// SetProtocolHeaders applies the headers shared by capability API and event +// responses. It is exported for the protocol adapter, while asset responses +// additionally receive the response CSP from setRuntimeHeaders. +func SetProtocolHeaders(w http.ResponseWriter, origin string) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin") + if origin == "null" { + w.Header().Set("Access-Control-Allow-Origin", "null") + w.Header().Add("Vary", "Origin") + } +} + +func writeRuntimeError(w http.ResponseWriter, status int, err error) { + code := runtimeErrorCode(err) + body, marshalErr := json.Marshal(struct { + Error string `json:"error"` + }{Error: code}) + if marshalErr != nil { + body = []byte(`{"error":"runtime_unavailable"}`) + } + w.Header().Set("Cache-Control", "no-store") + 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.WriteHeader(status) + _, _ = w.Write(body) +} + +func runtimeAuthorizationStatus(err error) int { + if errors.Is(err, ErrRuntimeTokenStale) { + return http.StatusUnauthorized + } + return http.StatusForbidden +} + +func runtimeErrorCode(err error) string { + switch { + case errors.Is(err, ErrRuntimeTokenInvalid): + return ErrRuntimeTokenInvalid.Error() + case errors.Is(err, ErrRuntimeTokenExpired): + return ErrRuntimeTokenExpired.Error() + case errors.Is(err, ErrRuntimeTokenStale): + return ErrRuntimeTokenStale.Error() + case errors.Is(err, ErrArtifactUnavailable): + return ErrArtifactUnavailable.Error() + case errors.Is(err, ErrUnsafePath): + return ErrUnsafePath.Error() + default: + return "runtime_unavailable" + } +} + +// Keep context in this file's API surface so implementations of a validator +// can use request cancellation without importing the HTTP package elsewhere. +var _ BindingValidator = func(context.Context, CapabilityBinding) error { return nil } diff --git a/apps/backend/internal/plugins/webapp/runtime_test.go b/apps/backend/internal/plugins/webapp/runtime_test.go new file mode 100644 index 0000000000..c75a9502eb --- /dev/null +++ b/apps/backend/internal/plugins/webapp/runtime_test.go @@ -0,0 +1,218 @@ +package webapp + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func TestRuntimeServesEntryWithSecurityHeadersAndNoCookies(t *testing.T) { + archive := canvasArchive(t, map[string]string{ + "manifest.yaml": staticManifestYAML, + "ui/index.html": "safe", + }) + 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, []string{"http://127.0.0.1:38429", "tauri://localhost", "http://tauri.localhost"}) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "null") + req.AddCookie(&http.Cookie{Name: "kandev_session", Value: "must-not-be-used"}) + response := httptest.NewRecorder() + runtime.Serve(response, req, token, "") + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + 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") { + t.Fatalf("CSP = %q", got) + } + for key, want := range map[string]string{ + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Cross-Origin-Resource-Policy": "cross-origin", + "Access-Control-Allow-Origin": "null", + } { + if got := response.Header().Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + if got := response.Header().Get("Set-Cookie"); got != "" { + t.Fatalf("runtime set a cookie: %q", got) + } +} + +func TestRuntimeResolvesNestedEntryAssetsAndKeepsProtocolRoot(t *testing.T) { + archive := canvasArchive(t, map[string]string{ + "manifest.yaml": staticManifestYAML, + "ui/index.html": ``, + "ui/app.js": `fetch("./_kandev/v1/context")`, + "ui/app.css": `body { color: red; }`, + }) + 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) + binding := CapabilityBinding{ + UserID: "user-1", InstanceID: "instance-1", ReleaseID: "release-1", WebAppKey: "main", + Placement: "task-canvas", Artifact: artifact, Entry: "ui/index.html", + } + token, err := manager.Issue(binding, 0) + if err != nil { + t.Fatalf("Issue: %v", err) + } + runtime := NewRuntime(manager, artifacts, nil, nil) + runtime.SetProtocolHandler(func(w http.ResponseWriter, _ *http.Request, _ string, _ CapabilityBinding, protocolPath string) { + if protocolPath != "v1/context" { + t.Errorf("protocol path = %q, want v1/context", protocolPath) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"protocol":true}`)) + }) + + for requestPath, want := range map[string]string{ + "app.js": `fetch("./_kandev/v1/context")`, + "app.css": `body { color: red; }`, + } { + response := httptest.NewRecorder() + runtime.Serve(response, httptest.NewRequest(http.MethodGet, "/", nil), token, requestPath) + if response.Code != http.StatusOK || response.Body.String() != want { + t.Fatalf("%s response = %d %q, want 200 %q", requestPath, response.Code, response.Body.String(), want) + } + } + + protocolResponse := httptest.NewRecorder() + runtime.Serve(protocolResponse, httptest.NewRequest(http.MethodGet, "/", nil), token, "_kandev/v1/context") + if protocolResponse.Code != http.StatusOK || protocolResponse.Body.String() != `{"protocol":true}` { + t.Fatalf("protocol response = %d %q", protocolResponse.Code, protocolResponse.Body.String()) + } +} + +func TestRuntimeRejectsStaleCapabilityBeforeReadingArtifact(t *testing.T) { + manager := NewTokenManager(nil) + token, err := manager.Issue(CapabilityBinding{UserID: "u", InstanceID: "i", ReleaseID: "r", WebAppKey: "main", Artifact: Artifact{Digest: strings.Repeat("c", 64), RelativePath: "releases/" + strings.Repeat("c", 64)}, Entry: "ui/index.html"}, 0) + if err != nil { + t.Fatalf("Issue: %v", err) + } + runtime := NewRuntime(manager, nil, func(_ context.Context, _ CapabilityBinding) error { return ErrRuntimeTokenStale }, nil) + response := httptest.NewRecorder() + runtime.Serve(response, httptest.NewRequest(http.MethodGet, "/", nil), token, "ui/index.html") + if response.Code != http.StatusUnauthorized || !strings.Contains(response.Body.String(), "runtime_token_stale") { + t.Fatalf("response = %d %q", response.Code, response.Body.String()) + } + if body, _ := io.ReadAll(response.Body); len(body) == 0 { + t.Fatal("stale token response has no safe error") + } +} + +func TestRuntimeRevalidatesBindingAfterAuthorityRevocation(t *testing.T) { + archive := canvasArchive(t, map[string]string{ + "manifest.yaml": staticManifestYAML, + "ui/index.html": "entry", + "ui/app.js": "script", + }) + 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) + } + revoked := false + runtime := NewRuntime(manager, artifacts, func(_ context.Context, _ CapabilityBinding) error { + if revoked { + return ErrRuntimeTokenStale + } + return nil + }, nil) + first := httptest.NewRecorder() + runtime.Serve(first, httptest.NewRequest(http.MethodGet, "/", nil), token, "") + if first.Code != http.StatusOK { + t.Fatalf("initial entry response = %d %q, want 200", first.Code, first.Body.String()) + } + revoked = true + second := httptest.NewRecorder() + runtime.Serve(second, httptest.NewRequest(http.MethodGet, "/", nil), token, "app.js") + if second.Code != http.StatusUnauthorized || !strings.Contains(second.Body.String(), "runtime_token_stale") { + t.Fatalf("post-revocation asset response = %d %q, want stale-token denial", second.Code, second.Body.String()) + } +} + +func TestFrameAncestorsForConfigIncludesExactBrowserLauncherOrigins(t *testing.T) { + origins, err := FrameAncestorsForConfig(48123, 48124, "http://localhost:48124") + if err != nil { + t.Fatalf("FrameAncestorsForConfig() unexpected error: %v", err) + } + want := []string{ + "http://localhost:48123", + "http://127.0.0.1:48123", + "http://localhost:48124", + "tauri://localhost", + "http://tauri.localhost", + } + for _, origin := range want { + if !containsString(origins, origin) { + t.Fatalf("frame origins = %v, missing %q", origins, origin) + } + } + for _, origin := range origins { + if strings.Contains(origin, "*") { + t.Fatalf("frame origin %q contains a wildcard", origin) + } + } +} diff --git a/apps/backend/internal/plugins/webapp/tokens.go b/apps/backend/internal/plugins/webapp/tokens.go new file mode 100644 index 0000000000..826e4eb622 --- /dev/null +++ b/apps/backend/internal/plugins/webapp/tokens.go @@ -0,0 +1,220 @@ +package webapp + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + "sync" + "time" +) + +const RuntimeTokenTTL = 15 * time.Minute + +const maxExpiredCapabilityTombstones = 1024 + +var ( + ErrRuntimeTokenInvalid = errors.New("runtime_token_invalid") + ErrRuntimeTokenExpired = errors.New("runtime_token_expired") + ErrRuntimeTokenStale = errors.New("runtime_token_stale") +) + +// CapabilityBinding is the complete authority binding for one runtime URL. +// It contains identifiers and a release artifact reference, never a user +// credential or a source payload. +type CapabilityBinding struct { + PluginID string + UserID string + InstanceID string + ReleaseID string + WebAppKey string + Placement string + ScopeKind string + WorkspaceID string + TaskID string + SessionID string + RepositoryID string + GrantGeneration int64 + Permissions []string + Artifact Artifact + Entry string + NetworkOrigins []string +} + +type storedCapability struct { + binding CapabilityBinding + issuedAt time.Time + expiresAt time.Time +} + +// TokenManager stores only a SHA-256 digest of each random runtime token. +// Tokens are deliberately process-local and short-lived; instance and grant +// changes are checked by the caller through BindingValidator on every request. +type TokenManager struct { + mu sync.Mutex + tokens map[[sha256.Size]byte]storedCapability + expired map[[sha256.Size]byte]time.Time + now func() time.Time +} + +func NewTokenManager(now func() time.Time) *TokenManager { + if now == nil { + now = time.Now + } + return &TokenManager{tokens: make(map[[sha256.Size]byte]storedCapability), expired: make(map[[sha256.Size]byte]time.Time), now: now} +} + +func (m *TokenManager) Issue(binding CapabilityBinding, ttl time.Duration) (string, error) { + if m == nil || binding.UserID == "" || binding.InstanceID == "" || binding.ReleaseID == "" || binding.WebAppKey == "" || binding.Entry == "" { + return "", ErrRuntimeTokenInvalid + } + if ttl <= 0 || ttl > RuntimeTokenTTL { + ttl = RuntimeTokenTTL + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate runtime capability: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(raw) + issuedAt := m.now().UTC() + m.mu.Lock() + m.sweepExpiredLocked(issuedAt) + digest := digestToken(token) + delete(m.expired, digest) + m.tokens[digest] = storedCapability{binding: binding, issuedAt: issuedAt, expiresAt: issuedAt.Add(ttl)} + m.mu.Unlock() + return token, nil +} + +func (m *TokenManager) IssueURL(baseURL string, binding CapabilityBinding, ttl time.Duration) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", ErrRuntimeTokenInvalid + } + token, err := m.Issue(binding, ttl) + if err != nil { + return "", err + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/v1/plugins/web-apps/runtime/" + token + "/" + return parsed.String(), nil +} + +func (m *TokenManager) Validate(token string) (CapabilityBinding, error) { + if m == nil || strings.TrimSpace(token) == "" { + return CapabilityBinding{}, ErrRuntimeTokenInvalid + } + digest := digestToken(token) + now := m.now().UTC() + m.mu.Lock() + m.sweepExpiredLocked(now) + capability, ok := m.tokens[digest] + if ok && !now.Before(capability.expiresAt) { + delete(m.tokens, digest) + m.expired[digest] = now + ok = false + } + _, wasExpired := m.expired[digest] + m.mu.Unlock() + if !ok { + if wasExpired { + return CapabilityBinding{}, ErrRuntimeTokenExpired + } + return CapabilityBinding{}, ErrRuntimeTokenInvalid + } + return capability.binding, nil +} + +// Renew extends a valid token by at most RuntimeTokenTTL. The raw token is +// never copied into the stored capability. +func (m *TokenManager) Renew(token string, ttl time.Duration) error { + if m == nil || strings.TrimSpace(token) == "" { + return ErrRuntimeTokenInvalid + } + if ttl <= 0 || ttl > RuntimeTokenTTL { + ttl = RuntimeTokenTTL + } + digest := digestToken(token) + now := m.now().UTC() + m.mu.Lock() + defer m.mu.Unlock() + m.sweepExpiredLocked(now) + capability, ok := m.tokens[digest] + if !ok { + if _, wasExpired := m.expired[digest]; wasExpired { + return ErrRuntimeTokenExpired + } + return ErrRuntimeTokenInvalid + } + if !now.Before(capability.expiresAt) { + delete(m.tokens, digest) + m.expired[digest] = now + return ErrRuntimeTokenExpired + } + capability.expiresAt = now.Add(ttl) + m.tokens[digest] = capability + return nil +} + +func (m *TokenManager) Revoke(token string) { + if m == nil { + return + } + m.mu.Lock() + delete(m.tokens, digestToken(token)) + delete(m.expired, digestToken(token)) + m.mu.Unlock() +} + +func (m *TokenManager) StoredTokenCount() int { + if m == nil { + return 0 + } + m.mu.Lock() + defer m.mu.Unlock() + m.sweepExpiredLocked(m.now().UTC()) + return len(m.tokens) +} + +func (m *TokenManager) sweepExpiredLocked(now time.Time) { + cutoff := now.Add(-RuntimeTokenTTL) + for digest, expiredAt := range m.expired { + if !expiredAt.After(cutoff) { + delete(m.expired, digest) + } + } + for len(m.expired) > maxExpiredCapabilityTombstones { + var oldestDigest [sha256.Size]byte + var oldestAt time.Time + for digest, expiredAt := range m.expired { + if oldestAt.IsZero() || expiredAt.Before(oldestAt) { + oldestDigest, oldestAt = digest, expiredAt + } + } + delete(m.expired, oldestDigest) + } +} + +// HasRawToken is a diagnostic test helper. Since the map key is a fixed-size +// digest, this is always false unless a future implementation regresses to a +// raw-token map. +func (m *TokenManager) HasRawToken(token string) bool { + if m == nil { + return false + } + m.mu.Lock() + defer m.mu.Unlock() + _, ok := any(m.tokens).(map[string]storedCapability) + return ok && token != "" +} + +func digestToken(token string) [sha256.Size]byte { + return sha256.Sum256([]byte(token)) +} + +// BindingValidator rechecks instance status, active release, grants, scope, +// and current caller authorization on every runtime request. +type BindingValidator func(context.Context, CapabilityBinding) error diff --git a/apps/backend/internal/plugins/webapp/tokens_test.go b/apps/backend/internal/plugins/webapp/tokens_test.go new file mode 100644 index 0000000000..66ec6a1d22 --- /dev/null +++ b/apps/backend/internal/plugins/webapp/tokens_test.go @@ -0,0 +1,79 @@ +package webapp + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestTokenManagerStoresOnlyDigestAndBindsReleaseScope(t *testing.T) { + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + manager := NewTokenManager(func() time.Time { return now }) + binding := CapabilityBinding{ + UserID: "user-1", + InstanceID: "instance-1", + ReleaseID: "release-1", + WebAppKey: "main", + Placement: "task-canvas", + ScopeKind: "task", + WorkspaceID: "workspace-1", + TaskID: "task-1", + GrantGeneration: 3, + Artifact: Artifact{Digest: strings.Repeat("a", 64), RelativePath: "releases/" + strings.Repeat("a", 64)}, + Entry: "ui/index.html", + } + token, err := manager.Issue(binding, time.Minute) + if err != nil { + t.Fatalf("Issue() unexpected error: %v", err) + } + if token == "" || manager.StoredTokenCount() != 1 { + t.Fatalf("token/count = %q/%d", token, manager.StoredTokenCount()) + } + got, err := manager.Validate(token) + if err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + if got.UserID != binding.UserID || got.ReleaseID != binding.ReleaseID || got.GrantGeneration != 3 { + t.Fatalf("binding = %+v", got) + } + if manager.HasRawToken(token) { + t.Fatal("token manager retained the raw capability") + } + + now = now.Add(2 * time.Minute) + if _, err := manager.Validate(token); !errors.Is(err, ErrRuntimeTokenExpired) { + t.Fatalf("expired token error = %v, want ErrRuntimeTokenExpired", err) + } +} + +func TestTokenManagerIssueURLDoesNotUseQueryToken(t *testing.T) { + manager := NewTokenManager(nil) + binding := CapabilityBinding{UserID: "u", InstanceID: "i", ReleaseID: "r", WebAppKey: "main", Placement: "task-canvas", Artifact: Artifact{Digest: strings.Repeat("b", 64), RelativePath: "releases/" + strings.Repeat("b", 64)}, Entry: "ui/index.html"} + url, err := manager.IssueURL("http://127.0.0.1:38429", binding, time.Minute) + if err != nil { + t.Fatalf("IssueURL() unexpected error: %v", err) + } + if strings.Contains(url, "?") || strings.Contains(url, "token=") { + t.Fatalf("runtime URL contains a query token: %q", url) + } +} + +func TestTokenManagerBoundsExpiredTombstones(t *testing.T) { + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + manager := NewTokenManager(func() time.Time { return now }) + for i := 0; i < maxExpiredCapabilityTombstones+25; i++ { + manager.expired[digestToken(strings.Repeat("x", i+1))] = now + } + if _, err := manager.Issue(CapabilityBinding{UserID: "u", InstanceID: "i", ReleaseID: "r", WebAppKey: "main", Placement: "task-canvas", Entry: "ui/index.html"}, time.Minute); err != nil { + t.Fatalf("issue token: %v", err) + } + if len(manager.expired) > maxExpiredCapabilityTombstones { + t.Fatalf("expired tombstones = %d, want at most %d", len(manager.expired), maxExpiredCapabilityTombstones) + } + now = now.Add(RuntimeTokenTTL + time.Second) + manager.StoredTokenCount() + if len(manager.expired) != 0 { + t.Fatalf("expired tombstones after retention = %d, want 0", len(manager.expired)) + } +} diff --git a/apps/backend/internal/plugins/webapp_cleanup.go b/apps/backend/internal/plugins/webapp_cleanup.go new file mode 100644 index 0000000000..f58acace75 --- /dev/null +++ b/apps/backend/internal/plugins/webapp_cleanup.go @@ -0,0 +1,99 @@ +package plugins + +import ( + "context" + "errors" + "time" + + "github.com/kandev/kandev/internal/plugins/instances" +) + +const ( + webAppCleanupInterval = time.Minute + webAppCleanupBatch = 32 + webAppCleanupMaxBackoff = time.Minute +) + +// StartWebAppArtifactCleanupWorker starts the durable release-artifact +// cleanup loop. Jobs are claimed from SQLite before files are removed, so a +// process restart can requeue an interrupted removal without losing the +// cleanup intent. +func (s *Service) StartWebAppArtifactCleanupWorker(parent context.Context) func() { + if s == nil || parent == nil { + return func() {} + } + store := s.Instances() + artifacts := s.WebArtifacts() + if store == nil || artifacts == nil { + return func() {} + } + ctx, cancel := context.WithCancel(parent) + done := make(chan struct{}) + go func() { + defer close(done) + _ = store.RequeueRunningCleanupJobs(ctx) + _ = s.RunWebAppArtifactCleanupOnce(ctx) + ticker := time.NewTicker(webAppCleanupInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _ = s.RunWebAppArtifactCleanupOnce(ctx) + } + } + }() + return func() { + cancel() + <-done + } +} + +// RunWebAppArtifactCleanupOnce drains one bounded batch of due cleanup jobs. +// It is exported for startup orchestration and deterministic maintenance +// tests; normal callers should use StartWebAppArtifactCleanupWorker. +func (s *Service) RunWebAppArtifactCleanupOnce(ctx context.Context) error { + if s == nil || ctx == nil { + return nil + } + store := s.Instances() + artifacts := s.WebArtifacts() + if store == nil || artifacts == nil { + return nil + } + for range webAppCleanupBatch { + job, claimed, err := store.ClaimCleanupJob(ctx, time.Now().UTC()) + if err != nil { + return err + } + if !claimed { + return nil + } + _, err = store.RemoveArtifactIfUnreferenced(ctx, job.ID, job.ArtifactPath, func() error { + return artifacts.RemoveRelativePath(job.ArtifactPath) + }) + if err != nil { + next := time.Now().UTC().Add(cleanupBackoff(job.Attempts)) + if retryErr := store.RetryCleanupJob(ctx, job.ID, next, err); retryErr != nil && !errors.Is(retryErr, instances.ErrNotFound) { + return retryErr + } + continue + } + } + return nil +} + +func cleanupBackoff(attempts int) time.Duration { + if attempts < 1 { + attempts = 1 + } + backoff := time.Second + for i := 1; i < attempts && backoff < webAppCleanupMaxBackoff; i++ { + backoff *= 2 + } + if backoff > webAppCleanupMaxBackoff { + return webAppCleanupMaxBackoff + } + return backoff +} diff --git a/apps/backend/internal/plugins/webapp_events.go b/apps/backend/internal/plugins/webapp_events.go new file mode 100644 index 0000000000..85dc641949 --- /dev/null +++ b/apps/backend/internal/plugins/webapp_events.go @@ -0,0 +1,307 @@ +package plugins + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/webapp" +) + +const webAppEventValidationTimeout = 2 * time.Second + +// subscribeWebAppEvents connects the public web-app transport to the existing +// Kandev event bus. The hub remains an in-process bounded projection, so a +// restart creates a new generation and never pretends that old events are +// replayable. +func (s *Service) subscribeWebAppEvents() { + if s == nil || s.eventBus == nil { + return + } + s.mu.Lock() + if s.eventSubscription != nil { + s.mu.Unlock() + return + } + s.mu.Unlock() + subscription, err := s.eventBus.Subscribe(">", s.forwardWebAppEvent) + if err != nil { + return + } + s.mu.Lock() + if s.eventSubscription == nil { + s.eventSubscription = subscription + s.mu.Unlock() + return + } + s.mu.Unlock() + _ = subscription.Unsubscribe() +} + +func (s *Service) closeWebAppEvents() { + if s == nil { + return + } + s.mu.Lock() + subscription := s.eventSubscription + s.eventSubscription = nil + hub := s.eventHub + s.mu.Unlock() + if subscription != nil { + _ = subscription.Unsubscribe() + } + if hub != nil { + hub.Close() + } +} + +func (s *Service) forwardWebAppEvent(ctx context.Context, source *bus.Event) error { + if source == nil { + return nil + } + projected, ok := projectWebAppEvent(source) + if !ok { + return nil + } + hub := s.WebAppEventHub() + store := s.Instances() + if hub == nil || store == nil { + return nil + } + projected.Scope = s.resolveWebAppEventScope(ctx, projected.Scope) + if projected.Scope.WorkspaceID == "" { + return nil + } + return publishWebAppEventToInstances(ctx, hub, store, projected) +} + +func (s *Service) resolveWebAppEventScope(ctx context.Context, scope webapp.EventScope) webapp.EventScope { + if scope.WorkspaceID != "" || scope.TaskID == "" || s.taskData == nil { + return scope + } + task, err := s.taskData.GetTask(ctx, scope.TaskID) + if err == nil && task != nil { + scope.WorkspaceID = task.WorkspaceID + } + return scope +} + +func publishWebAppEventToInstances(ctx context.Context, hub *webapp.EventHub, store *instances.Store, projected webapp.EventInput) error { + items, err := store.List(ctx, projected.Scope.WorkspaceID, false) + if err != nil { + return err + } + for _, item := range items { + if !webAppBusEventMatchesInstance(item, projected.Scope) { + continue + } + projected.Scope.InstanceID = item.ID + if _, err := hub.Publish(item.ID, projected); err != nil && !errors.Is(err, webapp.ErrEventHubClosed) { + return err + } + } + return nil +} + +// projectWebAppEvent converts an internal bus event into the bounded public +// event DTO. Event payloads often contain persistence records, diagnostics, or +// source metadata that must never be copied into a browser stream. Keeping the +// allowlist here also makes adding a new event an explicit review decision. +func projectWebAppEvent(source *bus.Event) (webapp.EventInput, bool) { + if source == nil || !isProjectableWebAppEvent(source.Type) { + return webapp.EventInput{}, false + } + value, err := marshalEventMap(source.Data) + if err != nil { + return webapp.EventInput{}, false + } + allowed := map[string]struct{}{} + for _, key := range publicEventFields(source.Type) { + allowed[key] = struct{}{} + } + data := make(map[string]any, len(allowed)) + for key := range allowed { + if item, exists := value[key]; exists { + data[key] = item + continue + } + camel := snakeToCamel(key) + if item, exists := value[camel]; exists { + data[key] = item + } + } + scope := eventScopeFromPublicData(data) + return webapp.EventInput{Type: source.Type, Scope: scope, Data: data}, true +} + +func isProjectableWebAppEvent(eventType string) bool { + switch eventType { + case events.CanvasCreated, events.CanvasReleaseActivated, events.CanvasReleasePermissionRequired, + events.CanvasPromoted, events.CanvasArchived, events.CanvasRestored, events.CanvasRemoved, + events.TaskCreated, events.TaskUpdated, events.TaskStateChanged, events.TaskDeleted, events.TaskMoved, + events.TaskQueuePromoted, events.TaskDependenciesResolved, events.TaskDependencyFailed, + events.WorkspaceCreated, events.WorkspaceUpdated, events.WorkspaceDeleted, + events.WorkflowCreated, events.WorkflowUpdated, events.WorkflowDeleted, + events.WorkflowStepCreated, events.WorkflowStepUpdated, events.WorkflowStepDeleted, + events.MessageAdded, events.MessageUpdated, events.MessageDeleted, + events.TaskSessionStateChanged, events.TaskSessionActivityChanged, events.TaskSessionCancellationChanged, + events.TaskSessionErrorChanged, events.TaskStatusSummaryUpdated: + return true + default: + return false + } +} + +func publicEventFields(eventType string) []string { + canvasFields := []string{ + "canvas_id", "workspace_id", "task_id", "scope_kind", "status", "title", "plugin_id", "plugin_instance_id", + "release_id", "active_release_id", "validation_status", "validation_error", "source_actor_kind", "source_user_id", + "source_task_id", "source_session_id", "placement", "protocol_version", + } + commonFields := []string{ + "workspace_id", "task_id", "session_id", "repository_id", "workflow_id", "workflow_step_id", + "state", "status", "title", "name", "message_id", "task_session_id", "created_at", "updated_at", + } + if strings.HasPrefix(eventType, "canvas.") { + return canvasFields + } + return commonFields +} + +func marshalEventMap(data any) (map[string]any, error) { + encoded, err := json.Marshal(data) + if err != nil { + return nil, err + } + var value map[string]any + if err := json.Unmarshal(encoded, &value); err != nil { + return nil, err + } + if value == nil { + value = make(map[string]any) + } + return value, nil +} + +func snakeToCamel(value string) string { + result := strings.Builder{} + upper := false + for _, character := range value { + if character == '_' { + upper = true + continue + } + if upper { + if character >= 'a' && character <= 'z' { + character -= 'a' - 'A' + } + result.WriteRune(character) + upper = false + continue + } + result.WriteRune(character) + } + return result.String() +} + +func eventScopeFromPublicData(data map[string]any) webapp.EventScope { + return webapp.EventScope{ + WorkspaceID: publicEventString(data, "workspace_id"), + TaskID: publicEventString(data, "task_id"), + SessionID: publicEventString(data, "session_id"), + RepositoryID: publicEventString(data, "repository_id"), + } +} + +func publicEventString(data map[string]any, key string) string { + value, ok := data[key].(string) + if !ok { + return "" + } + return strings.TrimSpace(value) +} + +func webAppBusEventMatchesInstance(item instances.Instance, scope webapp.EventScope) bool { + if item.Status == instances.StatusRemoved { + return false + } + if scope.WorkspaceID != "" && item.WorkspaceID != scope.WorkspaceID { + return false + } + switch item.ScopeKind { + case instances.ScopeInstance: + return true + case instances.ScopeWorkspace: + return scope.WorkspaceID == item.WorkspaceID + case instances.ScopeTask: + return scope.TaskID == item.TaskID + case instances.ScopeSession: + return scope.SessionID == item.SessionID + case instances.ScopeRepository: + return scope.RepositoryID == item.RepositoryID + default: + return false + } +} + +func (s *Service) webAppEventFilter(binding webapp.CapabilityBinding) webapp.EventFilter { + return func(event webapp.Event) bool { + if !webAppEventMatchesBinding(event.Scope, binding) { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), webAppEventValidationTimeout) + defer cancel() + if s.validateWebAppBinding(ctx, binding) != nil { + return false + } + return s.webAppEventDeclared(ctx, binding.ReleaseID, event.Type) + } +} + +// webAppEventDeclared keeps the event stream bound to the release manifest. +// Durable grant validation alone is not sufficient: an operator grant must +// never turn an undeclared event name into a subscription. +func (s *Service) webAppEventDeclared(ctx context.Context, releaseID, eventType string) bool { + if strings.TrimSpace(releaseID) == "" || strings.TrimSpace(eventType) == "" { + return false + } + store := s.Instances() + if store == nil { + return false + } + release, err := store.GetRelease(ctx, releaseID) + if err != nil { + return false + } + var m manifest.Manifest + if err := json.Unmarshal(release.ManifestJSON, &m); err != nil { + return false + } + return m.HasEvent(eventType) +} + +func webAppEventMatchesBinding(scope webapp.EventScope, binding webapp.CapabilityBinding) bool { + if scope.InstanceID != "" && scope.InstanceID != binding.InstanceID { + return false + } + switch binding.ScopeKind { + case instances.ScopeInstance: + return true + case instances.ScopeWorkspace: + return scope.WorkspaceID == binding.WorkspaceID + case instances.ScopeTask: + return scope.TaskID == binding.TaskID + case instances.ScopeSession: + return scope.SessionID == binding.SessionID + case instances.ScopeRepository: + return scope.RepositoryID == binding.RepositoryID + default: + return false + } +} diff --git a/apps/backend/internal/plugins/webapp_events_test.go b/apps/backend/internal/plugins/webapp_events_test.go new file mode 100644 index 0000000000..e10c79a9a4 --- /dev/null +++ b/apps/backend/internal/plugins/webapp_events_test.go @@ -0,0 +1,39 @@ +package plugins + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) { + event := bus.NewEvent(events.CanvasReleaseActivated, "canvas", map[string]any{ + "canvas_id": "canvas-1", + "workspace_id": "workspace-1", + "release_id": "release-1", + "active_release_id": "release-1", + "secret": strings.Repeat("s", 128), + "manifest_json": "must-not-leak", + }) + + projected, ok := projectWebAppEvent(event) + if !ok { + t.Fatal("projectWebAppEvent() rejected a public lifecycle event") + } + raw, err := json.Marshal(projected.Data) + if err != nil { + t.Fatalf("marshal projected data: %v", err) + } + encoded := string(raw) + for _, forbidden := range []string{"secret", "manifest_json", "must-not-leak"} { + if strings.Contains(encoded, forbidden) { + t.Fatalf("projected event contains forbidden field %q: %s", forbidden, encoded) + } + } + if projected.Scope.WorkspaceID != "workspace-1" || projected.Scope.InstanceID != "" { + t.Fatalf("projected scope = %+v", projected.Scope) + } +} diff --git a/apps/backend/internal/plugins/webapp_handlers.go b/apps/backend/internal/plugins/webapp_handlers.go new file mode 100644 index 0000000000..c5c53ac461 --- /dev/null +++ b/apps/backend/internal/plugins/webapp_handlers.go @@ -0,0 +1,25 @@ +package plugins + +import ( + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/plugins/webapp" +) + +// RegisterWebAppRuntimeRoutes mounts only the capability-bound static +// runtime. Callers must gate this registration with features.canvases. +func RegisterWebAppRuntimeRoutes(router *gin.Engine, runtime *webapp.Runtime) { + if router == nil || runtime == nil { + return + } + handler := func(c *gin.Context) { + runtime.Serve(c.Writer, c.Request, c.Param("token"), c.Param("path")) + } + router.GET("/api/v1/plugins/web-apps/runtime/:token/*path", handler) + router.HEAD("/api/v1/plugins/web-apps/runtime/:token/*path", handler) + router.OPTIONS("/api/v1/plugins/web-apps/runtime/:token/*path", handler) + router.POST("/api/v1/plugins/web-apps/runtime/:token/*path", handler) + router.PATCH("/api/v1/plugins/web-apps/runtime/:token/*path", handler) + router.PUT("/api/v1/plugins/web-apps/runtime/:token/*path", handler) + router.DELETE("/api/v1/plugins/web-apps/runtime/:token/*path", handler) +} diff --git a/apps/backend/internal/plugins/webapp_protocol.go b/apps/backend/internal/plugins/webapp_protocol.go new file mode 100644 index 0000000000..4602df9b50 --- /dev/null +++ b/apps/backend/internal/plugins/webapp_protocol.go @@ -0,0 +1,516 @@ +package plugins + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/state" + "github.com/kandev/kandev/internal/plugins/webapp" + userstore "github.com/kandev/kandev/internal/user/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +const ( + webAppProtocolVersion = 1 + webAppRequestLimit = 256 << 10 + webAppResponseLimit = 1 << 20 + webAppRequestTimeout = 30 * time.Second + webAppRuntimeUnavailable = "runtime_unavailable" +) + +// handleWebAppProtocol is called only after Runtime has authenticated and +// validated the capability URL. The path is relative to the _kandev root. +func (s *Service) handleWebAppProtocol(w http.ResponseWriter, r *http.Request, _ string, binding webapp.CapabilityBinding, path string) { + webapp.SetProtocolHeaders(w, r.Header.Get("Origin")) + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS, PATCH, POST, PUT, DELETE") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, If-Match, Last-Event-ID") + w.Header().Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusNoContent) + return + } + + parts, ok := splitWebAppProtocolPath(path) + if !ok || len(parts) < 2 || parts[0] != "v1" { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + + ctx, cancel := context.WithTimeout(webAppRequestContext(r.Context(), binding), webAppRequestTimeout) + defer cancel() + switch parts[1] { + case "context": + s.handleWebAppContext(w, r, binding, parts) + case "data": + s.handleWebAppData(ctx, w, r, s.webAppHost(binding), binding, parts) + case "state": + s.handleWebAppState(ctx, w, r, binding, parts) + case "events": + s.handleWebAppEvents(w, r, binding, parts) + case "actions": + s.handleWebAppAction(w, r, binding, parts) + default: + writeWebAppError(w, http.StatusNotFound, "not_found") + } +} + +func (s *Service) handleWebAppEvents(w http.ResponseWriter, r *http.Request, binding webapp.CapabilityBinding, parts []string) { + if r.Method != http.MethodGet || len(parts) != 2 { + writeWebAppError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + hub := s.WebAppEventHub() + if hub == nil { + writeWebAppError(w, http.StatusServiceUnavailable, webAppRuntimeUnavailable) + return + } + hub.Serve(w, r, webapp.EventSubscriptionRequest{ + InstanceID: binding.InstanceID, + UserID: binding.UserID, + Filter: s.webAppEventFilter(binding), + }) +} + +func splitWebAppProtocolPath(path string) ([]string, bool) { + trimmed := strings.Trim(path, "/") + if trimmed == "" { + return nil, false + } + parts := strings.Split(trimmed, "/") + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return nil, false + } + } + return parts, true +} + +func webAppRequestContext(ctx context.Context, binding webapp.CapabilityBinding) context.Context { + return authn.WithIdentity(ctx, authn.Identity{ + UserID: binding.UserID, + Role: authn.RoleMember, + Synthetic: binding.UserID == userstore.DefaultUserID, + }) +} + +func (s *Service) webAppHost(binding webapp.CapabilityBinding) *pluginHost { + var host *pluginHost + if s.registry != nil { + host, _ = s.hostForPlugin(binding.PluginID).(*pluginHost) + } + if host == nil { + host = &pluginHost{ + pluginID: binding.PluginID, + taskData: s.taskData, + workflows: s.workflows, + workflowSteps: s.workflowSteps, + messageData: s.messageData, + taskWriter: s.taskWriter, + taskPRsDep: s.taskPRSourceDep, + writeDeps: s.writeDependencies, + } + } + host.pluginID = binding.PluginID + host.capabilities = webAppCapabilities(binding.Permissions) + return host +} + +func webAppCapabilities(permissions []string) manifest.Capabilities { + capabilities := manifest.Capabilities{} + seenRead := make(map[string]struct{}) + seenWrite := make(map[string]struct{}) + for _, permission := range permissions { + permission = strings.TrimSpace(permission) + switch { + case permission == "state": + capabilities.State = true + case strings.HasPrefix(permission, "api_read:"): + resource := strings.TrimPrefix(permission, "api_read:") + if resource != "" { + if _, ok := seenRead[resource]; !ok { + capabilities.APIRead = append(capabilities.APIRead, resource) + seenRead[resource] = struct{}{} + } + } + case strings.HasPrefix(permission, "api_write:"): + resource := strings.TrimPrefix(permission, "api_write:") + if resource != "" { + if _, ok := seenWrite[resource]; !ok { + capabilities.APIWrite = append(capabilities.APIWrite, resource) + seenWrite[resource] = struct{}{} + } + } + } + } + return capabilities +} + +type webAppContext struct { + ProtocolVersion int `json:"protocol_version"` + InstanceID string `json:"instance_id"` + PluginID string `json:"plugin_id"` + ReleaseID string `json:"release_id"` + WebAppKey string `json:"web_app_key"` + Placement string `json:"placement"` + ScopeKind string `json:"scope_kind"` + WorkspaceID string `json:"workspace_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` + Capabilities []string `json:"capabilities"` +} + +func (s *Service) handleWebAppContext(w http.ResponseWriter, r *http.Request, binding webapp.CapabilityBinding, parts []string) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeWebAppError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + if len(parts) != 2 { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + writeWebAppJSON(w, r, http.StatusOK, webAppContext{ + ProtocolVersion: webAppProtocolVersion, + InstanceID: binding.InstanceID, + PluginID: binding.PluginID, + ReleaseID: binding.ReleaseID, + WebAppKey: binding.WebAppKey, + Placement: binding.Placement, + ScopeKind: binding.ScopeKind, + WorkspaceID: binding.WorkspaceID, + TaskID: binding.TaskID, + SessionID: binding.SessionID, + RepositoryID: binding.RepositoryID, + Capabilities: append([]string(nil), binding.Permissions...), + }) +} + +func (s *Service) handleWebAppData(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, parts []string) { + if len(parts) < 3 { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + switch parts[2] { + case "tasks": + s.handleWebAppTasks(ctx, w, r, host, binding, parts[3:]) + case "workflows": + s.handleWebAppWorkflows(ctx, w, r, host, binding, parts[3:]) + default: + writeWebAppError(w, http.StatusNotFound, "not_found") + } +} + +func (s *Service) handleWebAppTasks(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, parts []string) { + switch { + case len(parts) == 0 && r.Method == http.MethodGet: + s.listWebAppTasks(ctx, w, r, host, binding) + case len(parts) == 1 && r.Method == http.MethodGet: + s.getWebAppTask(ctx, w, r, host, binding, parts[0]) + case len(parts) == 1 && r.Method == http.MethodPatch: + s.updateWebAppTask(ctx, w, r, host, binding, parts[0]) + case len(parts) == 2 && parts[1] == "messages" && r.Method == http.MethodPost: + s.sendWebAppMessage(ctx, w, r, host, binding, parts[0]) + default: + writeWebAppError(w, http.StatusMethodNotAllowed, "method_not_allowed") + } +} + +func (s *Service) handleWebAppWorkflows(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, parts []string) { + switch { + case len(parts) == 0 && r.Method == http.MethodGet: + s.listWebAppWorkflows(ctx, w, r, host, binding) + case len(parts) == 2 && parts[1] == "steps" && r.Method == http.MethodGet: + s.listWebAppWorkflowSteps(ctx, w, r, host, binding, parts[0]) + default: + writeWebAppError(w, http.StatusMethodNotAllowed, "method_not_allowed") + } +} + +func (s *Service) handleWebAppAction(w http.ResponseWriter, r *http.Request, binding webapp.CapabilityBinding, parts []string) { + if r.Method != http.MethodPost || len(parts) != 2 || !validWebAppKey(parts[1]) { + writeWebAppError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + if !webAppHasActionPermission(binding.Permissions, parts[1]) { + writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied") + return + } + if _, err := readWebAppBody(r); err != nil { + writeWebAppError(w, http.StatusBadRequest, "invalid_request") + return + } + writeWebAppError(w, http.StatusNotImplemented, "plugin_action_unavailable") +} + +func webAppHasActionPermission(permissions []string, key string) bool { + for _, permission := range permissions { + if permission == "action:"+key || permission == "actions:"+key { + return true + } + } + return false +} + +func validWebAppKey(value string) bool { + if value == "" || len(value) > 128 { + return false + } + for _, r := range value { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' && r != '.' { + return false + } + } + return true +} + +func webAppRequestPage(r *http.Request) (pluginsdk.Page, error) { + query := r.URL.Query() + page := pluginsdk.Page{Cursor: query.Get("cursor")} + if raw := query.Get("limit"); raw != "" { + limit, err := strconv.Atoi(raw) + if err != nil || limit < 1 || limit > maxPageLimit { + return pluginsdk.Page{}, errors.New("invalid limit") + } + page.Limit = int32(limit) + } + return page, nil +} + +func webAppQueryList(queryValues []string) []string { + var values []string + for _, value := range queryValues { + for _, item := range strings.Split(value, ",") { + if item = strings.TrimSpace(item); item != "" { + values = append(values, item) + } + } + } + return values +} + +func webAppBoolQuery(r *http.Request, name string) (bool, error) { + value := r.URL.Query().Get(name) + if value == "" { + return false, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, errors.New("invalid boolean") + } + return parsed, nil +} + +func webAppParentQuery(r *http.Request) *string { + value := strings.TrimSpace(r.URL.Query().Get("parent_id")) + if value == "" { + return nil + } + return &value +} + +func webAppProtocolStatus(err error) int { + if errors.Is(err, webapp.ErrRuntimeTokenStale) { + return http.StatusUnauthorized + } + if errors.Is(err, instances.ErrNotFound) || status.Code(err) == codes.NotFound { + return http.StatusNotFound + } + if errors.Is(err, state.ErrConflict) { + return http.StatusConflict + } + switch status.Code(err) { + case codes.PermissionDenied: + return http.StatusForbidden + case codes.InvalidArgument: + return http.StatusBadRequest + case codes.FailedPrecondition: + return http.StatusConflict + case codes.Unimplemented: + return http.StatusNotImplemented + default: + if errors.Is(err, context.DeadlineExceeded) { + return http.StatusGatewayTimeout + } + return http.StatusInternalServerError + } +} + +// validateWebAppBinding rechecks every durable authority component on each +// asset and protocol request. The token therefore cannot survive release, +// scope, grant, or instance removal changes. +func (s *Service) validateWebAppBinding(ctx context.Context, binding webapp.CapabilityBinding) error { + store := s.Instances() + if store == nil { + return webapp.ErrRuntimeTokenStale + } + instance, err := store.Get(ctx, binding.InstanceID) + if err != nil || instance.Status != instances.StatusActive || instance.PluginID != binding.PluginID || + instance.ActiveReleaseID != binding.ReleaseID || instance.GrantGeneration != binding.GrantGeneration { + return webapp.ErrRuntimeTokenStale + } + release, err := store.GetRelease(ctx, binding.ReleaseID) + if err != nil || release.InstanceID != binding.InstanceID || release.PluginID != binding.PluginID || + release.ValidationStatus != instances.ValidationValid || release.PackageDigest != binding.Artifact.Digest || + release.ArtifactPath != binding.Artifact.RelativePath { + return webapp.ErrRuntimeTokenStale + } + if err := validateWebAppManifestBinding(release.ManifestJSON, binding); err != nil { + return webapp.ErrRuntimeTokenStale + } + if err := s.validateWebAppPermissions(ctx, binding, release.ManifestJSON); err != nil { + return webapp.ErrRuntimeTokenStale + } + return s.validateWebAppScope(webAppRequestContext(ctx, binding), binding) +} + +func (s *Service) validateWebAppPermissions(ctx context.Context, binding webapp.CapabilityBinding, raw json.RawMessage) error { + var m manifest.Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return err + } + store := s.Instances() + if store == nil { + return errors.New("web app instance store is unavailable") + } + grants, err := store.ListGrants(ctx, binding.InstanceID) + if err != nil { + return err + } + for _, permission := range webAppDeclaredPermissions(m, binding.WebAppKey) { + if !webAppGrantCovers(permission, binding.ScopeKind, grants) { + return errors.New("web app permission grant is stale") + } + } + for _, origin := range binding.NetworkOrigins { + if !webAppOriginGranted(origin, binding.ScopeKind, grants) { + return errors.New("web app network grant is stale") + } + } + return nil +} + +func webAppDeclaredPermissions(m manifest.Manifest, webAppKey string) []string { + permissions := make([]string, 0, len(m.Capabilities.APIRead)+len(m.Capabilities.APIWrite)+len(m.Capabilities.Events)+1) + for _, resource := range m.Capabilities.APIRead { + permissions = append(permissions, "api_read:"+resource) + } + for _, resource := range m.Capabilities.APIWrite { + permissions = append(permissions, "api_write:"+resource) + } + for _, subject := range m.Capabilities.Events { + permissions = append(permissions, "events:"+subject) + } + if m.Capabilities.State { + permissions = append(permissions, "state") + } + for _, app := range m.UI.WebApps { + if app.Key != webAppKey { + continue + } + origins, err := webapp.NormalizeNetworkOrigins(app.NetworkOrigins) + if err != nil { + break + } + for _, origin := range origins { + permissions = append(permissions, "network:"+origin) + } + break + } + return permissions +} + +func webAppGrantCovers(permission, scope string, grants []instances.Grant) bool { + parts := strings.SplitN(permission, ":", 2) + kind, resource := parts[0], "" + if len(parts) == 2 { + resource = parts[1] + } + for _, grant := range grants { + if kind == "network" { + if grant.PermissionKind == kind && grant.NetworkOrigin == resource && webAppGrantScopeCovers(grant.ScopeCeiling, scope) { + return true + } + continue + } + if grant.PermissionKind == kind && grant.Resource == resource && webAppGrantScopeCovers(grant.ScopeCeiling, scope) { + return true + } + } + return false +} + +func webAppOriginGranted(origin, scope string, grants []instances.Grant) bool { + for _, grant := range grants { + if grant.NetworkOrigin == origin && webAppGrantScopeCovers(grant.ScopeCeiling, scope) { + return true + } + } + return false +} + +func webAppGrantScopeCovers(ceiling, scope string) bool { + return ceiling == instances.ScopeInstance || ceiling == scope +} + +func validateWebAppManifestBinding(raw json.RawMessage, binding webapp.CapabilityBinding) error { + var m manifest.Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return err + } + for _, app := range m.UI.WebApps { + if app.Key == binding.WebAppKey && app.Entry == binding.Entry && webAppContainsString(app.Placements, binding.Placement) { + return nil + } + } + return errors.New("web app binding is not declared by the release") +} + +func (s *Service) validateWebAppScope(ctx context.Context, binding webapp.CapabilityBinding) error { + if binding.ScopeKind == instances.ScopeInstance || s.taskData == nil { + return nil + } + if binding.WorkspaceID == "" { + return webapp.ErrRuntimeTokenStale + } + workspaces, err := s.taskData.ListWorkspaces(ctx) + if err != nil { + return webapp.ErrRuntimeTokenStale + } + visible := false + for _, workspace := range workspaces { + if workspace != nil && workspace.ID == binding.WorkspaceID { + visible = true + break + } + } + if !visible { + return webapp.ErrRuntimeTokenStale + } + if binding.ScopeKind == instances.ScopeTask && binding.TaskID != "" { + task, err := s.taskData.GetTask(ctx, binding.TaskID) + if err != nil || task == nil || task.WorkspaceID != binding.WorkspaceID { + return webapp.ErrRuntimeTokenStale + } + } + return nil +} + +func webAppContainsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/apps/backend/internal/plugins/webapp_protocol_data.go b/apps/backend/internal/plugins/webapp_protocol_data.go new file mode 100644 index 0000000000..6ec6831427 --- /dev/null +++ b/apps/backend/internal/plugins/webapp_protocol_data.go @@ -0,0 +1,409 @@ +package plugins + +import ( + "context" + "errors" + "net/http" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "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/pkg/pluginsdk" +) + +func (s *Service) listWebAppTasks(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding) { + page, err := webAppRequestPage(r) + if err != nil { + writeWebAppError(w, http.StatusBadRequest, "invalid_request") + return + } + includeArchived, err := webAppBoolQuery(r, "include_archived") + if err != nil { + writeWebAppError(w, http.StatusBadRequest, "invalid_request") + return + } + workspaceIDs, err := s.webAppTaskWorkspaceIDs(r, binding) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + filter := pluginsdk.TaskFilter{ + WorkspaceIDs: workspaceIDs, + WorkflowIDs: webAppQueryList(r.URL.Query()["workflow_id"]), + States: webAppQueryList(r.URL.Query()["state"]), + ParentID: webAppParentQuery(r), + IncludeArchived: includeArchived, + IncludeEphemeral: true, + } + if binding.ScopeKind == instances.ScopeTask { + task, err := host.Tasks().Get(ctx, binding.TaskID) + if err != nil || task == nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + if !webAppTaskMatches(ctx, host, binding, *task) || !webAppTaskMatchesFilter(*task, filter) || (!includeArchived && task.ArchivedAt != nil) { + writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppTask]{Items: []webAppTask{}, PageInfo: webAppPageInfo{}}) + return + } + items, info := paginate([]webAppTask{webAppTaskFromSDK(*task)}, page) + writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(items, info)) + return + } + + items, info, err := host.Tasks().List(ctx, filter, page) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + filtered := make([]webAppTask, 0, len(items)) + for _, item := range items { + if webAppTaskMatches(ctx, host, binding, item) { + filtered = append(filtered, webAppTaskFromSDK(item)) + } + } + writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(filtered, info)) +} + +func (s *Service) getWebAppTask(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, taskID string) { + if !validWebAppKey(taskID) { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + task, err := host.Tasks().Get(ctx, taskID) + if err != nil || task == nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + if !webAppTaskMatches(ctx, host, binding, *task) { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*task)) +} + +type webAppTaskPatch struct { + Title *string `json:"title"` + Description *string `json:"description"` + State *string `json:"state"` + WorkflowStepID *string `json:"workflow_step_id"` +} + +func (p webAppTaskPatch) hasChanges() bool { + return p.Title != nil || p.Description != nil || p.State != nil || p.WorkflowStepID != nil +} + +func (p webAppTaskPatch) hasTaskFields() bool { + return p.Title != nil || p.Description != nil || p.State != nil +} + +func (s *Service) updateWebAppTask(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, taskID string) { + if !validWebAppKey(taskID) { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + task, err := host.Tasks().Get(ctx, taskID) + if err != nil || task == nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + if !webAppTaskMatches(ctx, host, binding, *task) { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + var patch webAppTaskPatch + if err := decodeWebAppJSON(r, &patch); err != nil || !patch.hasChanges() { + writeWebAppError(w, http.StatusBadRequest, "invalid_request") + return + } + var updated *pluginsdk.Task + if patch.hasTaskFields() { + updated, err = host.Tasks().Update(ctx, pluginsdk.UpdateTaskInput{ + ID: taskID, Title: patch.Title, Description: patch.Description, State: patch.State, + }) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + } + if patch.WorkflowStepID != nil { + // Keep the browser PATCH contract, but route transitions through Move so + // workflow hooks, active-session guards, and step history stay intact. + outcome, moveErr := host.Tasks().Move(ctx, pluginsdk.MoveTaskInput{ + TaskID: taskID, + WorkflowStepID: *patch.WorkflowStepID, + }) + if moveErr != nil { + writeWebAppError(w, webAppProtocolStatus(moveErr), webAppErrorCode(moveErr)) + return + } + if outcome == nil || outcome.Task == nil { + writeWebAppError(w, http.StatusInternalServerError, webAppRuntimeUnavailable) + return + } + updated = outcome.Task + } + if updated == nil { + writeWebAppError(w, http.StatusBadRequest, "invalid_request") + return + } + writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*updated)) +} + +type webAppMessageRequest struct { + Text string `json:"text"` + SessionID string `json:"session_id,omitempty"` +} + +func (s *Service) sendWebAppMessage(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, taskID string) { + if !validWebAppKey(taskID) { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + task, err := host.Tasks().Get(ctx, taskID) + if err != nil || task == nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + if !webAppTaskMatches(ctx, host, binding, *task) { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + var request webAppMessageRequest + if err := decodeWebAppJSON(r, &request); err != nil || strings.TrimSpace(request.Text) == "" { + writeWebAppError(w, http.StatusBadRequest, "invalid_request") + return + } + dispatch, err := host.Messages().Send(ctx, taskID, request.SessionID, request.Text) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + writeWebAppJSON(w, r, http.StatusAccepted, dispatch) +} + +func (s *Service) listWebAppWorkflows(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding) { + page, err := webAppRequestPage(r) + if err != nil { + writeWebAppError(w, http.StatusBadRequest, "invalid_request") + return + } + workspaceIDs, err := s.webAppWorkflowWorkspaceIDs(ctx, r, host, binding) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + var workflows []pluginsdk.Workflow + for _, workspaceID := range workspaceIDs { + items, _, err := host.Workflows().List(ctx, workspaceID, pluginsdk.Page{Limit: maxPageLimit}) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + workflows = append(workflows, items...) + } + if binding.ScopeKind == instances.ScopeTask { + task, err := host.Tasks().Get(ctx, binding.TaskID) + if err != nil || task == nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + workflows = filterWebAppWorkflows(workflows, task.WorkflowID) + } + items, info := paginate(workflows, page) + result := make([]webAppWorkflow, len(items)) + for i, workflow := range items { + result[i] = webAppWorkflowFromSDK(workflow) + } + writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(result, info)) +} + +func (s *Service) listWebAppWorkflowSteps(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, workflowID string) { + if !validWebAppKey(workflowID) { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + workspaceIDs, err := s.webAppWorkflowWorkspaceIDs(ctx, r, host, binding) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + allowed := false + for _, workspaceID := range workspaceIDs { + workflows, _, err := host.Workflows().List(ctx, workspaceID, pluginsdk.Page{Limit: maxPageLimit}) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + for _, workflow := range workflows { + if workflow.ID == workflowID { + allowed = true + break + } + } + } + if binding.ScopeKind == instances.ScopeTask { + task, err := host.Tasks().Get(ctx, binding.TaskID) + if err != nil || task == nil || task.WorkflowID != workflowID { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + } + if !allowed { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + steps, err := host.Workflows().ListSteps(ctx, workflowID) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + result := make([]webAppWorkflowStep, len(steps)) + for i, step := range steps { + result[i] = webAppWorkflowStepFromSDK(step) + } + writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppWorkflowStep]{Items: result, PageInfo: webAppPageInfo{}}) +} + +func (s *Service) webAppTaskWorkspaceIDs(r *http.Request, binding webapp.CapabilityBinding) ([]string, error) { + requested := strings.TrimSpace(r.URL.Query().Get("workspace_id")) + if binding.WorkspaceID != "" { + if requested != "" && requested != binding.WorkspaceID { + return nil, status.Error(codes.PermissionDenied, "workspace is outside the canvas scope") + } + return []string{binding.WorkspaceID}, nil + } + if requested != "" { + return []string{requested}, nil + } + // Task reads can use the Host task reader's global workspace resolution. + // This avoids requiring a separate workspaces grant for an instance canvas. + return nil, nil +} + +func (s *Service) webAppWorkflowWorkspaceIDs(ctx context.Context, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding) ([]string, error) { + requested := strings.TrimSpace(r.URL.Query().Get("workspace_id")) + if binding.WorkspaceID != "" { + if requested != "" && requested != binding.WorkspaceID { + return nil, status.Error(codes.PermissionDenied, "workspace is outside the canvas scope") + } + return []string{binding.WorkspaceID}, nil + } + if requested != "" { + return []string{requested}, nil + } + workspaces, _, err := host.Workspaces().List(ctx, pluginsdk.Page{Limit: maxPageLimit}) + if err != nil { + return nil, err + } + ids := make([]string, len(workspaces)) + for i, workspace := range workspaces { + ids[i] = workspace.ID + } + return ids, nil +} + +func webAppTaskMatches(ctx context.Context, host *pluginHost, binding webapp.CapabilityBinding, task pluginsdk.Task) bool { + if binding.ScopeKind == instances.ScopeInstance { + return true + } + if task.WorkspaceID != binding.WorkspaceID { + return false + } + switch binding.ScopeKind { + case instances.ScopeWorkspace: + return true + case instances.ScopeTask: + return task.ID == binding.TaskID + case instances.ScopeRepository: + return webAppTaskHasRepository(task, binding.RepositoryID) + case instances.ScopeSession: + return webAppTaskHasSession(ctx, host, task, binding.SessionID) + default: + return false + } +} + +func webAppTaskHasRepository(task pluginsdk.Task, repositoryID string) bool { + for _, repository := range task.Repositories { + if repository.RepositoryID == repositoryID || repository.ID == repositoryID { + return true + } + } + return false +} + +func webAppTaskHasSession(ctx context.Context, host *pluginHost, task pluginsdk.Task, sessionID string) bool { + if host.taskData == nil { + return false + } + sessions, err := host.taskData.ListTaskSessions(ctx, task.ID) + if err != nil { + return false + } + for _, session := range sessions { + if session != nil && session.ID == sessionID { + return true + } + } + return false +} + +func webAppTaskMatchesFilter(task pluginsdk.Task, filter pluginsdk.TaskFilter) bool { + if len(filter.WorkflowIDs) > 0 && !stringInSlice(filter.WorkflowIDs, task.WorkflowID) { + return false + } + if len(filter.States) > 0 && !stringInSlice(filter.States, task.State) { + return false + } + return filter.ParentID == nil || (task.ParentID != nil && *task.ParentID == *filter.ParentID) +} + +func stringInSlice(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func filterWebAppWorkflows(workflows []pluginsdk.Workflow, workflowID string) []pluginsdk.Workflow { + filtered := make([]pluginsdk.Workflow, 0, 1) + for _, workflow := range workflows { + if workflow.ID == workflowID { + filtered = append(filtered, workflow) + } + } + return filtered +} + +func webAppErrorCode(err error) string { + if err == nil { + return webAppRuntimeUnavailable + } + if errors.Is(err, webapp.ErrRuntimeTokenStale) { + return "runtime_token_stale" + } + if errors.Is(err, state.ErrConflict) { + return "plugin_state_conflict" + } + if errors.Is(err, instances.ErrNotFound) || status.Code(err) == codes.NotFound { + return "not_found" + } + switch status.Code(err) { + case codes.PermissionDenied: + return "plugin_permission_denied" + case codes.InvalidArgument: + return "invalid_request" + case codes.FailedPrecondition: + return "plugin_state_conflict" + case codes.Unimplemented: + return webAppRuntimeUnavailable + default: + return webAppRuntimeUnavailable + } +} diff --git a/apps/backend/internal/plugins/webapp_protocol_json.go b/apps/backend/internal/plugins/webapp_protocol_json.go new file mode 100644 index 0000000000..640a4270e9 --- /dev/null +++ b/apps/backend/internal/plugins/webapp_protocol_json.go @@ -0,0 +1,218 @@ +package plugins + +import ( + "encoding/json" + "net/http" + + "github.com/kandev/kandev/pkg/pluginsdk" +) + +type webAppPageInfo struct { + NextCursor string `json:"next_cursor,omitempty"` + HasMore bool `json:"has_more"` +} + +type webAppPage[T any] struct { + Items []T `json:"items"` + PageInfo webAppPageInfo `json:"page_info"` +} + +func webAppPageFromSDK[T any](items []T, info *pluginsdk.PageInfo) webAppPage[T] { + page := webAppPage[T]{Items: items} + if info != nil { + page.PageInfo = webAppPageInfo{NextCursor: info.NextCursor, HasMore: info.HasMore} + } + return page +} + +func writeWebAppJSON(w http.ResponseWriter, r *http.Request, status int, value any) { + body, err := json.Marshal(value) + if err != nil { + writeWebAppError(w, http.StatusInternalServerError, webAppRuntimeUnavailable) + return + } + if len(body) > webAppResponseLimit { + writeWebAppError(w, http.StatusInternalServerError, "response_too_large") + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if r == nil || r.Method != http.MethodHead { + _, _ = w.Write(body) + } +} + +func writeWebAppError(w http.ResponseWriter, status int, code string) { + body, err := json.Marshal(struct { + Error string `json:"error"` + }{Error: code}) + if err != nil { + body = []byte(`{"error":"runtime_unavailable"}`) + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write(body) +} + +type webAppTaskRepository struct { + ID string `json:"id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + Position int32 `json:"position"` + CheckoutBranch string `json:"checkout_branch"` +} + +type webAppPullRequest struct { + Number int64 `json:"number"` + URL string `json:"url"` + Title string `json:"title"` + State string `json:"state"` + HeadBranch string `json:"head_branch"` + BaseBranch string `json:"base_branch"` + IsDraft bool `json:"is_draft"` + Provider string `json:"provider"` + MergedAt *string `json:"merged_at,omitempty"` + ClosedAt *string `json:"closed_at,omitempty"` + ReviewState string `json:"review_state"` + ChecksState string `json:"checks_state"` + MergeableState string `json:"mergeable_state"` + UnresolvedReviewThreads int32 `json:"unresolved_review_threads"` + ChecksTotal int32 `json:"checks_total"` + ChecksPassing int32 `json:"checks_passing"` + Additions int32 `json:"additions"` + Deletions int32 `json:"deletions"` + AuthorLogin string `json:"author_login"` +} + +type webAppTask struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + Priority string `json:"priority"` + CreatedBy string `json:"created_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + StartedAt *string `json:"started_at,omitempty"` + CompletedAt *string `json:"completed_at,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + Identifier string `json:"identifier"` + IsEphemeral bool `json:"is_ephemeral"` + Repositories []webAppTaskRepository `json:"repositories,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + ArchivedAt *string `json:"archived_at,omitempty"` + PullRequests []webAppPullRequest `json:"pull_requests,omitempty"` + WorkflowStepID string `json:"workflow_step_id"` + Position int32 `json:"position"` + AssigneeAgentProfileID string `json:"assignee_agent_profile_id"` + Labels []string `json:"labels,omitempty"` + Autopilot bool `json:"autopilot"` + WIPAdmitted bool `json:"wip_admitted"` + QueuedForStepID string `json:"queued_for_step_id,omitempty"` + QueuedAt *string `json:"queued_at,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ExternalID string `json:"external_id,omitempty"` +} + +func webAppTaskFromSDK(task pluginsdk.Task) webAppTask { + result := webAppTask{ + ID: task.ID, + WorkspaceID: task.WorkspaceID, + WorkflowID: task.WorkflowID, + Title: task.Title, + Description: task.Description, + State: task.State, + Priority: task.Priority, + CreatedBy: task.CreatedBy, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + StartedAt: task.StartedAt, + CompletedAt: task.CompletedAt, + ParentID: task.ParentID, + Identifier: task.Identifier, + IsEphemeral: task.IsEphemeral, + Metadata: task.Metadata, + ArchivedAt: task.ArchivedAt, + WorkflowStepID: task.WorkflowStepID, + Position: task.Position, + AssigneeAgentProfileID: task.AssigneeAgentProfileID, + Labels: task.Labels, + Autopilot: task.Autopilot, + WIPAdmitted: task.WIPAdmitted, + QueuedForStepID: task.QueuedForStepID, + QueuedAt: task.QueuedAt, + ProjectID: task.ProjectID, + ExternalID: task.ExternalID, + } + if len(task.Repositories) > 0 { + result.Repositories = make([]webAppTaskRepository, len(task.Repositories)) + for i, repository := range task.Repositories { + result.Repositories[i] = webAppTaskRepository{ + ID: repository.ID, RepositoryID: repository.RepositoryID, + BaseBranch: repository.BaseBranch, Position: repository.Position, + CheckoutBranch: repository.CheckoutBranch, + } + } + } + if len(task.PullRequests) > 0 { + result.PullRequests = make([]webAppPullRequest, len(task.PullRequests)) + for i, pullRequest := range task.PullRequests { + result.PullRequests[i] = webAppPullRequest{ + Number: pullRequest.Number, URL: pullRequest.URL, Title: pullRequest.Title, + State: pullRequest.State, HeadBranch: pullRequest.HeadBranch, + BaseBranch: pullRequest.BaseBranch, IsDraft: pullRequest.IsDraft, + Provider: pullRequest.Provider, MergedAt: pullRequest.MergedAt, + ClosedAt: pullRequest.ClosedAt, ReviewState: pullRequest.ReviewState, + ChecksState: pullRequest.ChecksState, MergeableState: pullRequest.MergeableState, + UnresolvedReviewThreads: pullRequest.UnresolvedReviewThreads, + ChecksTotal: pullRequest.ChecksTotal, ChecksPassing: pullRequest.ChecksPassing, + Additions: pullRequest.Additions, Deletions: pullRequest.Deletions, + AuthorLogin: pullRequest.AuthorLogin, + } + } + } + return result +} + +type webAppWorkflow struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + SortOrder int32 `json:"sort_order"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func webAppWorkflowFromSDK(workflow pluginsdk.Workflow) webAppWorkflow { + return webAppWorkflow{ + ID: workflow.ID, WorkspaceID: workflow.WorkspaceID, Name: workflow.Name, + Description: workflow.Description, SortOrder: workflow.SortOrder, + CreatedAt: workflow.CreatedAt, UpdatedAt: workflow.UpdatedAt, + } +} + +type webAppWorkflowStep struct { + ID string `json:"id"` + WorkflowID string `json:"workflow_id"` + Name string `json:"name"` + Position int32 `json:"position"` + StageType string `json:"stage_type"` + Color string `json:"color"` + IsStartStep bool `json:"is_start_step"` + WIPLimit int32 `json:"wip_limit"` + AgentProfileID string `json:"agent_profile_id"` + OnEnterActionTypes []string `json:"on_enter_action_types,omitempty"` +} + +func webAppWorkflowStepFromSDK(step pluginsdk.WorkflowStep) webAppWorkflowStep { + return webAppWorkflowStep{ + ID: step.ID, WorkflowID: step.WorkflowID, Name: step.Name, + Position: step.Position, StageType: step.StageType, Color: step.Color, + IsStartStep: step.IsStartStep, WIPLimit: step.WIPLimit, + AgentProfileID: step.AgentProfileID, OnEnterActionTypes: step.OnEnterActionTypes, + } +} diff --git a/apps/backend/internal/plugins/webapp_protocol_state.go b/apps/backend/internal/plugins/webapp_protocol_state.go new file mode 100644 index 0000000000..513b6795d9 --- /dev/null +++ b/apps/backend/internal/plugins/webapp_protocol_state.go @@ -0,0 +1,193 @@ +package plugins + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/kandev/kandev/internal/plugins/state" + "github.com/kandev/kandev/internal/plugins/webapp" +) + +type webAppStateEntry struct { + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Revision int64 `json:"revision"` + WriterKind string `json:"writer_kind"` + UpdatedAt string `json:"updated_at"` +} + +func (s *Service) handleWebAppState(ctx context.Context, w http.ResponseWriter, r *http.Request, binding webapp.CapabilityBinding, parts []string) { + if !webAppCapabilities(binding.Permissions).State { + writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied") + return + } + store := s.InstanceState() + if store == nil { + writeWebAppError(w, http.StatusNotImplemented, webAppRuntimeUnavailable) + return + } + switch { + case len(parts) == 2 && r.Method == http.MethodGet: + s.listWebAppState(ctx, w, r, store, binding.InstanceID) + case len(parts) == 3 && validWebAppStateKey(parts[2]) && r.Method == http.MethodGet: + s.getWebAppState(ctx, w, r, store, binding.InstanceID, parts[2]) + case len(parts) == 3 && validWebAppStateKey(parts[2]) && r.Method == http.MethodPut: + s.setWebAppState(ctx, w, r, store, binding.InstanceID, parts[2]) + case len(parts) == 3 && validWebAppStateKey(parts[2]) && r.Method == http.MethodDelete: + s.deleteWebAppState(ctx, w, r, store, binding.InstanceID, parts[2]) + default: + writeWebAppError(w, http.StatusMethodNotAllowed, "method_not_allowed") + } +} + +func validWebAppStateKey(key string) bool { + return validWebAppKey(key) && !strings.HasPrefix(key, "_") +} + +func (s *Service) listWebAppState(ctx context.Context, w http.ResponseWriter, r *http.Request, store *state.InstanceStore, instanceID string) { + entries, err := store.List(ctx, instanceID) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + items := make([]webAppStateEntry, len(entries)) + for i, entry := range entries { + items[i] = webAppStateEntryFromStore(entry) + } + writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppStateEntry]{Items: items, PageInfo: webAppPageInfo{}}) +} + +func (s *Service) getWebAppState(ctx context.Context, w http.ResponseWriter, r *http.Request, store *state.InstanceStore, instanceID, key string) { + entry, found, err := store.Get(ctx, instanceID, key) + if err != nil { + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) + return + } + if !found { + writeWebAppError(w, http.StatusNotFound, "not_found") + return + } + writeWebAppJSON(w, r, http.StatusOK, webAppStateEntryFromStore(entry)) +} + +func (s *Service) setWebAppState(ctx context.Context, w http.ResponseWriter, r *http.Request, store *state.InstanceStore, instanceID, key string) { + expected, ok := webAppIfMatch(r) + if !ok { + writeWebAppError(w, http.StatusPreconditionRequired, "plugin_state_precondition_required") + return + } + body, err := readWebAppBody(r) + if err != nil || !json.Valid(body) { + writeWebAppError(w, http.StatusBadRequest, "invalid_request") + return + } + entry, err := store.Set(ctx, instanceID, key, json.RawMessage(body), &expected, "browser") + if err != nil { + writeWebAppStateError(w, err) + return + } + writeWebAppJSON(w, r, http.StatusOK, webAppStateEntryFromStore(entry)) +} + +func (s *Service) deleteWebAppState(ctx context.Context, w http.ResponseWriter, r *http.Request, store *state.InstanceStore, instanceID, key string) { + expected, ok := webAppIfMatch(r) + if !ok { + writeWebAppError(w, http.StatusPreconditionRequired, "plugin_state_precondition_required") + return + } + revision, err := store.Delete(ctx, instanceID, key, &expected, "browser") + if err != nil { + writeWebAppStateError(w, err) + return + } + writeWebAppJSON(w, r, http.StatusOK, struct { + Revision int64 `json:"revision"` + }{Revision: revision}) +} + +func webAppStateEntryFromStore(entry state.InstanceStateEntry) webAppStateEntry { + return webAppStateEntry{ + Key: entry.Key, Value: append(json.RawMessage(nil), entry.Value...), + Revision: entry.Revision, WriterKind: entry.WriterKind, + UpdatedAt: entry.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func webAppIfMatch(r *http.Request) (int64, bool) { + value := strings.TrimSpace(r.Header.Get("If-Match")) + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + value = value[1 : len(value)-1] + } + if value == "" || strings.ContainsAny(value, "\"*, ") { + return 0, false + } + revision, err := strconv.ParseInt(value, 10, 64) + if err != nil || revision < 0 { + return 0, false + } + return revision, true +} + +func writeWebAppStateError(w http.ResponseWriter, err error) { + var conflict *state.ConflictError + if errors.As(err, &conflict) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write(mustMarshalWebAppStateConflict(conflict.CurrentRevision)) + return + } + writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) +} + +func mustMarshalWebAppStateConflict(revision int64) []byte { + body, err := json.Marshal(struct { + Error string `json:"error"` + CurrentRevision int64 `json:"current_revision"` + }{Error: "plugin_state_conflict", CurrentRevision: revision}) + if err != nil { + return []byte(`{"error":"plugin_state_conflict"}`) + } + return body +} + +func readWebAppBody(r *http.Request) ([]byte, error) { + if r.Body == nil { + return nil, errors.New("request body is required") + } + body, err := io.ReadAll(io.LimitReader(r.Body, webAppRequestLimit+1)) + if err != nil { + return nil, err + } + if len(body) == 0 || len(body) > webAppRequestLimit { + return nil, errors.New("request body exceeds limit") + } + return body, nil +} + +func decodeWebAppJSON(r *http.Request, destination any) error { + body, err := readWebAppBody(r) + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return errors.New("multiple JSON values") + } + return err + } + return nil +} diff --git a/apps/backend/internal/plugins/webapp_protocol_test.go b/apps/backend/internal/plugins/webapp_protocol_test.go new file mode 100644 index 0000000000..f09910f217 --- /dev/null +++ b/apps/backend/internal/plugins/webapp_protocol_test.go @@ -0,0 +1,220 @@ +package plugins + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/state" + "github.com/kandev/kandev/internal/plugins/webapp" + taskmodels "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) { + svc := &Service{} + binding := webapp.CapabilityBinding{ + UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", + ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas", + ScopeKind: "task", WorkspaceID: "workspace-1", TaskID: "task-1", + Permissions: []string{"api_read:tasks", "state"}, + } + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + svc.handleWebAppProtocol(response, request, "ignored", binding, "v1/context") + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if _, ok := body["user_id"]; ok { + t.Fatal("context exposed user_id") + } + if _, ok := body["token"]; ok { + t.Fatal("context exposed token") + } + if body["instance_id"] != "instance-1" || body["protocol_version"] != float64(1) { + t.Fatalf("context = %#v", body) + } +} + +func TestWebAppProtocolStateUsesRevisionPreconditions(t *testing.T) { + store := newWebAppProtocolStateStore(t) + svc := &Service{instanceState: store} + binding := webapp.CapabilityBinding{ + UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", + ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas", + ScopeKind: "task", Permissions: []string{"state"}, + } + + missing := httptest.NewRecorder() + svc.handleWebAppProtocol(missing, httptest.NewRequest(http.MethodPut, "/", bytes.NewBufferString(`{"enabled":true}`)), "", binding, "v1/state/preferences") + if missing.Code != http.StatusPreconditionRequired || !containsBody(missing, "plugin_state_precondition_required") { + t.Fatalf("missing precondition = %d %s", missing.Code, missing.Body.String()) + } + + first := httptest.NewRecorder() + initial := httptest.NewRequest(http.MethodPut, "/", bytes.NewBufferString(`{"enabled":true}`)) + initial.Header.Set("If-Match", `"0"`) + svc.handleWebAppProtocol(first, initial, "", binding, "v1/state/preferences") + if first.Code != http.StatusOK || !containsBody(first, `"revision":1`) { + t.Fatalf("initial state write = %d %s", first.Code, first.Body.String()) + } + + stale := httptest.NewRecorder() + staleRequest := httptest.NewRequest(http.MethodPut, "/", bytes.NewBufferString(`{"enabled":false}`)) + staleRequest.Header.Set("If-Match", `"0"`) + svc.handleWebAppProtocol(stale, staleRequest, "", binding, "v1/state/preferences") + if stale.Code != http.StatusConflict || !containsBody(stale, `"current_revision":1`) || containsBody(stale, "enabled") { + t.Fatalf("stale state write = %d %s", stale.Code, stale.Body.String()) + } + + read := httptest.NewRecorder() + svc.handleWebAppProtocol(read, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/state/preferences") + if read.Code != http.StatusOK || !containsBody(read, `"enabled":true`) { + t.Fatalf("state read = %d %s", read.Code, read.Body.String()) + } +} + +func TestValidateWebAppPermissionsRequiresCurrentScopedGrants(t *testing.T) { + connection, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "protocol-grants.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + connection.SetMaxOpenConns(1) + pool := db.NewPool(sqlx.NewDb(connection, "sqlite3"), sqlx.NewDb(connection, "sqlite3")) + t.Cleanup(func() { _ = pool.Close() }) + store, err := instances.NewStore(pool) + if err != nil { + t.Fatalf("new instance store: %v", err) + } + if err := store.Create(context.Background(), instances.Instance{ + ID: "instance-1", PluginID: "plugin-1", SourceKind: instances.SourceLocalCanvas, + ScopeKind: instances.ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-1", + Status: instances.StatusActive, CreatedAt: time.Now(), UpdatedAt: time.Now(), + }); err != nil { + t.Fatalf("create instance: %v", err) + } + svc := &Service{} + svc.SetWebAppStorage(store, nil) + binding := webapp.CapabilityBinding{ + InstanceID: "instance-1", ScopeKind: instances.ScopeTask, + Permissions: []string{"api_read:tasks", "state"}, + } + raw := json.RawMessage(`{"capabilities":{"api_read":["tasks"],"state":true}}`) + if err := svc.validateWebAppPermissions(context.Background(), binding, raw); err == nil { + t.Fatal("permission validation succeeded without grants") + } + for _, grant := range []instances.Grant{ + {InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeTask, ApprovedBy: "user-1"}, + {InstanceID: "instance-1", PermissionKind: "state", ScopeCeiling: instances.ScopeTask, ApprovedBy: "user-1"}, + } { + if err := store.AddGrant(context.Background(), grant); err != nil { + t.Fatalf("add grant %s: %v", grant.PermissionKind, err) + } + } + if err := svc.validateWebAppPermissions(context.Background(), binding, raw); err != nil { + t.Fatalf("permission validation with grants: %v", err) + } +} + +func TestWebAppProtocolTaskWorkflowAndMessageRoutesUseHostAdapters(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{ + APIRead: []string{"tasks", "workflows"}, + APIWrite: []string{"tasks", "messages"}, + }) + task := &taskmodels.Task{ + ID: "task-1", WorkspaceID: "workspace-1", WorkflowID: "workflow-1", + WorkflowStepID: "step-1", Title: "Canvas task", State: v1.TaskStateTODO, + } + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "workspace-1"}} + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"workspace-1": {task}} + d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": task} + d.workflows.workflows = map[string][]*taskmodels.Workflow{"workspace-1": {{ID: "workflow-1", WorkspaceID: "workspace-1", Name: "Main"}}} + d.steps.steps = map[string][]*wfmodels.WorkflowStep{"workflow-1": {{ID: "step-1", WorkflowID: "workflow-1", Name: "Doing", Position: 1}}} + d.taskWriter.moveResult = &TaskMoveResult{ + Task: &taskmodels.Task{ID: "task-1", WorkspaceID: "workspace-1", WorkflowID: "workflow-1", WorkflowStepID: "step-2", Title: "Moved"}, + Transitioned: true, + FromStepID: "step-1", + } + d.messenger.result = PluginMessageResult{SessionID: "session-1", Status: "queued"} + + svc := &Service{ + taskData: d.tasks, workflows: d.workflows, workflowSteps: d.steps, + taskWriter: d.taskWriter, + messenger: d.messenger, + taskStarter: d.starter, + } + binding := webapp.CapabilityBinding{ + UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", + ReleaseID: "release-1", WebAppKey: "board", Placement: "workspace-canvas", + ScopeKind: instances.ScopeWorkspace, WorkspaceID: "workspace-1", + Permissions: []string{"api_read:tasks", "api_read:workflows", "api_write:tasks", "api_write:messages"}, + } + + list := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/?limit=10", nil) + svc.handleWebAppProtocol(list, request, "", binding, "v1/data/tasks") + if list.Code != http.StatusOK || !containsBody(list, `"workflow_step_id":"step-1"`) { + t.Fatalf("task list = %d %s", list.Code, list.Body.String()) + } + + steps := httptest.NewRecorder() + svc.handleWebAppProtocol(steps, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/workflows/workflow-1/steps") + if steps.Code != http.StatusOK || !containsBody(steps, `"id":"step-1"`) { + t.Fatalf("workflow steps = %d %s", steps.Code, steps.Body.String()) + } + + patch := httptest.NewRecorder() + patchRequest := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"workflow_step_id":"step-2"}`)) + svc.handleWebAppProtocol(patch, patchRequest, "", binding, "v1/data/tasks/task-1") + if patch.Code != http.StatusOK || d.taskWriter.moveCalls != 1 || d.taskWriter.lastMove.TaskID != "task-1" || d.taskWriter.lastMove.WorkflowStepID != "step-2" { + t.Fatalf("task patch = %d %s, move input = %+v", patch.Code, patch.Body.String(), d.taskWriter.lastMove) + } + if d.taskWriter.updateCalls != 0 { + t.Fatalf("workflow-step patch used UpdateTask %d times", d.taskWriter.updateCalls) + } + + message := httptest.NewRecorder() + messageRequest := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"text":"continue","session_id":"session-1"}`)) + svc.handleWebAppProtocol(message, messageRequest, "", binding, "v1/data/tasks/task-1/messages") + if message.Code != http.StatusAccepted || d.messenger.lastText != "continue" || d.messenger.lastSource != "plugin:plugin-1" { + t.Fatalf("message send = %d %s, messenger = %+v", message.Code, message.Body.String(), d.messenger) + } +} + +func newWebAppProtocolStateStore(t *testing.T) *state.InstanceStore { + t.Helper() + connection, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "protocol-state.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + connection.SetMaxOpenConns(1) + pool := db.NewPool(sqlx.NewDb(connection, "sqlite3"), sqlx.NewDb(connection, "sqlite3")) + t.Cleanup(func() { _ = pool.Close() }) + store, err := state.NewInstanceStore(pool) + if err != nil { + t.Fatalf("new instance state store: %v", err) + } + return store +} + +func containsBody(response *httptest.ResponseRecorder, value string) bool { + return bytes.Contains(response.Body.Bytes(), []byte(value)) +} diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index d18aacf32a..90cf329a61 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -54,6 +54,13 @@ features: dev: "false" e2e: "false" + # Agent-authored plugin web applications. Off in every shipped profile until + # the isolated runtime and lifecycle are ready for opt-in use. + KANDEV_FEATURES_CANVASES: + prod: "false" + dev: "false" + e2e: "false" + # Organizations: a tenant boundary above users. Requires KANDEV_FEATURES_AUTH; # the backend refuses to start with this on and auth off. Off everywhere until # the runtime-isolation work (per-org filesystem roots, per-org agent diff --git a/apps/backend/internal/profiles/profiles_test.go b/apps/backend/internal/profiles/profiles_test.go index 8c90e29f7e..d10ca46277 100644 --- a/apps/backend/internal/profiles/profiles_test.go +++ b/apps/backend/internal/profiles/profiles_test.go @@ -57,6 +57,41 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { } } +func TestCanvasFeatureFlagIsDisabledInEveryProfile(t *testing.T) { + for _, profile := range []struct { + name string + selector map[string]string + }{{ + name: "prod", + }, { + name: "dev", + selector: map[string]string{ + "KANDEV_DEBUG_DEV_MODE": "true", + }, + }, { + name: "e2e", + selector: map[string]string{ + "KANDEV_E2E_MOCK": "true", + }, + }} { + t.Run(profile.name, func(t *testing.T) { + clearProfileSelectors(t) + clearProfilesYAMLVars(t) + for key, value := range profile.selector { + t.Setenv(key, value) + } + + defaults, err := EnvironmentDefaults() + if err != nil { + t.Fatalf("EnvironmentDefaults: %v", err) + } + if got := defaults["KANDEV_FEATURES_CANVASES"]; got != "false" { + t.Fatalf("KANDEV_FEATURES_CANVASES = %q in %s, want false", got, profile.name) + } + }) + } +} + func TestEnvironmentDefaultsSelectsProfileWithoutMutatingEnvironment(t *testing.T) { clearProfileSelectors(t) clearProfilesYAMLVars(t) diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index fbb2d8bec6..5f2752e136 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -67,6 +67,24 @@ var registrations = []runtimeFlagRegistration{ read: func(cfg *config.Config) bool { return cfg.Features.Auth }, apply: func(cfg *config.Config, value bool) { cfg.Features.Auth = value }, }, + { + definition: RuntimeFlagDefinition{ + Key: "features.canvases", + EnvVar: "KANDEV_FEATURES_CANVASES", + Kind: KindFeature, + Label: "Agent-authored canvases", + Description: "Enables isolated agent-authored web application canvases for tasks and workspaces.", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "Canvas applications execute arbitrary packaged browser code in a sandboxed runtime and " + + "can access only explicitly granted Kandev data. Enable this only while reviewing the isolation, " + + "storage, and permission behavior of the experimental feature.", + RestartRequired: true, + Mutable: true, + }, + read: func(cfg *config.Config) bool { return cfg.Features.Canvases }, + apply: func(cfg *config.Config, value bool) { cfg.Features.Canvases = value }, + }, { definition: RuntimeFlagDefinition{ Key: "features.multiTenancy", diff --git a/apps/backend/internal/runtimeflags/registry_test.go b/apps/backend/internal/runtimeflags/registry_test.go index 7c6e6c09ee..66a2824bbe 100644 --- a/apps/backend/internal/runtimeflags/registry_test.go +++ b/apps/backend/internal/runtimeflags/registry_test.go @@ -61,6 +61,31 @@ func TestDefinitionsIncludeDynamicAgentRoutingMetadata(t *testing.T) { } } +func TestDefinitionsIncludeCanvasMetadata(t *testing.T) { + def, ok := DefinitionByKey("features.canvases") + if !ok { + t.Fatal("features.canvases definition missing") + } + if def.EnvVar != "KANDEV_FEATURES_CANVASES" { + t.Fatalf("EnvVar = %q, want KANDEV_FEATURES_CANVASES", def.EnvVar) + } + if def.Stability != StabilityExperimental { + t.Fatalf("Stability = %q, want %q", def.Stability, StabilityExperimental) + } + if def.RiskLevel != RiskHigh { + t.Fatalf("RiskLevel = %q, want %q", def.RiskLevel, RiskHigh) + } + if def.RiskDescription == "" { + t.Fatal("RiskDescription empty") + } + if !def.RestartRequired { + t.Fatal("RestartRequired = false, want true") + } + if !def.Mutable { + t.Fatal("Mutable = false, want true") + } +} + // TestDefinitionsExcludePlugins pins the graduation of the plugin system out // of the feature-flag tier: plugins ship in the base product, so no toggle may // reappear in Settings > System > Feature Toggles. diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 196c2f02e9..d24dd2b531 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -26,6 +26,16 @@ type WorktreeCleanup interface { OnTaskDeleted(ctx context.Context, taskID string) error } +// CanvasCleanup removes plugin-backed canvas authority owned by a task or +// workspace. It is optional so focused task-service users do not need the +// canvas subsystem. Both cleanup methods run before their owning task or +// workspace delete commits, so release-artifact cleanup ownership is recorded +// before any canvas authority can become orphaned. +type CanvasCleanup interface { + CleanupTaskCanvases(ctx context.Context, taskID string) error + CleanupWorkspaceCanvases(ctx context.Context, workspaceID string) error +} + // WorkspaceSecretDeleter removes secrets owned by a workspace. It is optional // for isolated task-service users. type WorkspaceSecretDeleter interface { @@ -354,6 +364,7 @@ type Service struct { logger *logger.Logger discoveryConfig RepositoryDiscoveryConfig worktreeCleanup WorktreeCleanup + canvasCleanup CanvasCleanup executionStopper TaskExecutionStopper clarificationCanceller TerminalClarificationCanceller rowLivenessProber TaskRowLivenessProber @@ -528,6 +539,11 @@ func (s *Service) SetWorktreeCleanup(cleanup WorktreeCleanup) { s.worktreeCleanup = cleanup } +// SetCanvasCleanup wires lifecycle cleanup for plugin-backed canvases. +func (s *Service) SetCanvasCleanup(cleanup CanvasCleanup) { + s.canvasCleanup = cleanup +} + func (s *Service) setCleanupDoneForTestHook(ch chan struct{}) { s.cleanupDoneForTest = ch } diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 0fb6fd743c..8cd5c518a5 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -274,6 +274,14 @@ func (s *Service) deleteWorkspace(ctx context.Context, workspace *models.Workspa if err != nil { return err } + // Record canvas artifact cleanup before the workspace cascade removes its + // task and workspace rows. This keeps the release ownership boundary + // durable across a process stop between the two operations. + if s.canvasCleanup != nil { + if err := s.canvasCleanup.CleanupWorkspaceCanvases(ctx, workspace.ID); err != nil { + return fmt.Errorf("cleanup workspace canvases before workspace delete: %w", err) + } + } var deletedTasks []*models.Task var deletedWorkflows []*models.Workflow diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index b6d2b52b1e..868e41f92d 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -2665,6 +2665,15 @@ func (s *Service) deleteTaskWithReasonAndDBDelete( zap.String("env_id", taskEnvironmentID(taskEnv)), zap.String("new_owner_task_id", taskEnv.TaskID)) } + // Remove task-scoped canvas authority while the task identity still exists. + // Promoted canvases have no task_id and are therefore preserved by the + // canvas service. A failure aborts the task mutation so the active release + // cannot be orphaned by a successful task delete. + if s.canvasCleanup != nil { + if err := s.canvasCleanup.CleanupTaskCanvases(ctx, id); err != nil { + return false, fmt.Errorf("cleanup task canvases for delete: %w", err) + } + } envCleanup := taskEnvironmentCleanup{env: taskEnv, deleteRow: false} cleanupJob, err := s.persistTaskResourceCleanup( diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 1fb337f5cc..51d2810a99 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -88,7 +88,17 @@ const ( ActionTaskPlanRevisionsList = "task.plan.revisions.list" ActionTaskPlanRevisionGet = "task.plan.revision.get" ActionTaskPlanRevert = "task.plan.revert" - ActionTaskPlanImplement = "task.plan.implementation_started" + + // Plugin-backed canvas lifecycle notifications. These are owner-scoped + // workspace/task events and contain no application content or capabilities. + ActionCanvasCreated = "canvas.created" + ActionCanvasReleaseActivated = "canvas.release.activated" + ActionCanvasReleasePermissionRequired = "canvas.release.permission_required" + ActionCanvasPromoted = "canvas.promoted" + ActionCanvasArchived = "canvas.archived" + ActionCanvasRestored = "canvas.restored" + ActionCanvasRemoved = "canvas.removed" + ActionTaskPlanImplement = "task.plan.implementation_started" ActionTaskSessionList = "task.session.list" ActionTaskSessionStatus = "task.session.status" @@ -445,12 +455,19 @@ const ( ActionMCPGetDiagnosticBundle = "mcp.get_diagnostic_bundle" // Office task handoffs (cross-task context). - ActionMCPListRelatedTasks = "mcp.list_related_tasks" - ActionMCPListTaskDocuments = "mcp.list_task_documents" - ActionMCPGetTaskDocument = "mcp.get_task_document" - ActionMCPWriteTaskDocument = "mcp.write_task_document" - ActionMCPListPluginTools = "mcp.list_plugin_tools" - ActionMCPInvokePluginTool = "mcp.invoke_plugin_tool" + ActionMCPListRelatedTasks = "mcp.list_related_tasks" + ActionMCPListTaskDocuments = "mcp.list_task_documents" + ActionMCPGetTaskDocument = "mcp.get_task_document" + ActionMCPWriteTaskDocument = "mcp.write_task_document" + ActionMCPListPluginTools = "mcp.list_plugin_tools" + ActionMCPInvokePluginTool = "mcp.invoke_plugin_tool" + ActionMCPListCanvases = "mcp.list_canvases" + ActionMCPReadCanvasAuthoringSkill = "mcp.read_canvas_authoring_skill" + ActionMCPCreateCanvas = "mcp.create_canvas" + ActionMCPGetCanvas = "mcp.get_canvas" + ActionMCPPublishCanvas = "mcp.publish_canvas" + ActionMCPGetCanvasState = "mcp.get_canvas_state" + ActionMCPSetCanvasState = "mcp.set_canvas_state" // Office quorum decision recording. ActionMCPRecordStepDecision = "mcp.record_step_decision" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 72423ab01a..b0ecd81ac9 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -27,7 +27,7 @@ "capabilities": [ "default" ], - "csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; frame-ancestors 'none'" + "csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; frame-src 'self' http://127.0.0.1:* http://localhost:*; frame-ancestors 'none'" } }, "plugins": { diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 2fdb6b146e..d819cc9426 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -297,4 +297,4 @@ plugin leaks a stale registration. - `vitest.config.ts` pins `process.env.NODE_ENV = "test"`, and that line is load-bearing. React exports `act()` only from its development build, so under `NODE_ENV=production` — which the runtime image sets (`Dockerfile`) and every container/agent shell inherits, while CI's image does not — `@testing-library/react` falls back to `react-dom/test-utils` and **every** `render()`/`renderHook()` throws `TypeError: React.act is not a function` before any assertion, with CI still green. `vitest-environment.test.tsx` and the `vitest.setup.ts` preflight fail by name if the pin goes, and the `Run tests` step in `.github/workflows/frontend-tests.yml` exports `NODE_ENV=production` on purpose so those guards fire in CI too rather than only in a container. - **E2E waits name the cause, they do not budget for the effect.** The suite's dominant flake is `await expect(x).toBeEnabled({ timeout: 30_000 })` — an assertion on a UI shadow of a backend event whose hand-picked budget expires under load. `e2e/helpers/causal-waits.ts` has one primitive per transport (`waitForHttp`, `watchWs().waitForEvent`, `watchWs().waitForResponse`): arm it before the action, await it after, then assert the UI with its **default** timeout. "The backend reached state X" needs no primitive — `expect.poll` against `e2e/helpers/api-client.ts` already reads the backend, not the DOM. `watchWs(page)` only sees sockets opened after it is called, so it goes before the first `page.goto()`. Confirm a causal chain by probing a live run with a throwaway `page.on("response")` logger; reading the components predicts it wrong often enough to matter. The only sanctioned wall-clock wait is `dwell(page, ms, category, reason)`, or `dwell(ms, category, reason)` where no `Page` exists (fixtures, api-client retries) — `category` is the closed `DwellCategory` union (`negative-assertion` is permanent, `unverified` is debt to drive to zero), `reason` says why no event exists, no options; a delay inside a `page.route()` handler is `injectLatency(ms, reason)` instead, since the delay is the stimulus rather than a wait; raw `page.waitForTimeout` and promise sleeps are not sanctioned. Guide and worked examples: `e2e/README.md`. **The sleep ban is enforced in two layers**, the same shape as the i18n guards, and the conversion is complete so both cover the whole tree. `eslint-rules/no-unsanctioned-sleep.mjs` is an AST rule (`e2e/` has ~700 `test.setTimeout()` calls, Playwright's per-test timeout setter, which a regex cannot separate from a sleep — nor a sleep from a `Promise.race` guard); it is an **error across all of `e2e/`** via `e2eSleepGuardFiles`, and it also rejects a `dwell`/`injectLatency` not imported from `e2e/helpers/causal-waits`, since a failed import otherwise lints clean and throws on exactly the loaded shard the wait existed to survive. `pnpm run e2e:sleep-ratchet` (CI + pre-commit) is the second layer and judges the **change, not the file**. The guard **only ever widens** — never narrow it to make a build pass; `e2e-sleep-wiring.test.ts` asserts coverage via ESLint's own config resolution and fails if the CI step or hook disappears. Details: `e2e/README.md` ("How this is enforced"). -- jsdom secure cookies need cookie-setter interception; Radix Tooltip tests use keyboard focus, while Playwright covers pointer hover with `locator.hover()`. Scope terminal selectors to the active panel/container; mobile and dockview may mount multiple instances, so shared helpers must not use global selectors. +- jsdom secure cookies need cookie-setter interception; Radix Tooltip tests use keyboard focus, while Playwright covers pointer hover with `locator.hover()`. Scope terminal selectors to the active panel/container; mobile and dockview may mount multiple instances, so shared helpers must not use global selectors. Local full Vitest suites use the configured 20-percent worker budget; do not pass all-worker overrides or overlap suites. diff --git a/apps/web/components/app-sidebar/app-sidebar-constants.ts b/apps/web/components/app-sidebar/app-sidebar-constants.ts index 4af389ae65..922600e944 100644 --- a/apps/web/components/app-sidebar/app-sidebar-constants.ts +++ b/apps/web/components/app-sidebar/app-sidebar-constants.ts @@ -7,6 +7,7 @@ export const APP_SIDEBAR_SECTION_IDS = { projects: "projects", agents: "agents", integrations: "integrations", + canvases: "canvases", settings: "settings", } as const; diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-actions.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-actions.tsx index a4e76198f1..d80c85fb76 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-actions.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-actions.tsx @@ -2,11 +2,17 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { IconLayoutGrid, IconListDetails } from "@tabler/icons-react"; +import Link from "@/components/routing/app-link"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useAppStore } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { usePathname } from "@/lib/routing/client-router"; import type { SidebarWorkspaceActionsSlotProps } from "@/lib/plugins/types"; import { usePluginRegistry } from "@/lib/plugins/registry"; import { cn } from "@/lib/utils"; +import { canvasHref, workspaceCanvasSettingsHref } from "@/lib/api/domains/canvas-api"; +import { isActiveWorkspaceCanvas, useWorkspaceCanvases } from "./sections/canvases-section"; /** * Props forwarded to every plugin component registered for the @@ -61,10 +67,15 @@ export function MobileWorkspaceActionsSection({ }) { const { t } = useTranslation(); const activeWorkspaceId = useAppStore((state) => state.workspaces?.activeId ?? null); + const canvasesEnabled = useFeature("canvases"); const registry = usePluginRegistry(); + const pathname = usePathname(); const workspaceId = providedWorkspaceId ?? activeWorkspaceId; + const canvases = useWorkspaceCanvases(canvasesEnabled ? workspaceId : null); + const activeCanvases = canvases.filter(isActiveWorkspaceCanvas); + const hasPluginActions = registry.getSlotRegistrations("sidebar-workspace-actions").length > 0; - if (!workspaceId || registry.getSlotRegistrations("sidebar-workspace-actions").length === 0) { + if (!workspaceId || (!hasPluginActions && !canvasesEnabled)) { return null; } @@ -75,7 +86,43 @@ export function MobileWorkspaceActionsSection({ role="group" aria-label={t("common:workspace")} > - + {canvasesEnabled && ( +
+
+
+ {activeCanvases.length > 0 ? ( + activeCanvases.map((canvas) => ( + +
+ )} + {hasPluginActions && ( + + )} ); } diff --git a/apps/web/components/app-sidebar/app-sidebar.test.tsx b/apps/web/components/app-sidebar/app-sidebar.test.tsx index 9963a48118..16b5a79603 100644 --- a/apps/web/components/app-sidebar/app-sidebar.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.test.tsx @@ -47,6 +47,11 @@ vi.mock("./sections/tasks-section", () => ({ ), })); +vi.mock("./sections/canvases-section", () => ({ + CanvasesSection: ({ collapsed }: { collapsed: boolean }) => ( +
+ ), +})); vi.mock("./sections/projects-section", () => ({ ProjectsSection: () =>
, })); @@ -102,6 +107,7 @@ vi.mock("@/hooks/use-office-workspace-data", () => ({ const storeState = { features: { office: false, + canvases: false, }, workspaces: { activeId: undefined as string | undefined, @@ -115,6 +121,7 @@ const storeState = { projects: false, agents: false, integrations: false, + canvases: false, automations: false, settings: false, }, @@ -168,6 +175,8 @@ function resetSidebarState() { officeRouteMock.mode = null; storeState.appSidebar.collapsed = false; storeState.appSidebar.settingsMode = false; + storeState.appSidebar.sectionExpanded.canvases = false; + storeState.features.canvases = false; storeState.toggleAppSidebar = vi.fn(); storeState.toggleAppSidebarSection = vi.fn(); storeState.toggleAppSidebarSettingsMode = vi.fn(); @@ -298,6 +307,25 @@ describe("AppSidebar", () => { }); }); +describe("AppSidebar canvas routes", () => { + beforeEach(resetSidebarState); + + afterEach(() => { + cleanup(); + }); + + it("does not force-expand the Canvases section for a direct canvas route", async () => { + navigationMock.pathname = "/canvases/canvas-1"; + storeState.features.canvases = true; + + renderSidebar(); + + await waitFor(() => expect(screen.getByTestId("canvases-section")).toBeTruthy()); + expect(storeState.toggleAppSidebarSection).not.toHaveBeenCalledWith("canvases"); + expect(storeState.appSidebar.sectionExpanded.canvases).toBe(false); + }); +}); + describe("AppSidebar before the workspace resolves", () => { beforeEach(() => { navigationMock.pathname = "/"; diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx index 2d5f717d93..22bed91df8 100644 --- a/apps/web/components/app-sidebar/app-sidebar.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -22,6 +22,7 @@ import { AppSidebarResizeHandle } from "./app-sidebar-resize-handle"; import { AppSidebarSettingsMode } from "./app-sidebar-settings-mode"; import { AgentsSection } from "./sections/agents-section"; import { AutomationsSection } from "./sections/automations-section"; +import { CanvasesSection } from "./sections/canvases-section"; import { IntegrationsSection } from "./sections/integrations-section"; import { OfficeNavigationSection } from "./sections/office-navigation-section"; import { ProjectsSection } from "./sections/projects-section"; @@ -81,6 +82,7 @@ function AppSidebarModeNav({ collapsed, inOffice }: { collapsed: boolean; inOffi same weight as a project, and the list IS the nav — picking one opens its history rather than a settings form. */} {!inOffice && } + {!inOffice && } {inOffice && } diff --git a/apps/web/components/app-sidebar/sections/canvases-section.test.tsx b/apps/web/components/app-sidebar/sections/canvases-section.test.tsx new file mode 100644 index 0000000000..6f855f1246 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/canvases-section.test.tsx @@ -0,0 +1,186 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Canvas } from "@/lib/api/domains/canvas-api"; + +const mocks = vi.hoisted(() => ({ + listWorkspaceCanvases: vi.fn(), + enabled: true, + pathname: "/", + expanded: true, +})); + +const state = { + features: { canvases: true }, + workspaces: { activeId: "workspace-1" }, + appSidebar: { sectionExpanded: { canvases: true } as Record }, + toggleAppSidebarSection: vi.fn(), + setAppSidebarCollapsed: vi.fn(), +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (value: typeof state) => unknown) => selector(state), +})); +vi.mock("@/hooks/domains/features/use-feature", () => ({ + useFeature: () => mocks.enabled, +})); +vi.mock("@/lib/routing/client-router", () => ({ + usePathname: () => mocks.pathname, +})); +vi.mock("@/lib/api/domains/canvas-api", () => ({ + canvasHref: (id: string) => `/canvases/${encodeURIComponent(id)}`, + workspaceCanvasSettingsHref: (id: string) => + `/settings/workspaces/${encodeURIComponent(id)}/canvases`, + listWorkspaceCanvases: mocks.listWorkspaceCanvases, +})); +import { CanvasesSection, isActiveWorkspaceCanvas } from "./canvases-section"; + +const ACTIVE_CANVAS: Canvas = { + id: "canvas-1", + plugin_instance_id: "instance-1", + plugin_id: "plugin-1", + workspace_id: "workspace-1", + scope_kind: "workspace", + title: "Release readiness", + status: "active", + active_release_id: "release-1", + active_release_status: "valid", +}; +const CANVASES_LABEL = "Canvases"; +const ACTIVE_CANVAS_TEST_ID = "sidebar-canvas-canvas-1"; + +beforeEach(() => { + mocks.enabled = true; + mocks.pathname = "/"; + state.appSidebar.sectionExpanded.canvases = true; + mocks.listWorkspaceCanvases.mockReset(); + mocks.listWorkspaceCanvases.mockResolvedValue({ + canvases: [ + ACTIVE_CANVAS, + { ...ACTIVE_CANVAS, id: "task-canvas", scope_kind: "task", title: "Task only" }, + { ...ACTIVE_CANVAS, id: "archived-canvas", status: "archived", title: "Archived" }, + { + ...ACTIVE_CANVAS, + id: "pending-canvas", + active_release_status: "pending_permission", + title: "Pending permissions", + }, + { + ...ACTIVE_CANVAS, + id: "invalid-canvas", + active_release_status: "invalid", + title: "Invalid release", + }, + { + ...ACTIVE_CANVAS, + id: "unavailable-canvas", + active_release_status: "unavailable", + title: "Unavailable release", + }, + ], + }); +}); + +afterEach(cleanup); + +describe("CanvasesSection", () => { + it("lists only active workspace canvases and links the settings shortcut", async () => { + render( + + + , + ); + + expect(await screen.findByTestId(ACTIVE_CANVAS_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(ACTIVE_CANVAS_TEST_ID).getAttribute("href")).toBe( + "/canvases/canvas-1", + ); + expect(screen.queryByText("Task only")).toBeNull(); + expect(screen.queryByText("Archived")).toBeNull(); + expect(screen.queryByText("Pending permissions")).toBeNull(); + expect(screen.queryByText("Invalid release")).toBeNull(); + expect(screen.queryByText("Unavailable release")).toBeNull(); + expect(screen.getByTestId("sidebar-canvases-settings").getAttribute("href")).toBe( + "/settings/workspaces/workspace-1/canvases", + ); + expect(screen.queryByTestId("sidebar-create-canvas")).toBeNull(); + }); + + it("shows setup guidance only after expanding an empty canvas section", async () => { + mocks.listWorkspaceCanvases.mockResolvedValueOnce({ canvases: [] }); + state.appSidebar.sectionExpanded.canvases = false; + + const { rerender } = render( + + + , + ); + + await waitFor(() => expect(screen.getByText(CANVASES_LABEL)).toBeTruthy()); + expect(screen.queryByTestId("sidebar-canvases-empty")).toBeNull(); + + state.appSidebar.sectionExpanded.canvases = true; + rerender( + + + , + ); + + const setup = await screen.findByTestId("sidebar-canvases-empty"); + expect(setup.textContent).toContain("Set up a canvas"); + expect(setup.getAttribute("href")).toBe("/settings/workspaces/workspace-1/canvases"); + expect(screen.queryByTestId("sidebar-create-canvas")).toBeNull(); + }); + + it("starts folded while preserving the active workspace count", async () => { + state.appSidebar.sectionExpanded.canvases = false; + render( + + + , + ); + + await waitFor(() => expect(screen.getByText(CANVASES_LABEL)).toBeTruthy()); + expect(screen.queryByTestId(ACTIVE_CANVAS_TEST_ID)).toBeNull(); + }); + + it("stays folded when the feature changes from off to on without a saved preference", async () => { + state.appSidebar.sectionExpanded = {}; + mocks.enabled = false; + const { rerender } = render( + + + , + ); + + expect(screen.queryByText(CANVASES_LABEL)).toBeNull(); + + mocks.enabled = true; + rerender( + + + , + ); + + await waitFor(() => expect(screen.getByText(CANVASES_LABEL)).toBeTruthy()); + expect(screen.queryByTestId(ACTIVE_CANVAS_TEST_ID)).toBeNull(); + }); +}); + +describe("isActiveWorkspaceCanvas", () => { + it("rejects task, archived, and disabled records", () => { + expect(isActiveWorkspaceCanvas(ACTIVE_CANVAS)).toBe(true); + expect(isActiveWorkspaceCanvas({ ...ACTIVE_CANVAS, scope_kind: "task" })).toBe(false); + expect(isActiveWorkspaceCanvas({ ...ACTIVE_CANVAS, status: "archived" })).toBe(false); + expect(isActiveWorkspaceCanvas({ ...ACTIVE_CANVAS, status: "disabled" })).toBe(false); + expect( + isActiveWorkspaceCanvas({ ...ACTIVE_CANVAS, active_release_status: "pending_permission" }), + ).toBe(false); + expect(isActiveWorkspaceCanvas({ ...ACTIVE_CANVAS, active_release_status: "invalid" })).toBe( + false, + ); + expect( + isActiveWorkspaceCanvas({ ...ACTIVE_CANVAS, active_release_status: "unavailable" }), + ).toBe(false); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/canvases-section.tsx b/apps/web/components/app-sidebar/sections/canvases-section.tsx new file mode 100644 index 0000000000..0cbfc4a86a --- /dev/null +++ b/apps/web/components/app-sidebar/sections/canvases-section.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconLayoutGrid, IconListDetails } from "@tabler/icons-react"; +import Link from "@/components/routing/app-link"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { usePathname } from "@/lib/routing/client-router"; +import { + canvasHref, + listWorkspaceCanvases, + workspaceCanvasSettingsHref, + type Canvas, +} from "@/lib/api/domains/canvas-api"; +import { cn } from "@/lib/utils"; +import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle"; +import { + APP_SIDEBAR_SECTION_IDS, + SIDEBAR_ITEM_ACTIVE, + SIDEBAR_ITEM_INACTIVE, +} from "../app-sidebar-constants"; +import { AppSidebarSection } from "../app-sidebar-section"; + +const EMPTY_CANVASES: Canvas[] = []; + +export function isActiveWorkspaceCanvas(canvas: Canvas): boolean { + return ( + canvas.scope_kind === "workspace" && + canvas.status === "active" && + canvas.active_release_status === "valid" + ); +} + +export function useWorkspaceCanvases( + workspaceId: string | null, + includeArchived = false, +): Canvas[] { + const [loaded, setLoaded] = useState<{ workspaceId: string | null; canvases: Canvas[] }>({ + workspaceId: null, + canvases: EMPTY_CANVASES, + }); + const requestRef = useRef(0); + const lifecycleRevision = useCanvasLifecycleRevision(); + + useEffect(() => { + const requestId = ++requestRef.current; + if (!workspaceId) { + setLoaded({ workspaceId: null, canvases: EMPTY_CANVASES }); + return; + } + + setLoaded((current) => ({ + workspaceId, + canvases: current.workspaceId === workspaceId ? current.canvases : EMPTY_CANVASES, + })); + listWorkspaceCanvases(workspaceId, { includeArchived }) + .then((response) => { + if (requestRef.current !== requestId) return; + setLoaded({ workspaceId, canvases: response?.canvases ?? EMPTY_CANVASES }); + }) + .catch(() => { + if (requestRef.current === requestId) setLoaded({ workspaceId, canvases: EMPTY_CANVASES }); + }); + }, [includeArchived, lifecycleRevision, workspaceId]); + + return loaded.workspaceId === workspaceId ? loaded.canvases : EMPTY_CANVASES; +} + +function OpenCanvasSettingsShortcut({ workspaceId }: { workspaceId: string }) { + const { t } = useTranslation(); + return ( + + + + + + + {t("canvases:openWorkspaceSettings")} + + ); +} + +function CanvasRow({ canvas, active }: { canvas: Canvas; active: boolean }) { + return ( + +