diff --git a/CHANGELOG.md b/CHANGELOG.md index ce22430b3..fbcd9ab79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ ### Fixed +- Draw segmented controls in the properties panel at the height of the fields beside them. - Keep saving AI chat history in Safari Private Browsing after a message with an image or a reply that changed the document. Safari cannot store image data that way in a private window, so the conversation stopped saving from that point and showed "Chat history could not be saved". - `figma.combineAsVariants` derives variant properties from components named as Figma names variants, such as `State=On, Size=Large`, as Combine as variants in the editor now does too; before, only slash-separated names gave the set any properties. - HTML and Tailwind export place layers of frames without auto layout at their coordinates instead of stacking them, leave the size of hugging auto layout frames and auto-sizing text to their content, and round ellipses. @@ -181,6 +182,7 @@ ### Security +- Refuse writes from the desktop app to places where a written file would run: login items and startup folders, PowerShell profiles, global package and executable folders such as Homebrew, `/usr/local`, npm, Volta, and Scoop, and the MCP discovery files coding agents trust. On Windows, hidden files and folders such as shell profiles and agent settings are now off-limits too, as on macOS and Linux, except a document you open there yourself, which can still be saved. - Limit the programs the desktop app may start to the exact command lines of the supported coding agents, the MCP server, and the Harness companion. On Windows the app could run any command through `cmd /c`, so any code running in the editor's webview could start arbitrary programs. - Update the desktop app to Tauri 2.12, which binds large IPC channel responses to the webview that requested them instead of letting another webview fetch them (GHSA-w28w-mhc8-qvjv). - Install a desktop update only when its signature names the version the update server announces, so a tampered update manifest cannot pair a newer version number with an older signed build. diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index 8bacb5d2c..afab90f78 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -6,6 +6,7 @@ Check `desktop/Cargo.toml`, `desktop/capabilities/**`, and `desktop/tauri.conf.j - Dev tools: add or use a menu item to toggle them; do not rely on keyboard shortcuts. - `desktop/src/credentials.rs` stores secrets in the native system credential store; failures must surface, never fall back to browser or plaintext storage (`src/AGENTS.md`, Settings). - ACP and harness process changes require checking `desktop/capabilities/**`. +- The fs scope allows documents anywhere but denies, in the global `fs:scope`, every place where a written file would run, and `requireLiteralLeadingDot` keeps hidden files out of `**` on every platform; a new write path must not reopen either (`tests/e2e/native/fs-scope.spec.ts`). - A `shell:allow-spawn` entry pins the whole command line: no `"args": true`, and a Windows `.cmd` shim runs through its own `cmd-` entry with fixed `/c …` arguments, which `resolvePlatformCommand` selects (`tests/engine/tauri/command.test.ts`). - `desktop/generated/menu.json` is produced by `bun run generate:tauri-menu` from `src/app/shell/menu/schema.ts`; do not edit or import it directly. - Run `bun run generate:icons --target desktop` before direct Cargo checks; native icons are generated, not committed. diff --git a/desktop/capabilities/default.json b/desktop/capabilities/default.json index 234640066..fe39ba63f 100644 --- a/desktop/capabilities/default.json +++ b/desktop/capabilities/default.json @@ -15,6 +15,42 @@ "clipboard-manager:allow-read-text", "clipboard-manager:allow-write-html", "clipboard-manager:allow-write-text", + "fs:deny-default", + { + "identifier": "fs:scope", + "deny": [ + { "path": "$HOME/Library/LaunchAgents" }, + { "path": "$HOME/Library/LaunchAgents/**" }, + { "path": "$DATA/Microsoft/Windows/Start Menu/Programs/Startup" }, + { "path": "$DATA/Microsoft/Windows/Start Menu/Programs/Startup/**" }, + { "path": "$DOCUMENT/PowerShell" }, + { "path": "$DOCUMENT/PowerShell/**" }, + { "path": "$DOCUMENT/WindowsPowerShell" }, + { "path": "$DOCUMENT/WindowsPowerShell/**" }, + { "path": "/usr/local" }, + { "path": "/usr/local/**" }, + { "path": "/opt/homebrew" }, + { "path": "/opt/homebrew/**" }, + { "path": "$HOME/bin" }, + { "path": "$HOME/bin/**" }, + { "path": "$HOME/n" }, + { "path": "$HOME/n/**" }, + { "path": "$DATA/npm" }, + { "path": "$DATA/npm/**" }, + { "path": "$DATA/nvm" }, + { "path": "$DATA/nvm/**" }, + { "path": "$DATA/fnm" }, + { "path": "$DATA/fnm/**" }, + { "path": "$LOCALDATA/Volta" }, + { "path": "$LOCALDATA/Volta/**" }, + { "path": "$LOCALDATA/Programs" }, + { "path": "$LOCALDATA/Programs/**" }, + { "path": "$LOCALDATA/Microsoft/WindowsApps" }, + { "path": "$LOCALDATA/Microsoft/WindowsApps/**" }, + { "path": "$HOME/scoop" }, + { "path": "$HOME/scoop/**" } + ] + }, { "identifier": "fs:allow-read-file", "allow": [{ "path": "**" }] @@ -25,7 +61,13 @@ }, { "identifier": "fs:allow-open", - "allow": [{ "path": "**" }] + "allow": [{ "path": "**" }], + "deny": [ + { "path": "$HOME/Library/Application Support/OpenPencil/mcp.json" }, + { "path": "$LOCALDATA/OpenPencil/mcp.json" }, + { "path": "$RUNTIME/openpencil/mcp.json" }, + { "path": "$HOME/.openpencil/mcp.json" } + ] }, "fs:allow-fstat", "fs:allow-seek", @@ -41,7 +83,13 @@ }, { "identifier": "fs:allow-write-file", - "allow": [{ "path": "**" }] + "allow": [{ "path": "**" }], + "deny": [ + { "path": "$HOME/Library/Application Support/OpenPencil/mcp.json" }, + { "path": "$LOCALDATA/OpenPencil/mcp.json" }, + { "path": "$RUNTIME/openpencil/mcp.json" }, + { "path": "$HOME/.openpencil/mcp.json" } + ] }, { "identifier": "fs:allow-exists", @@ -58,7 +106,13 @@ }, { "identifier": "fs:allow-remove", - "allow": [{ "path": "**" }] + "allow": [{ "path": "**" }], + "deny": [ + { "path": "$HOME/Library/Application Support/OpenPencil/mcp.json" }, + { "path": "$LOCALDATA/OpenPencil/mcp.json" }, + { "path": "$RUNTIME/openpencil/mcp.json" }, + { "path": "$HOME/.openpencil/mcp.json" } + ] }, { "identifier": "fs:allow-watch", diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 044b96e23..c2bc23c9a 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -65,6 +65,7 @@ } }, "plugins": { + "fs": { "requireLiteralLeadingDot": true }, "deep-link": { "desktop": { "schemes": ["openpencil"] } }, diff --git a/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts b/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts index f7adf3939..73d796349 100644 --- a/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts +++ b/packages/vue/src/primitives/PropertySection/PropertyPrimitives.stories.ts @@ -24,10 +24,9 @@ export const StateMatrix: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) - const layer = canvas.getByRole('button', { name: 'Layer' }) - await expect(canvas.getByText('Collapsible content')).toBeVisible() - await userEvent.click(layer) - await expect(layer).toHaveAttribute('data-state', 'closed') + // Section titles are static, as in Figma: clicking one keeps the section open. + await userEvent.click(canvas.getByText('Layer', { exact: true })) + await expect(canvas.getByText('Section content')).toBeVisible() await userEvent.click(canvas.getByRole('button', { name: 'Add first effect' })) await expect(canvas.getByText('Drop shadow')).toBeVisible() diff --git a/packages/vue/src/primitives/PropertySection/examples/States.vue b/packages/vue/src/primitives/PropertySection/examples/States.vue index 2614dea50..c83f62660 100644 --- a/packages/vue/src/primitives/PropertySection/examples/States.vue +++ b/packages/vue/src/primitives/PropertySection/examples/States.vue @@ -60,7 +60,7 @@ function toggleFill(index: number) { ⌘ L - Collapsible content + Section content diff --git a/src/AGENTS.md b/src/AGENTS.md index 95613adaf..7fe7906e5 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -82,4 +82,5 @@ The supported browser baseline lives in `src/app/shell/support/baseline.ts` and ### Storybook - Colocate `ComponentName.stories.ts` with `ComponentName.vue`; multipart compositions may use a descriptive family name. Preserve explicit titles and exported story names during moves. Default playgrounds stay static; interaction flows get named stories. Prefer inline story fixtures for small app-local states; use colocated `examples/.vue` SFCs for substantial templates or fixtures that need SFC template/slot typing, including app-only fixtures (shared SDK examples follow `packages/vue/AGENTS.md`, Documentation). Story templates compile at runtime, so they must be plain JavaScript with no TypeScript syntax, and `icon-lucide-*` tags do not resolve there; import icons from `~icons/...` and register them. +- Every story must render and pass its play function: `tests/e2e/storybook/stories.spec.ts` runs them all in `bun run test:storybook`, so a play function asserts behavior that stays true, not copy or obsolete markup. - Isolated visual states of feedback components belong in stories, not Playwright application screenshots. Do not add automated tests or snapshot baselines for CSS-only changes (spacing, sizing, colors, breakpoints); verify those visually. Settings E2E covers integration behavior: feedback appearance, validation and focus, retained drafts, successful retries. diff --git a/src/components/LayerTree/LayerTree.stories.ts b/src/components/LayerTree/LayerTree.stories.ts index 65fbd5b88..7d05a8ae7 100644 --- a/src/components/LayerTree/LayerTree.stories.ts +++ b/src/components/LayerTree/LayerTree.stories.ts @@ -33,19 +33,13 @@ export const AdjacentRows: Story = { args: { adjacent: true } } export const StateMatrix: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) - await expect(canvas.getByLabelText('Selected focused').firstElementChild).toHaveAttribute( - 'data-focused' - ) - await expect(canvas.getByLabelText('Selected unfocused').firstElementChild).toHaveAttribute( - 'data-selected' - ) - await expect(canvas.getByLabelText('Hidden').firstElementChild).toHaveAttribute('data-hidden') - await expect(canvas.getByLabelText('Dragging').firstElementChild).toHaveAttribute( - 'data-dragging' - ) - await expect(canvas.getByLabelText('Child drop').firstElementChild).toHaveAttribute( - 'data-drop-position', - 'child' - ) + // Each row's disclosure is named after its layer. + const row = (name: string) => + canvas.getByRole('button', { name }).closest('[data-slot="row"]') + await expect(row('Selected focused')).toHaveAttribute('data-focused') + await expect(row('Selected unfocused')).toHaveAttribute('data-selected') + await expect(row('Hidden')).toHaveAttribute('data-hidden') + await expect(row('Dragging')).toHaveAttribute('data-dragging') + await expect(row('Child drop')).toHaveAttribute('data-drop-position', 'child') } } diff --git a/src/components/LayerTree/examples/States.vue b/src/components/LayerTree/examples/States.vue index 43f4b0538..e5b28e317 100644 --- a/src/components/LayerTree/examples/States.vue +++ b/src/components/LayerTree/examples/States.vue @@ -155,7 +155,7 @@ const states: Array<{ Layer Tree states
-
+
= { 'not-installed': { code: 'not-installed', @@ -19,7 +21,7 @@ const reasons: Record = { code: 'exited', detail: 'Error: listen EADDRINUSE: address already in use 127.0.0.1:7600' }, - timeout: { code: 'timeout', detail: 'no health response from http://127.0.0.1:7600/health' }, + timeout: { code: 'timeout', detail: TIMEOUT_DETAIL }, rejected: { code: 'rejected', detail: 'HTTP 401' }, malformed: { code: 'malformed', detail: 'HTTP 200' }, unreachable: { code: 'unreachable', detail: 'http://127.0.0.1:7600/mcp' }, @@ -70,10 +72,10 @@ export const DetailsExpandOnDemand: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) // Collapsed payloads stay unmounted, so the alert announces only its summary. - await expect(canvas.queryByTestId('settings-mcp-failure-detail')).toBeNull() + await expect(canvas.queryByText(TIMEOUT_DETAIL)).toBeNull() const trigger = canvas.getByRole('button', { name: 'Details' }) await expect(trigger).toHaveAttribute('aria-expanded', 'false') await userEvent.click(trigger) - await expect(canvas.getByTestId('settings-mcp-failure-detail')).toBeVisible() + await expect(canvas.getByText(TIMEOUT_DETAIL)).toBeVisible() } } diff --git a/src/components/ui/panel/PanelFoundation.stories.ts b/src/components/ui/panel/PanelFoundation.stories.ts index 69c7d963f..632671189 100644 --- a/src/components/ui/panel/PanelFoundation.stories.ts +++ b/src/components/ui/panel/PanelFoundation.stories.ts @@ -27,7 +27,7 @@ const meta = { docs: { description: { component: - 'The 26px properties-panel foundation: strict grids, field groups, action rails, and semantic field states.' + 'The 24px properties-panel foundation: strict grids, field groups, action rails, and semantic field states.' } } } @@ -125,10 +125,10 @@ export const StateMatrix: Story = { - + - +