Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/artifacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 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:<id>]` 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/<id>` proxies to the
agent (`{PRAX_URL}/teamwork/artifacts/<id>`) 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 `<head>` 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.
Inside TeamWork an artifact is private only as long as TeamWork is: keep its
login on and reach it privately (see
[security/exposure.md](security/exposure.md)).

Idea credit: Telepath's Television
([comparison](comparisons/television.md)).
18 changes: 18 additions & 0 deletions docs/security/exposure.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@ guess is obscurity, not access control: URLs leak through browser history,
screenshots, shared links, logs and referrer headers, and tunnel hostnames are
scanned.

## Agents treat TeamWork as private — so it has to be

An agent connected to TeamWork shows you its work here without asking: files,
notes, pages it made, its terminal and browser. That is the right default only
because TeamWork is meant to be private. Prax, for one, enforces exactly this
split: anything in TeamWork is fine, but putting something on a public link
needs a person's explicit decision every time
([Prax: public exposure](https://github.com/praxagent/prax/blob/main/docs/security/public-exposure.md)).

That trust is something **you** provide when you deploy TeamWork. No agent can
check it from the inside. If TeamWork is reachable by people you don't trust,
everything an agent shows you here is reachable by them too. So:

- keep the login on (`INTERNAL_API_KEY`, below);
- reach TeamWork privately (loopback, `tailscale serve`, an SSH tunnel);
- if you do put it behind a public tunnel, follow the checklist at the end
first.

## What is open without a login

Out of the box, TeamWork has **no login** (`INTERNAL_API_KEY` is empty and
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/common/ArtifactCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ArtifactFrame html="<h1>Plan</h1>" title="Plan" />);
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('<h1>Plan</h1>');
expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer');
});

it('ignores size messages from any window but its own', () => {
const { container } = render(<ArtifactFrame html="<p>x</p>" title="x" />);
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);
});
});
111 changes: 111 additions & 0 deletions frontend/src/components/common/ArtifactCard.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLIFrameElement>(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 (
<iframe
ref={frameRef}
title={title}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
srcDoc={doc}
className="w-full block bg-white"
style={{ height: maxHeight ? Math.min(height, maxHeight) : height, border: 0 }}
/>
);
}

