feat(canvas): build and securely deliver cloud artifacts - #76649
Conversation
Rewire the client off the retired desktop file-system surface. Canvases are now rows on the canvases API: the record carries pointers only, source lives in server-side versions (source/versions/revert endpoints), and the rendered output is the published build's artifact. One channel identity everywhere — the backend channel UUID keys the sidebar, stars, CONTEXT.md instructions, feeds, task filing (a PATCH of the task's channel), routes, and deep links; the name-matching bridge (useBackendChannel) is gone. The home canvas is an is_home row seeded by a real publish, and its board reads system.canvases / system.tasks. The freeform store shrinks to view state (context buffer, version browsing, runtime errors); undo/redo browse server versions and revert republishes. The build lifecycle is now observable end-to-end: builds poll while a generation runs, the artifact URL is pinned per build (no iframe reloads on poll ticks), queued/building states show elapsed time, failures surface in both modes with diagnostics and an "Ask agent to fix" affordance, and the completion toast checks the build before announcing success. Agent prompts use the canvas-*/ channel-* tools; the unused local-preview pipeline (canvas-preview, local canvas builder, its contract) is deleted, with the platform manifest vendored from the server's single source of truth for the whitelist contract test. assertCanvasCapability moves to @posthog/core and tolerates a not-yet-loaded manifest instead of rejecting every request. Generated-By: PostHog Code Task-Id: b548ad79-52a8-496c-a064-6dfc4c61b688
Restore the canvas context editor's save path (it lost its writer in the remodel): the editor now buffers locally and commits through the saveContext mutation, and the freeform store shrinks to runtime-error + version-browse state only. Scope revert/reset/sync invalidations to the one canvas via a shared helper; poll builds at the record cadence until a build actually starts; request explicit page sizes on paginated lists. Collapse the duplicated channels query onto useTaskChannels, find the personal channel by type everywhere, and collapse ChannelTaskRecord's duplicate id/taskId. Extract the version-navigation arithmetic into a tested pure helper, replace the currentCode sentinel with a real isEdit flag, seed the home canvas from the shared contract constants, and delete dead code (orphaned freeform version schemas, unreferenced channel client methods, the pruned canvas-contracts validators, a stray local redis dump). Generated-By: PostHog Code Task-Id: b548ad79-52a8-496c-a064-6dfc4c61b688
On OAuth apps with a seeded scope ceiling, /oauth/authorize narrows a wildcard request to the ceiling enumerated at grant time, and refresh never widens it — so sessions granted before the server grew the canvas scope 403 on every /canvases/ call forever. Bumping the version forces those sessions through one re-auth, whose fresh grant includes the scope. Generated-By: PostHog Code Task-Id: b548ad79-52a8-496c-a064-6dfc4c61b688
Canvas-mode submits from the channel composer pre-created an "Untitled canvas" and pinned it in the generation prompt, so the skill's resolve-or-create step never ran and every prompt minted a new husk. The composer now just starts the task (staying on the feed with an optimistic kickoff card); the prompt routes the agent into listing the channel's canvases, building on a clearly-matching one — saying so in its reply — or creating one with a descriptive name. Canvas-side effects (generation-task recording, auto-naming, completion-toast tracking) only run when the surface pre-resolved a target. While a generation runs on a not-yet-renderable canvas, the run's chat panel now opens by default — in view mode too — instead of stranding the user on the "Generating…" spinner; a minimize sticks for the visit and each new run reopens it. Generated-By: PostHog Code Task-Id: b548ad79-52a8-496c-a064-6dfc4c61b688
…ud runs A cloud sandbox lingers after the agent's turn so follow-ups can reuse it — the run record only completes on the inactivity timeout, long after generation ended — so keying the canvas's "Generating…" state (and the loading bar, disabled undo/redo, and record/build polling) on run status pinned it for the whole linger window. When a live session mirror exists, its turn-level prompt-pending signal (armed on session/prompt, cleared on turn_complete) now decides, with "connecting" covering the attach/replay window; without a mirror the run status remains the only signal. Generated-By: PostHog Code Task-Id: b548ad79-52a8-496c-a064-6dfc4c61b688
The `quality` CI job (`biome ci .`) failed with two format errors on lines this branch added: a wrapped `expect(...).not.toContain(...)` call in prompt-builder.test.ts and the collapsed-panel JSX guard in FreeformCanvasView.tsx. Reformatted with `biome format --write` so `biome ci` exits clean; no behaviour change. Generated-By: PostHog Code Task-Id: 4928140a-2ab8-4fb3-86c1-e02d25f60cca
…ffect react-doctor blocked CI with a `no-adjust-state-on-prop-change` error at CanvasBuildStatus.tsx: the effect called `setNow(Date.now())` to restart the clock when a new build became active, so the first commit after a build change briefly rendered the previous build's elapsed time. The `now` state was only ever read to compute the label, so drop it: the interval now just bumps a tick to force a re-render and the label is computed from `Date.now()` during render. This removes the duplicated state (one of the two remedies react-doctor suggests) rather than adding a `prev`-prop comparison, and it also means the label can never be stale. react-doctor and `biome ci` are clean; @posthog/ui typecheck plus the @posthog/ui (2323) and @posthog/core (2595) unit suites pass. Generated-By: PostHog Code Task-Id: 4928140a-2ab8-4fb3-86c1-e02d25f60cca
…build status, home seed (#3931)
Generated-By: PostHog Code Task-Id: 53286c7c-92b5-42a3-8e73-7f45d68382c8
Generated-By: PostHog Code Task-Id: 53286c7c-92b5-42a3-8e73-7f45d68382c8
Generated-By: PostHog Code Task-Id: 53286c7c-92b5-42a3-8e73-7f45d68382c8
Generated-By: PostHog Code Task-Id: 53286c7c-92b5-42a3-8e73-7f45d68382c8
8 updated Run: 556c77f3-ca53-4b8c-b076-ce14c09c6b1b Co-authored-by: k11kirky <4571455+k11kirky@users.noreply.github.com>
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Generated-By: PostHog Code Task-Id: 45da9f78-5b15-4cfe-a8a5-9afcbd6b4755
Reuse: use escapeXmlAttr from @posthog/shared for prompt metadata; derive platform pins (react version, canvasSdkVersion) from CANVAS_PLATFORM_MANIFEST; route the dashboards-grid delete through deleteCanvasWithUndo. Simplification: single build-record mapping shared by toBuildRecord and tryToBuildRecord; de-duplicate the ActivityView feed body across layouts; drop the freeformChatStore LRU/mount bookkeeping now that per-thread state is tiny; inline the one-line canvasSourcePresentation helpers; delete the orphaned desktop file-system client methods. Efficiency: stop re-validating build records in polled getBuilds; bound canvas data payloads without a per-request TextEncoder; keep onDataRequest referentially stable across builds polls; resolve the home canvas from the query cache before hitting ensureHomeCanvas. Altitude: extract a shared canvas host message router used by BuiltCanvas and FreeformCanvas (both paths now get the concurrency/size/timeout guards); move capability gating onto BuiltCanvas via a capabilities prop; extract the pinned-artifact lifecycle into usePinnedArtifact. Generated-By: PostHog Code Task-Id: aea6c5f4-02ae-448b-b4dc-f10f581ad597
Align the port with the imported Spaces UI and singular task repository contract. Regenerate the nested lockfile entry while retaining the monorepo security overrides. Generated-By: PostHog Code Task-Id: 636487f1-66a7-476a-aa18-3b6340ce2c34
|
😎 Merged successfully - details. |
|
Hey @k11kirky! 👋 It looks like your git author email on this PR isn't your
You can fix it for this repo with: git config user.email "you@posthog.com"Or set it globally with |
|
Too many files changed for review. ( Bypass the limit by tagging |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
|
Required CI is green. The remaining review finding concerns the published artifact capability/permission boundary. I am not changing security-sensitive permission enforcement during this automated CI-feedback turn based on review guidance alone; this needs explicit human review and direction. |
Generated-By: PostHog Code Task-Id: 474defa1-1ea2-4e0e-ad8c-542180ecf339
Piccirello
left a comment
There was a problem hiding this comment.
Agent-driven security review — findings inline.
| // assert allows that transient window instead of hard-failing | ||
| // every read. The interactive path never gates: the author's | ||
| // own client keeps full data access while iterating. | ||
| capabilities={ |
There was a problem hiding this comment.
Medium · authz · confidence 5/5
Capability enforcement is switched off by a local UI toggle any viewer can click.
interactive comes from useIsDashboardEditing(dashboardId), which reads dashboardEditStore — a plain Zustand store holding editing: Record<string, boolean>. It's flipped by the "Edit" button in components/WebsiteLayout.tsx, and FreeformEditControls renders that button unconditionally: no authorship check, no access-control check, no server involvement.
The published manifest's capabilities are presented as a security control across both halves of this work. The server's CanvasSourceProjectSerializer help text says "the host enforces these at runtime and validation rejects undeclared ph calls", and validate_source_project blocks a publish whose code calls undeclared ph.*. This line is that runtime enforcement, and it's one click from off for any viewer on any canvas.
Impact — user B opens a canvas that user A's agent published, clicks Edit (a normal, always-visible affordance), and A's canvas gets ungated ph.query (arbitrary HogQL), ph.loadInsight (any short id), and ph.capture (any event name), all executed with B's credentials against B's project. Canvas source is agent-authored, so a prompt-injected generation is the realistic way a canvas comes to want more than it declared. Egress stays capped by the artifact CSP and the posthog.com-only openExternal allowlist, so this is undeclared data access and event writes rather than exfiltration.
Fix — derive the ungated path from something authoritative (authorship, or project write access on the canvas) rather than from a client-side view-mode toggle. If the toggle is meant to stay the trigger, the gate has to move somewhere the canvas's viewer can't flip.
Agent-driven security review.
| method: string, | ||
| payload: unknown, | ||
| ): void { | ||
| if (!capabilities) return; |
There was a problem hiding this comment.
Low · authz · confidence 5/5
assertCanvasCapability is an allowlist of rejections rather than a default-deny, so it fails open in two directions.
if (!capabilities) return;— no manifest means every method is allowed.- Any
methodoutside{query, loadInsight, capture}passes unchecked, because the function is a sequence of per-method rejections with no terminalthrow.
(2) is already primed to matter: canvasToHostMessageSchema accepts method: "run" (core/src/canvas/freeformSchemas.ts), and the comments there and in freeformDataBridge.ts say Phase 3 makes run the view-mode data path — the one that most needs gating. Today run throws "not available yet" in handleFreeformDataRequest, so there's no live hole; the shape is the defect.
On (1), the docstring justifies fail-open as covering the window where "the manifest hasn't loaded yet". That window doesn't exist on this path: capabilities and artifactUrl are both read off the same publishedBuild in the same render (FreeformCanvasView.tsx → usePinnedArtifact), so a rendered artifact always has its manifest. The remaining routes to undefined are the interactive toggle (separate comment) and the pinned artifact outliving publishedBuild when lifecycle goes undefined — worth checking whether a canvas→canvas route change remounts FreeformCanvasView, since WebsiteDashboard.tsx puts no key on it.
Fix — switch on the method and throw for anything unrecognized, and make "ungated" an explicit value (a sentinel or a separate prop) so a missing manifest can't be spelled the same way as a deliberate bypass:
export function assertCanvasCapability(
capabilities: CanvasCapabilities | "ungated",
method: string,
payload: unknown,
): void {
if (capabilities === "ungated") return;
switch (method) {
case "query": ...
case "loadInsight": ...
case "capture": ...
default:
throw new Error(`Method "${method}" is not allowed by this canvas`);
}
}Agent-driven security review.
| onRendered: () => latest.current.onRendered?.(), | ||
| onNavigate: (intent) => latest.current.onNavigate?.(intent), | ||
| }), | ||
| isFrameFocused: () => document.activeElement === iframeRef.current, |
There was a problem hiding this comment.
Low · availability · confidence 3/5
The focus gate on open-external doesn't hold — canvas code can satisfy it without any user interaction.
document.activeElement === iframeRef.current is checked against the host document. Canvas code can call .focus() on an element inside the artifact frame at load; focus moving into a nested browsing context makes the containing iframe the ancestor document's activeElement. So the guarantee stated in canvasHostMessageRouter.ts — "a real link click moves focus into the iframe; requiring it stops canvas code from auto-opening URLs on load" — isn't one. The window.confirm below is what actually holds.
Impact — a canvas can drive a modal confirm dialog at the throttle rate (1/s) from the moment it loads, which blocks the desktop window. The isSafePostHogUrl allowlist caps everything beyond the nuisance, so this is availability rather than exfiltration.
Fix — key the gate on a real user-activation signal (a recent trusted pointer or key event on the host) instead of activeElement. If the focus check stays as-is, drop the guarantee from its doc comment so the next reader doesn't treat it as the gate.
Agent-driven security review. 3/5: the activeElement behaviour is standard but I didn't run it, and whether the dialog spam sustains depends on the artifact frame being site-isolated into its own process, which isn't visible from the repo.
Generated-By: PostHog Code Task-Id: 88e5c6c3-a3f3-4800-85d9-9e02cc4b08ea
|
/trunk merge |
1 similar comment
|
/trunk merge |
|
Could not verify team-posthog-code membership (GitHub API error). Auto-release is limited to members of the team-posthog-code GitHub team, so this will ship with the next scheduled release instead. |
…ng clobbered by the canvas port The canvas artifacts port (#76649) was built from a stale snapshot and overwrote newer master work when it merged. This restores the artifacts page's list/grid/masonry view toggle, page header, and delete-undo handling; the Task.repositories field and its three consumers (task normalization, cloud-run instructions/checkpointing, local handoff) from #76448 that #77252 did not cover; the deleted multi-repo handoff tests; converts the new CanvasBuildStatus component off banned Radix imports; and drops the stale common/alerting entry #73874's conflict resolution re-added to .dockerignore. Generated-By: PostHog Code Task-Id: 167fa10a-0a98-447b-bc44-34d4433de1f0
Co-authored-by: k11kirky <4571455+k11kirky@users.noreply.github.com>
…text editor fill Second adversarial audit pass over the #76649 diff found four more still-broken downgrades: the canvas toolbar's Delete… flow (destructive confirm + undo toast + navigate back to artifacts) was removed, the pin menu label was hardcoded to "channel" under the Spaces layout, the menu lost the w-auto min-w-fit width fix that prevented "Unpin from space" clipping, and the CONTEXT.md edit textarea stopped filling the viewport (fixed rows inside a scroll area). Generated-By: PostHog Code Task-Id: 167fa10a-0a98-447b-bc44-34d4433de1f0
Problem
Canvases relied on the desktop file-system abstraction for source, versions, channels, and task filing. The paired backend remodel in #73874 replaces that surface with first-class canvas and channel APIs, so the desktop client must move to the new contract at the same time.
Ports PostHog/code#3835 into
products/desktop/after the monorepo migration.Changes
/canvas-artifacts/; infrastructure configuration handles this deployment boundary.How did you test this code?
pnpm install --frozen-lockfile --lockfile-onlypnpm typecheckpnpm --filter @posthog/core test– 2,996 tests passedpnpm --filter @posthog/ui test– 2,528 tests passedpnpm --filter @posthog/shared test– 780 tests passednode scripts/check-host-boundaries.mjs– no new violationspnpm exec vitest run src/features/canvas/freeform/BuiltCanvas.test.tsx– 3 tests passedpnpm --filter @posthog/ui typecheckBuiltCanvas.tsxand its testI did not run a manual end-to-end generate → build → artifact flow.
👉 Stay up-to-date with PostHog coding conventions for a smoother review.
Automatic notifications
Docs update
No documentation change. This ports an existing client implementation into the imported desktop tree.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Codex applied the
/porting-code-prsskill to preserve the source patch series and authorship, map it underproducts/desktop/, retain the monorepo lockfile security overrides, and reconcile conflicts against the imported Spaces tree. The original 27 commits were republished as GitHub-verified commits, followed by verified migration-reconciliation and artifact-isolation commits.Created with PostHog Code