From 2d59edf13a25fbc8e47abfcc49f2b8b0f312447b Mon Sep 17 00:00:00 2001 From: praxagent Date: Sat, 3 Oct 2026 11:50:48 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20show=20the=20agent's=20artifacts=20?= =?UTF-8?q?=E2=80=94=20live,=20sandboxed,=20never=20on=20TeamWork's=20orig?= =?UTF-8?q?in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A line '[artifact:]' in a message or note renders the agent's page as a live card (polls for new versions, full-screen view). The page comes from the agent through /api/artifacts (behind TeamWork's login) and runs as srcdoc in an iframe with sandbox='allow-scripts' and no allow-same-origin, under an injected CSP with no network. Verified in headless Chromium: parent, cookies and storage throw SecurityError, origin is null, fetch is refused. Idea credit: Telepath's Television. --- docs/artifacts.md | 33 ++++++ .../components/common/ArtifactCard.test.tsx | 25 ++++ .../src/components/common/ArtifactCard.tsx | 111 ++++++++++++++++++ .../src/components/common/MarkdownContent.tsx | 20 +++- frontend/src/hooks/useApi.ts | 48 ++++++++ frontend/src/utils/artifacts.test.ts | 47 ++++++++ frontend/src/utils/artifacts.ts | 90 ++++++++++++++ src/teamwork/main.py | 2 + src/teamwork/routers/__init__.py | 2 + src/teamwork/routers/artifacts.py | 55 +++++++++ tests/test_artifacts_proxy.py | 81 +++++++++++++ 11 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 docs/artifacts.md create mode 100644 frontend/src/components/common/ArtifactCard.test.tsx create mode 100644 frontend/src/components/common/ArtifactCard.tsx create mode 100644 frontend/src/utils/artifacts.test.ts create mode 100644 frontend/src/utils/artifacts.ts create mode 100644 src/teamwork/routers/artifacts.py create mode 100644 tests/test_artifacts_proxy.py diff --git a/docs/artifacts.md b/docs/artifacts.md new file mode 100644 index 0000000..18e68a8 --- /dev/null +++ b/docs/artifacts.md @@ -0,0 +1,33 @@ +# Artifacts — the viewer + +An agent can make pages for you and keep them updated: a plan, a table, a +chart, a small tool. Prax's `artifact_publish` is one example. The agent owns +them (writing, storing, versioning). **TeamWork only shows them.** + +- **In chat and notes.** A line that is exactly `[artifact:]` becomes a + live card. It reloads when the agent publishes a new version (polled every + 5 s), and can be opened full screen. +- **Where the page comes from.** `GET /api/artifacts/` proxies to the + agent (`{PRAX_URL}/teamwork/artifacts/`) behind TeamWork's login, like + the other agent panels. Unreachable → 503; unknown → 404. +- **How it is rendered, and why.** An artifact was written by an agent, + possibly while it was reading untrusted content, and TeamWork's origin holds + your session. So a page never runs on TeamWork's origin: + - it is loaded as `srcdoc` in an iframe with `sandbox="allow-scripts"` and + **no** `allow-same-origin`, so its scripts run in an opaque origin; + - a Content-Security-Policy injected first in its `` allows **no + network**, forms or base changes; the page's own tags can't loosen it, + because every policy applies; + - the frame sizes itself from a height message, accepted only from that + frame's own window. + + Verified in headless Chromium (2026-10-03). From inside the frame: + `parent.document`, `document.cookie` and `localStorage` all throw + `SecurityError`, `self.origin` is `"null"`, and `fetch` is refused by + `connect-src 'none'`. +- **Public links are the agent's business**, and need a person's decision. + TeamWork is never put behind a public tunnel (see + [security/exposure.md](security/exposure.md)). + +Idea credit: Telepath's Television +([comparison](comparisons/television.md)). diff --git a/frontend/src/components/common/ArtifactCard.test.tsx b/frontend/src/components/common/ArtifactCard.test.tsx new file mode 100644 index 0000000..1d35d6c --- /dev/null +++ b/frontend/src/components/common/ArtifactCard.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { act, render } from '@testing-library/react'; +import { ArtifactFrame } from './ArtifactCard'; + +describe('ArtifactFrame', () => { + it('runs the page sandboxed: scripts yes, TeamWork\'s origin no', () => { + const { container } = render(); + const frame = container.querySelector('iframe')!; + expect(frame.getAttribute('sandbox')).toBe('allow-scripts'); + expect(frame.getAttribute('sandbox')).not.toContain('allow-same-origin'); + expect(frame.getAttribute('srcdoc')).toContain('Content-Security-Policy'); + expect(frame.getAttribute('srcdoc')).toContain('

Plan

'); + expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer'); + }); + + it('ignores size messages from any window but its own', () => { + const { container } = render(); + const frame = container.querySelector('iframe')!; + const before = frame.style.height; + act(() => { + window.dispatchEvent(new MessageEvent('message', { data: { tvArtifactHeight: 9999 }, source: window })); + }); + expect(frame.style.height).toBe(before); + }); +}); diff --git a/frontend/src/components/common/ArtifactCard.tsx b/frontend/src/components/common/ArtifactCard.tsx new file mode 100644 index 0000000..fd4aab6 --- /dev/null +++ b/frontend/src/components/common/ArtifactCard.tsx @@ -0,0 +1,111 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { clsx } from 'clsx'; +import { Maximize2, Minimize2, LayoutTemplate } from 'lucide-react'; +import { useArtifact, useArtifactMeta } from '@/hooks/useApi'; +import { prepareArtifactDocument } from '@/utils/artifacts'; + +/** + * A page the agent made, rendered in a sandboxed frame: `allow-scripts` and + * NOT `allow-same-origin`, so it runs in an opaque origin and cannot touch + * TeamWork's session, storage or /api/*; its CSP allows no network. See + * utils/artifacts.ts. + */ +export function ArtifactFrame({ html, title, maxHeight }: { html: string; title: string; maxHeight?: number }) { + const frameRef = useRef(null); + const [height, setHeight] = useState(240); + const doc = useMemo(() => prepareArtifactDocument(html), [html]); + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + // Only this frame's own window may size it, and only with a number. + if (event.source !== frameRef.current?.contentWindow) return; + const reported = (event.data as { tvArtifactHeight?: unknown })?.tvArtifactHeight; + if (typeof reported === 'number' && Number.isFinite(reported)) { + setHeight(Math.max(80, Math.min(reported, 20_000))); + } + }; + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, []); + + return ( +