/** `[artifact:<id>]` in a message or note: the artifact, live, with a full-screen view. */
export function ArtifactCard({ id }: { id: string }) {
const meta = useArtifactMeta(id);
const page = useArtifact(id, meta.data?.version);
const [expanded, setExpanded] = useState(false);

useEffect(() => {
if (!expanded) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setExpanded(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [expanded]);

if (meta.isError) {
return (
<div className="my-2 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 text-sm text-gray-500">
Artifact <code>{id}</code> is not available.
</div>
);
}
const title = meta.data?.title ?? 'Artifact';
const header = (
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
<LayoutTemplate className="w-3.5 h-3.5 shrink-0" aria-hidden />
<span className="font-medium truncate">{title}</span>
{meta.data && <span className="text-gray-400 shrink-0">v{meta.data.version}</span>}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-auto p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label={expanded ? 'Close full screen' : 'Open full screen'}
title={expanded ? 'Close (Esc)' : 'Open full screen'}
>
{expanded ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</button>
</div>
);
const body = page.data
? <ArtifactFrame html={page.data.html} title={title} maxHeight={expanded ? undefined : 480} />
: <div className="p-4 text-center text-sm text-gray-500">Loading artifact…</div>;

return (
<>
<div className="my-2 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
{header}
<div className="max-h-[480px] overflow-auto">{!expanded && body}</div>
</div>
{expanded && (
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-2 md:p-8"
onClick={() => setExpanded(false)}
>
<div
className={clsx('w-full max-w-6xl h-full rounded-lg overflow-hidden flex flex-col',
'bg-white dark:bg-gray-900')}
onClick={(e) => e.stopPropagation()}
>
{header}
<div className="flex-1 overflow-auto">{body}</div>
</div>
</div>
)}
</>
);
}
20 changes: 19 additions & 1 deletion frontend/src/components/common/MarkdownContent.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ArtifactCard } from './ArtifactCard';
import { splitArtifactMarkers } from '@/utils/artifacts';
import ReactMarkdown from 'react-markdown';
import { clsx } from 'clsx';
import { ReactNode, createContext, useContext, useEffect, useId, useRef, useState } from 'react';
Expand Down Expand Up @@ -213,7 +215,23 @@ function preprocessContent(content: string): string {
* - @mentions
* - All standard markdown formatting
*/
export function MarkdownContent({ content, className, darkMode: darkModeProp, onToggleTask }: MarkdownContentProps) {
/**
* Markdown, plus the artifacts the agent shows with a line that is exactly
* `[artifact:<id>]` (rendered live, sandboxed — see ArtifactCard).
*/
export function MarkdownContent(props: MarkdownContentProps) {
const segments = splitArtifactMarkers(props.content);
if (!segments.some((s) => s.kind === 'artifact')) return <MarkdownBody {...props} />;
return (
<div className={props.className}>
{segments.map((s, i) => s.kind === 'artifact'
? <ArtifactCard key={`a${i}-${s.id}`} id={s.id} />
: <MarkdownBody key={`m${i}`} {...props} className={undefined} content={s.text} />)}
</div>
);
}

function MarkdownBody({ content, className, darkMode: darkModeProp, onToggleTask }: MarkdownContentProps) {
const storeDarkMode = useUIStore((s) => s.darkMode);
const darkMode = darkModeProp ?? storeDarkMode;
const processedContent = preprocessContent(content);
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2699,6 +2699,54 @@ export function useAgentPlan(enabled: boolean = true) {
}


// Artifacts — pages the agent makes and keeps updating (see utils/artifacts.ts)

export interface ArtifactMeta {
id: string;
title: string;
version: number;
created_at: string;
updated_at: string;
size: number;
space?: string;
}

export interface Artifact extends ArtifactMeta {
html: string;
}

/** The artifact's manifest, polled so a new version shows up on its own. */
export function useArtifactMeta(id: string, enabled: boolean = true) {
return useQuery({
queryKey: ['artifact-meta', id],
queryFn: () => fetchJson<ArtifactMeta>(`/artifacts/${encodeURIComponent(id)}?meta=1`),
enabled: enabled && !!id,
refetchInterval: 5_000,
retry: false,
});
}

/** The full page for one version; refetched only when the version changes. */
export function useArtifact(id: string, version: number | undefined) {
return useQuery({
queryKey: ['artifact', id, version],
queryFn: () => fetchJson<Artifact>(`/artifacts/${encodeURIComponent(id)}`),
enabled: !!id && version !== undefined,
staleTime: Infinity,
retry: false,
});
}

export function useArtifactList(enabled: boolean = true) {
return useQuery({
queryKey: ['artifacts'],
queryFn: () => fetchJson<{ artifacts: ArtifactMeta[] }>('/artifacts'),
enabled,
refetchInterval: 15_000,
retry: false,
});
}

// Claude Code Sessions

export interface ClaudeCodeSession {
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/utils/artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { ARTIFACT_CSP, prepareArtifactDocument, splitArtifactMarkers } from './artifacts';

describe('prepareArtifactDocument', () => {
it('puts the CSP first in an existing <head>', () => {
const doc = prepareArtifactDocument('<!doctype html><html><head><title>x</title></head><body>hi</body></html>');
const head = doc.indexOf('<head>');
expect(doc.slice(head + '<head>'.length).startsWith('<meta http-equiv="Content-Security-Policy"')).toBe(true);
expect(doc.indexOf('<meta')).toBeLessThan(doc.indexOf('<title>'));
});

it('adds a <head> when the page has only <html>', () => {
const doc = prepareArtifactDocument('<html><body>hi</body></html>');
expect(doc).toMatch(/<html><head><meta http-equiv="Content-Security-Policy"/);
});

it('wraps a fragment in a full document', () => {
const doc = prepareArtifactDocument('<h1>hi</h1>');
expect(doc.startsWith('<!doctype html><html><head><meta')).toBe(true);
expect(doc).toContain('<body><h1>hi</h1></body>');
});

it('allows no network', () => {
expect(ARTIFACT_CSP).toContain("default-src 'none'");
expect(ARTIFACT_CSP).toContain("connect-src 'none'");
expect(ARTIFACT_CSP).not.toMatch(/https?:/);
});
});

describe('splitArtifactMarkers', () => {
it('splits on lines that are exactly a marker', () => {
expect(splitArtifactMarkers('Here it is:\n[artifact:plan-1a2b3c]\nTell me what to change.')).toEqual([
{ kind: 'markdown', text: 'Here it is:' },
{ kind: 'artifact', id: 'plan-1a2b3c' },
{ kind: 'markdown', text: 'Tell me what to change.' },
]);
});

it('leaves inline mentions and code blocks alone', () => {
const text = 'see [artifact:a-1] inline\n```\n[artifact:b-2]\n```';
expect(splitArtifactMarkers(text)).toEqual([{ kind: 'markdown', text }]);
});

it('rejects ids that are not artifact ids', () => {
expect(splitArtifactMarkers('[artifact:../etc]')).toEqual([{ kind: 'markdown', text: '[artifact:../etc]' }]);
});
});
Loading
Loading