From c27f71791ca0c91ebd77fd96ba084be76fac4fe6 Mon Sep 17 00:00:00 2001 From: Berry Wahlberg <40695099+BerryUIKI@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:19:42 +0800 Subject: [PATCH 1/3] feat(artifacts): open artifacts in a dedicated window - build an initialization script that embeds the validated payload as JSON and parses it with JSON.parse - open or focus an artifact-* WebView loading artifact.html - expose open_artifact_window and add artifact retrieval to the repository and service - cover payload escaping and artifact lookup in tests --- .../src-tauri/src/commands/artifacts.rs | 15 +++ .../repositories/artifact_repository.rs | 18 +++ .../repositories/artifact_repository_test.rs | 24 ++++ apps/desktop/src-tauri/src/lib.rs | 1 + .../src/services/artifact_service.rs | 5 + .../src-tauri/src/windows/artifact_window.rs | 124 +++++++++++++++++- 6 files changed, 186 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/commands/artifacts.rs b/apps/desktop/src-tauri/src/commands/artifacts.rs index 7a0d25b0..77ec42e8 100644 --- a/apps/desktop/src-tauri/src/commands/artifacts.rs +++ b/apps/desktop/src-tauri/src/commands/artifacts.rs @@ -17,3 +17,18 @@ pub async fn list_artifacts( .list(workspace_id.as_deref(), task_id.as_deref()) .await } + +#[tauri::command] +pub async fn open_artifact_window( + artifact_id: String, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), AppError> { + let artifact = state + .artifact_service + .get(&artifact_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("Artifact '{}' not found", artifact_id)))?; + + crate::windows::artifact_window::open_artifact_window(&app, &artifact) +} diff --git a/apps/desktop/src-tauri/src/database/repositories/artifact_repository.rs b/apps/desktop/src-tauri/src/database/repositories/artifact_repository.rs index c7ab2c59..cc246249 100644 --- a/apps/desktop/src-tauri/src/database/repositories/artifact_repository.rs +++ b/apps/desktop/src-tauri/src/database/repositories/artifact_repository.rs @@ -61,6 +61,24 @@ impl ArtifactRepository { }) } + /// Gets an artifact by ID. + pub async fn get(&self, id: &str) -> Result, AppError> { + let row = sqlx::query_as::<_, ArtifactRow>( + r#" + SELECT id, task_id, workspace_id, artifact_type, title, input AS data, + status, created_at, updated_at + FROM artifacts + WHERE id = ? + "#, + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|e| AppError::Internal(format!("Failed to get artifact: {}", e)))?; + + Ok(row.map(|row| row.into())) + } + /// Lists artifacts, optionally filtered by workspace and task. pub async fn list( &self, diff --git a/apps/desktop/src-tauri/src/database/repositories/artifact_repository_test.rs b/apps/desktop/src-tauri/src/database/repositories/artifact_repository_test.rs index 176cc18a..d20e7259 100644 --- a/apps/desktop/src-tauri/src/database/repositories/artifact_repository_test.rs +++ b/apps/desktop/src-tauri/src/database/repositories/artifact_repository_test.rs @@ -65,6 +65,30 @@ mod tests { assert_eq!(artifacts[0].data, r#"{"columns":[],"rows":[]}"#); } + #[tokio::test] + async fn test_get_artifact() { + let repo = setup_repo().await; + + let created = repo + .create(Some("t1"), Some("w1"), "timeline", "Timeline", "{}") + .await + .expect("artifact should be created"); + + let fetched = repo + .get(&created.id) + .await + .expect("artifact should load") + .expect("artifact should exist"); + assert_eq!(fetched.title, "Timeline"); + assert_eq!(fetched.artifact_type, "timeline"); + + assert!(repo + .get("missing") + .await + .expect("lookup should succeed") + .is_none()); + } + #[tokio::test] async fn test_list_returns_all_when_filters_are_none() { let repo = setup_repo().await; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d07b59a6..29a69be7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -86,6 +86,7 @@ pub fn run() { commands::portfolio::list_portfolio_accounts, // Artifacts commands commands::artifacts::list_artifacts, + commands::artifacts::open_artifact_window, ]) .run(tauri::generate_context!()) .expect("failed to launch Investment OS"); diff --git a/apps/desktop/src-tauri/src/services/artifact_service.rs b/apps/desktop/src-tauri/src/services/artifact_service.rs index ff926ff4..118cbbd7 100644 --- a/apps/desktop/src-tauri/src/services/artifact_service.rs +++ b/apps/desktop/src-tauri/src/services/artifact_service.rs @@ -42,6 +42,11 @@ impl ArtifactService { Ok(artifacts) } + /// Gets an artifact by ID. + pub async fn get(&self, id: &str) -> Result, AppError> { + self.repo.get(id).await + } + /// Lists artifacts, optionally filtered by workspace and task. pub async fn list( &self, diff --git a/apps/desktop/src-tauri/src/windows/artifact_window.rs b/apps/desktop/src-tauri/src/windows/artifact_window.rs index eab975fa..427888da 100644 --- a/apps/desktop/src-tauri/src/windows/artifact_window.rs +++ b/apps/desktop/src-tauri/src/windows/artifact_window.rs @@ -1 +1,123 @@ -// Artifact window management. Placeholder. +// Artifact window management. +// +// Each artifact opens in its own WebView with a minimal capability set +// (`capabilities/artifact-window.json`, `core:default` only). The window loads +// a dedicated entry point that renders the payload with predefined components; +// no privileged application commands are imported by that entry. + +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +use crate::error::AppError; +use domain::artifact::Artifact; + +pub const ARTIFACT_WINDOW_PREFIX: &str = "artifact-"; + +/// Builds the initialization script that hands the artifact payload to the window. +/// +/// The payload is embedded as a JSON string literal and parsed with `JSON.parse`, +/// so quotes, newlines, or script-like content in the artifact cannot break out +/// of the assignment. +pub fn artifact_init_script(artifact: &Artifact) -> Result { + let data: serde_json::Value = serde_json::from_str(&artifact.data) + .map_err(|_| AppError::Internal("stored artifact payload is not valid JSON".to_string()))?; + + let payload = serde_json::json!({ + "id": artifact.id, + "title": artifact.title, + "artifact_type": artifact.artifact_type, + "data": data, + "created_at": artifact.created_at.to_rfc3339(), + }); + + let encoded = serde_json::to_string(&payload) + .map_err(|error| AppError::Internal(format!("failed to encode artifact: {error}")))?; + let literal = serde_json::to_string(&encoded) + .map_err(|error| AppError::Internal(format!("failed to quote artifact: {error}")))?; + + Ok(format!( + "window.__ALPHAFORGE_ARTIFACT__ = JSON.parse({literal});" + )) +} + +/// Opens an artifact window, or focuses it when it is already open. +pub fn open_artifact_window(app: &AppHandle, artifact: &Artifact) -> Result<(), AppError> { + let label = format!("{ARTIFACT_WINDOW_PREFIX}{}", artifact.id); + + if let Some(window) = app.get_webview_window(&label) { + window.show().map_err(|error| { + AppError::Internal(format!("failed to show artifact window: {error}")) + })?; + window.set_focus().map_err(|error| { + AppError::Internal(format!("failed to focus artifact window: {error}")) + })?; + return Ok(()); + } + + let script = artifact_init_script(artifact)?; + + WebviewWindowBuilder::new(app, label, WebviewUrl::App("artifact.html".into())) + .title(format!("Artifact — {}", artifact.title)) + .inner_size(1000.0, 720.0) + .min_inner_size(640.0, 480.0) + .resizable(true) + .initialization_script(&script) + .build() + .map_err(|error| AppError::Internal(format!("failed to open artifact window: {error}")))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::*; + use domain::artifact::ArtifactStatus; + + fn artifact(title: &str, data: &str) -> Artifact { + let now = Utc::now(); + Artifact { + id: "artifact-1".to_string(), + task_id: Some("task-1".to_string()), + workspace_id: Some("ws-1".to_string()), + artifact_type: "timeline".to_string(), + title: title.to_string(), + data: data.to_string(), + status: ArtifactStatus::Completed, + created_at: now, + updated_at: now, + } + } + + #[test] + fn test_init_script_escapes_quotes_and_newlines() { + let script = artifact_init_script(&artifact( + "A \"quoted\" title\nwith newline", + r#"{"events":[{"date":"2026-01-01","title":"Start"}]}"#, + )) + .expect("script should build"); + + let literal = script + .strip_prefix("window.__ALPHAFORGE_ARTIFACT__ = JSON.parse(") + .and_then(|rest| rest.strip_suffix(");")) + .expect("script should have the expected shape"); + + assert!(!literal.contains('\n')); + + let encoded: String = + serde_json::from_str(literal).expect("literal should be a JSON string"); + let payload: serde_json::Value = + serde_json::from_str(&encoded).expect("payload should be JSON"); + + assert_eq!(payload["title"], "A \"quoted\" title\nwith newline"); + assert_eq!(payload["artifact_type"], "timeline"); + assert_eq!(payload["data"]["events"][0]["title"], "Start"); + } + + #[test] + fn test_init_script_rejects_invalid_payload() { + let result = artifact_init_script(&artifact("Title", "not json")); + + assert!(matches!(result, Err(AppError::Internal(_)))); + } +} From 7d5b65c65032ca0fdf992b8198bade8dbe911522 Mon Sep 17 00:00:00 2001 From: Berry Wahlberg <40695099+BerryUIKI@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:19:46 +0800 Subject: [PATCH 2/3] feat(artifacts): render artifacts in the standalone window entry - add the artifact.html entry and window component, bundled separately from the app shell - add an Open in window action with error feedback - build both entries through Vite multi-page input - test the window component, page action, and updated flows --- apps/desktop/artifact.html | 27 +++++++++ apps/desktop/src/artifact-main.tsx | 10 ++++ .../src/artifact/ArtifactWindow.test.tsx | 39 +++++++++++++ apps/desktop/src/artifact/ArtifactWindow.tsx | 58 +++++++++++++++++++ .../artifacts/components/ArtifactDetail.tsx | 37 ++++++++---- .../features/artifacts/hooks/useArtifacts.ts | 11 +++- apps/desktop/src/features/artifacts/index.ts | 2 +- apps/desktop/src/lib/desktop-api/artifacts.ts | 7 +++ .../pages/artifacts/ArtifactsPage.test.tsx | 30 +++++++++- .../src/pages/artifacts/ArtifactsPage.tsx | 22 ++++++- apps/desktop/vite.config.ts | 6 ++ 11 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/artifact.html create mode 100644 apps/desktop/src/artifact-main.tsx create mode 100644 apps/desktop/src/artifact/ArtifactWindow.test.tsx create mode 100644 apps/desktop/src/artifact/ArtifactWindow.tsx diff --git a/apps/desktop/artifact.html b/apps/desktop/artifact.html new file mode 100644 index 00000000..2b088820 --- /dev/null +++ b/apps/desktop/artifact.html @@ -0,0 +1,27 @@ + + + + + + Artifact — Investment OS + + + +
+ + + diff --git a/apps/desktop/src/artifact-main.tsx b/apps/desktop/src/artifact-main.tsx new file mode 100644 index 00000000..ccc87cf7 --- /dev/null +++ b/apps/desktop/src/artifact-main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { ArtifactWindow } from "./artifact/ArtifactWindow"; +import "./styles/globals.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/desktop/src/artifact/ArtifactWindow.test.tsx b/apps/desktop/src/artifact/ArtifactWindow.test.tsx new file mode 100644 index 00000000..12edfe5d --- /dev/null +++ b/apps/desktop/src/artifact/ArtifactWindow.test.tsx @@ -0,0 +1,39 @@ +// Tests for the standalone artifact window entry. + +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import { ArtifactWindow } from "./ArtifactWindow"; + +describe("ArtifactWindow", () => { + afterEach(() => { + cleanup(); + delete window.__ALPHAFORGE_ARTIFACT__; + }); + + it("renders the injected artifact payload", () => { + window.__ALPHAFORGE_ARTIFACT__ = { + id: "artifact-1", + title: "Tesla vs Nvidia", + artifact_type: "comparison-table", + data: { + columns: [{ key: "pe", label: "P/E" }], + rows: [{ label: "Tesla", values: { pe: 28.5 } }], + }, + created_at: "2026-01-01T00:00:00Z", + }; + + render(); + + expect(screen.getByText("Tesla vs Nvidia")).toBeInTheDocument(); + expect(screen.getByText("Tesla")).toBeInTheDocument(); + expect(screen.getByText("28.5")).toBeInTheDocument(); + }); + + it("shows a fallback when no payload was injected", () => { + render(); + + expect(screen.getByText("No artifact data was provided.")).toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/src/artifact/ArtifactWindow.tsx b/apps/desktop/src/artifact/ArtifactWindow.tsx new file mode 100644 index 00000000..5961f8ef --- /dev/null +++ b/apps/desktop/src/artifact/ArtifactWindow.tsx @@ -0,0 +1,58 @@ +// Standalone artifact window. +// +// This entry is bundled separately from the main application and does not +// import desktopApi or app-shell code. It renders only the injected artifact +// payload with predefined components. + +import type { Artifact } from "@/lib/desktop-api"; +import { ArtifactRenderer } from "@/features/artifacts/components/ArtifactRenderer"; + +interface ArtifactWindowPayload { + id: string; + title: string; + artifact_type: string; + data: unknown; + created_at: string; +} + +declare global { + interface Window { + __ALPHAFORGE_ARTIFACT__?: ArtifactWindowPayload; + } +} + +export function ArtifactWindow() { + const payload = window.__ALPHAFORGE_ARTIFACT__; + + if (!payload) { + return ( +
No artifact data was provided.
+ ); + } + + const artifact: Artifact = { + id: payload.id, + task_id: null, + workspace_id: null, + artifact_type: payload.artifact_type, + title: payload.title, + data: JSON.stringify(payload.data), + status: "completed", + created_at: payload.created_at, + updated_at: payload.created_at, + }; + + return ( +
+
+

{artifact.title}

+

+ Created {new Date(artifact.created_at).toLocaleString()} +

+
+
+ +
+
+ ); +} diff --git a/apps/desktop/src/features/artifacts/components/ArtifactDetail.tsx b/apps/desktop/src/features/artifacts/components/ArtifactDetail.tsx index d54d9894..5354102d 100644 --- a/apps/desktop/src/features/artifacts/components/ArtifactDetail.tsx +++ b/apps/desktop/src/features/artifacts/components/ArtifactDetail.tsx @@ -1,6 +1,6 @@ // Artifact detail with a predefined renderer. -import { X } from "lucide-react"; +import { ExternalLink, X } from "lucide-react"; import { Badge } from "@investment-os/ui"; import type { Artifact } from "@/lib/desktop-api/artifacts"; import { ArtifactRenderer } from "./ArtifactRenderer"; @@ -9,9 +9,10 @@ import { artifactTypeLabel } from "./artifactLabels"; interface ArtifactDetailProps { artifact: Artifact; onClose?: () => void; + onOpen?: () => void; } -export function ArtifactDetail({ artifact, onClose }: ArtifactDetailProps) { +export function ArtifactDetail({ artifact, onClose, onOpen }: ArtifactDetailProps) { return (
@@ -24,16 +25,28 @@ export function ArtifactDetail({ artifact, onClose }: ArtifactDetailProps) {
- {onClose && ( - - )} +
+ {onOpen && ( + + )} + {onClose && ( + + )} +
diff --git a/apps/desktop/src/features/artifacts/hooks/useArtifacts.ts b/apps/desktop/src/features/artifacts/hooks/useArtifacts.ts index a91a7230..b0512fa0 100644 --- a/apps/desktop/src/features/artifacts/hooks/useArtifacts.ts +++ b/apps/desktop/src/features/artifacts/hooks/useArtifacts.ts @@ -1,6 +1,6 @@ // Hooks for artifacts. -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { desktopApi, type ArtifactFilters } from "@/lib/desktop-api"; export const ARTIFACT_KEYS = { @@ -18,3 +18,12 @@ export function useArtifacts(filters: ArtifactFilters = {}) { queryFn: () => desktopApi.artifacts.listArtifacts(filters), }); } + +/** + * Hook to open an artifact in its own window. + */ +export function useOpenArtifactWindow() { + return useMutation({ + mutationFn: (artifactId: string) => desktopApi.artifacts.openArtifactWindow(artifactId), + }); +} diff --git a/apps/desktop/src/features/artifacts/index.ts b/apps/desktop/src/features/artifacts/index.ts index da6f3d12..a84d181b 100644 --- a/apps/desktop/src/features/artifacts/index.ts +++ b/apps/desktop/src/features/artifacts/index.ts @@ -9,4 +9,4 @@ export type { ParsedArtifactData, TimelineData, } from "./components/parseArtifactData"; -export { ARTIFACT_KEYS, useArtifacts } from "./hooks/useArtifacts"; +export { ARTIFACT_KEYS, useArtifacts, useOpenArtifactWindow } from "./hooks/useArtifacts"; diff --git a/apps/desktop/src/lib/desktop-api/artifacts.ts b/apps/desktop/src/lib/desktop-api/artifacts.ts index ebfe4009..fc455492 100644 --- a/apps/desktop/src/lib/desktop-api/artifacts.ts +++ b/apps/desktop/src/lib/desktop-api/artifacts.ts @@ -34,3 +34,10 @@ export async function listArtifacts(filters: ArtifactFilters = {}): Promise { + return invoke("open_artifact_window", { artifactId }); +} diff --git a/apps/desktop/src/pages/artifacts/ArtifactsPage.test.tsx b/apps/desktop/src/pages/artifacts/ArtifactsPage.test.tsx index 57b19f0d..41f16c18 100644 --- a/apps/desktop/src/pages/artifacts/ArtifactsPage.test.tsx +++ b/apps/desktop/src/pages/artifacts/ArtifactsPage.test.tsx @@ -1,11 +1,12 @@ // Tests for the Artifacts page. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import "@testing-library/jest-dom"; import { invoke } from "@tauri-apps/api/core"; +import { ToastProvider } from "@/components/common/ToastProvider"; import { ArtifactsPage } from "./ArtifactsPage"; import type { Artifact } from "@/lib/desktop-api"; @@ -38,7 +39,9 @@ function renderPage() { return render( - + + + , ); } @@ -94,4 +97,27 @@ describe("ArtifactsPage", () => { expect(await screen.findByText("Failed to load artifacts")).toBeInTheDocument(); }); + + it("opens an artifact window", async () => { + vi.mocked(invoke).mockImplementation(((command: string) => { + if (command === "list_artifacts") { + return Promise.resolve([comparisonArtifact]); + } + if (command === "open_artifact_window") { + return Promise.resolve(null); + } + return Promise.reject(new Error(`Unexpected command: ${command}`)); + }) as unknown as typeof invoke); + + renderPage(); + + fireEvent.click(await screen.findByText("Tesla vs Nvidia")); + fireEvent.click(await screen.findByRole("button", { name: /open in window/i })); + + await waitFor(() => { + expect(vi.mocked(invoke)).toHaveBeenCalledWith("open_artifact_window", { + artifactId: "artifact-1", + }); + }); + }); }); diff --git a/apps/desktop/src/pages/artifacts/ArtifactsPage.tsx b/apps/desktop/src/pages/artifacts/ArtifactsPage.tsx index 090e109b..12a9a6b7 100644 --- a/apps/desktop/src/pages/artifacts/ArtifactsPage.tsx +++ b/apps/desktop/src/pages/artifacts/ArtifactsPage.tsx @@ -5,11 +5,20 @@ import { Boxes } from "lucide-react"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { EmptyState } from "@/components/common/EmptyState"; import { ErrorState } from "@/components/common/ErrorState"; -import { ArtifactDetail, ArtifactList, useArtifacts } from "@/features/artifacts"; +import { useToast } from "@/components/common/useToast"; +import { + ArtifactDetail, + ArtifactList, + useArtifacts, + useOpenArtifactWindow, +} from "@/features/artifacts"; +import { getErrorMessage } from "@/lib/errors"; import type { Artifact } from "@/lib/desktop-api"; export function ArtifactsPage() { + const { toast } = useToast(); const { data: artifacts, isLoading, error, refetch } = useArtifacts(); + const openArtifact = useOpenArtifactWindow(); const [selectedArtifactId, setSelectedArtifactId] = useState(null); const selectedArtifact = @@ -55,6 +64,17 @@ export function ArtifactsPage() { onClose={() => { setSelectedArtifactId(null); }} + onOpen={() => { + openArtifact.mutate(selectedArtifact.id, { + onError: (error) => { + toast({ + title: "Failed to open artifact window", + description: getErrorMessage(error), + variant: "destructive", + }); + }, + }); + }} /> )}
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index d717c74e..816939e0 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -20,5 +20,11 @@ export default defineConfig({ target: "esnext", minify: !process.env.TAURI_DEBUG ? "esbuild" : false, sourcemap: !!process.env.TAURI_DEBUG, + rollupOptions: { + input: { + main: path.resolve(__dirname, "index.html"), + artifact: path.resolve(__dirname, "artifact.html"), + }, + }, }, }); From 140f524f8c648662dc9a3af6ee9ab532607e73e0 Mon Sep 17 00:00:00 2001 From: Berry Wahlberg <40695099+BerryUIKI@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:20:11 +0800 Subject: [PATCH 3/3] docs: document artifact windows and permission isolation - describe the dedicated artifact entry, payload injection, and core:default capability - note the residual app-command ACL limitation - mark temporary window creation and permission isolation on the roadmap --- CHANGELOG.md | 1 + CLAUDE.md | 3 ++- docs/ARCHITECTURE.md | 3 ++- docs/ARTIFACT_SYSTEM.md | 5 +++-- docs/MILESTONE_ROADMAP.md | 8 ++++---- docs/SECURITY.md | 1 + 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e680df..3bc9d0f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `alphaforge` CLI for workspaces and agent tasks, with human/JSON output, live progress, timeout, cancellation, and retry. - Agent context and tools: workspace and recent-task context in prompts, an OpenAI tool-calling loop (max 4 rounds), and read-only tools for listing/searching tasks, reading task output, and arithmetic. - Agent artifacts: validated comparison-table and timeline outputs are generated with tasks, persisted in SQLite, and rendered on the Artifacts page with predefined React components. +- Artifact windows: validated artifacts can open in a dedicated minimal-permission WebView (`core:default`) with the payload injected as parsed JSON. ### Fixed - Root `pnpm typecheck` now uses the pnpm workspace instead of a non-existent npm workspace. diff --git a/CLAUDE.md b/CLAUDE.md index b16577cd..ec090a80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,7 +136,8 @@ React component - `agent/runtime.rs` defines `AgentRuntime`, `ProgressReporter`, and a simulated `LocalDemoRuntime` for tests/demos; `agent/openai_runtime.rs` is the production runtime — it assembles context, runs up to 4 OpenAI tool-calling rounds against a `ToolRegistry`, validates the final JSON against `ResearchOutput`, and persists canonical JSON. `agent/context.rs` supplies the workspace name and summaries of recent completed tasks; `agent/tools/` provides `list_tasks`, `search_tasks`, `get_task_output`, and `calculate`. Tool execution errors are returned to the model as `{"error": ...}` so it can self-correct. `agent/events.rs` publishes `agent-task-event` through an injected `EventPublisher` (Tauri-backed in production). - The frontend `useAgentTaskEvents` hook subscribes to `agent-task-event` and invalidates agent queries so task status and results update live. `AgentWorkspacePanel` (rendered by `TodayPage` once a workspace is selected) combines task creation, start/cancel actions, and a detail view with the execution timeline, output, and error. - Agent artifacts: `agent/openai_runtime.rs` accepts an optional `artifacts` array in the final JSON; `artifacts/schema.rs` validates the `comparison-table` and `timeline` schemas, and `services/artifact_service.rs` persists them (title/workspace on the row, payload in `artifacts.input`). `commands/artifacts.rs` exposes `list_artifacts` with workspace/task filters, and `features/artifacts` renders them with predefined React components in the main window. -- Artifact windows, the plugin runtime, and the remaining built-in artifact types are still scaffolding. Rendering never injects model-generated HTML; treat artifact payloads as untrusted data even after Rust validation. The intended contracts are documented in `docs/ARTIFACT_SYSTEM.md`, `docs/PLUGIN_SPEC.md`, and the manifests in `plugins/*/manifest.json` (`id`, `name`, `version`, `entry`, `inputSchema`, `permissions`, `window`). +- Artifacts can also open in a dedicated WebView via `open_artifact_window` (`windows/artifact_window.rs`). The window loads `artifact.html` / `src/artifact-main.tsx`, receives the validated payload through an initialization script as a JSON string parsed with `JSON.parse`, and runs with only `core:default` (`capabilities/artifact-window.json`). The artifact entry must not import `desktopApi` or app-shell code. +- The plugin runtime and the remaining built-in artifact types are still scaffolding. Rendering never injects model-generated HTML; treat artifact payloads as untrusted data even after Rust validation. The intended contracts are documented in `docs/ARTIFACT_SYSTEM.md`, `docs/PLUGIN_SPEC.md`, and the manifests in `plugins/*/manifest.json` (`id`, `name`, `version`, `entry`, `inputSchema`, `permissions`, `window`). - Treat agent output as untrusted: validate it against a schema and render it with predefined React plugins. Never inject agent-generated HTML into the privileged main-window DOM. - Artifact windows receive only `core:default` (`capabilities/artifact-window.json`); the main window additionally gets opener, shell-open, and store permissions (`capabilities/main-window.json`). - The research, journal, portfolio, and artifact list commands (`commands/research.rs`, `journal.rs`, `portfolio.rs`, `artifacts.rs`) are stubs returning empty collections. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 413e9481..d2b20e6a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -165,7 +165,7 @@ since the desktop app may be open at the same time. | `list_research_documents` | research | Stub — returns [] | | `list_theses` | journal | Stub — returns [] | | `list_portfolio_accounts` | portfolio | Stub — returns [] | -| `list_artifacts` (optional `workspace_id` / `task_id` filters) | artifacts | ✅ Active (SQLite; comparison-table and timeline renderers) | +| `list_artifacts` (optional `workspace_id` / `task_id` filters), `open_artifact_window` | artifacts | ✅ Active (SQLite; comparison-table and timeline renderers; dedicated minimal-permission WebView) | ## Database @@ -178,6 +178,7 @@ since the desktop app may be open at the same time. ## Security - API keys: stored in the OS keychain (`security/credentials.rs`, `keyring` crate); status is exposed to React, the key is never returned +- Artifact windows: dedicated `artifact.html` entry with only `core:default`; validated JSON is injected through an initialization script and parsed with `JSON.parse` - Artifact windows: Minimal permissions (`capabilities/artifact-window.json`) - Main window: Controlled permissions (`capabilities/main-window.json`) - Input validation: Will use Zod (TS) + manual validation (Rust) diff --git a/docs/ARTIFACT_SYSTEM.md b/docs/ARTIFACT_SYSTEM.md index efb4ddf2..33d1d625 100644 --- a/docs/ARTIFACT_SYSTEM.md +++ b/docs/ARTIFACT_SYSTEM.md @@ -31,14 +31,15 @@ User submits task ## Current Implementation (MVP) -The first slice renders artifacts inside the main window rather than a separate WebView: +The MVP supports main-window rendering and dedicated artifact windows: - The agent may include an `artifacts` array in its final structured output; each entry has `type`, `title`, and `data`. - `artifacts/schema.rs` validates `comparison-table` and `timeline` payloads; invalid artifacts are discarded before persistence. - `ArtifactService` stores validated artifacts and `list_artifacts` exposes them with workspace/task filters. - `features/artifacts` renders the payloads with predefined React components. No model-generated HTML is used. +- `open_artifact_window` opens a dedicated WebView (`artifact.html`) with only `core:default`. The payload is injected by an initialization script as a JSON string parsed with `JSON.parse`; the artifact entry does not import `desktopApi` or the app shell. -Separate artifact windows, the plugin SDK, and the remaining artifact types are still future work. +Residual risk: Tauri application commands are not ACL-gated per window, so isolation relies on the artifact entry containing no privileged code paths and on the minimal capability set. The plugin SDK and the remaining artifact types are still future work. ## Lifecycle diff --git a/docs/MILESTONE_ROADMAP.md b/docs/MILESTONE_ROADMAP.md index 46023645..ae8739c9 100644 --- a/docs/MILESTONE_ROADMAP.md +++ b/docs/MILESTONE_ROADMAP.md @@ -302,8 +302,8 @@ Enable Agents to create interactive research experiences. #### Artifact Runtime - [x] Structured output schema (comparison-table, timeline) - [x] Renderer system (predefined React components in the main window) -- [ ] Temporary window creation -- [ ] Permission isolation +- [x] Temporary window creation (dedicated `artifact.html` WebView) +- [x] Permission isolation (only `core:default`; dedicated entry with no privileged imports) #### Built-in Artifacts - [x] Comparison table @@ -312,8 +312,8 @@ Enable Agents to create interactive research experiences. - [ ] Valuation model - [ ] Risk dashboard -Artifacts currently render inside the main window with predefined components; -separate artifact WebViews and the plugin runtime remain future work. +Artifacts render in the main window and can open in a dedicated minimal-permission +WebView. The plugin runtime and remaining built-in artifact types remain future work. ### Artifact Flow ```text diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 9dde2d36..590ceaef 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -67,6 +67,7 @@ Plugins are internal-only in the MVP. Even so, every plugin is untrusted: - Tool results are treated as untrusted data: the system prompt tells the model not to follow instructions inside them, and tool arguments are validated against their JSON Schema before execution. - Agent-generated artifacts are validated against their input schema before rendering. - Artifacts are rendered by predefined React components in the main window; model-generated HTML is never injected. +- Artifact windows load a dedicated `artifact.html` entry with only `core:default`, receive validated JSON through an initialization script, and do not import `desktopApi` or app-shell code. - The agent cannot generate arbitrary code for execution. ## Input Validation