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
8 changes: 8 additions & 0 deletions components/agent/bridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export function AgentBridge({ hidden = false }: { hidden?: boolean }) {
stopped = false
let baseline: Snapshot,
localId = "",
rememberedName = "",
revision = 0
const openingId = useSquig.getState().docId
function detach() {
Expand Down Expand Up @@ -204,6 +205,8 @@ export function AgentBridge({ hidden = false }: { hidden?: boolean }) {
}
attaching.current = null
useSquig.setState({ docId: `agent_${id}` })
useSquig.getState().rememberSharedFile(id!)
rememberedName = useSquig.getState().fileName
localId = useSquig.getState().docId
baseline = keepCurrent ? editable(row.document) : snapshot()
revision = row.revision
Expand Down Expand Up @@ -281,6 +284,11 @@ export function AgentBridge({ hidden = false }: { hidden?: boolean }) {
remoteChanged ? "Live canvas · new changes" : "Live canvas",
)
}
const latestName = useSquig.getState().fileName
if (latestName !== rememberedName) {
useSquig.getState().rememberSharedFile(id!)
rememberedName = latestName
}
} catch (e) {
if (!active) return
if (!initialized && useSquig.getState().docId !== openingId) {
Expand Down
14 changes: 7 additions & 7 deletions components/chrome/recent-files.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"use client"

// ---------------------------------------------------------------------------
// The recent files submenu. Everything squig has saved in this browser, newest
// first — click one to open it, or arm the trash twice to let it go. Deleting
// takes two clicks on purpose: this list is the only copy.
// Local drawings and visited shared canvases, newest first. Local deletion
// takes two clicks because this list holds the only copy of those drawings.
// ---------------------------------------------------------------------------

import { useEffect, useRef, useState } from "react"
Expand All @@ -30,7 +29,7 @@ export function RecentFiles() {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Open recent</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-72">
<DropdownMenuSubContent className="w-80">
{files.length === 0 ? (
<p className="px-2.5 py-1.5 text-row text-muted-foreground">nothing saved yet</p>
) : (
Expand Down Expand Up @@ -69,6 +68,7 @@ function FileRow({ file, current }: { file: FileMeta; current: boolean }) {
<DropdownMenuItem className="flex-1 gap-2 pr-8" onClick={() => st().openFile(file.id)}>
{current && <CheckIcon className="size-3.5 shrink-0 text-muted-foreground" weight="bold" />}
<span className="min-w-0 flex-1 truncate">{file.name}</span>
{file.agentId && <span className="shrink-0 text-label text-muted-foreground">shared</span>}
<span className={`shrink-0 text-label ${armed ? "text-destructive" : "text-muted-foreground"}`}>
{armed ? "click again" : relativeTime(file.updatedAt)}
</span>
Expand All @@ -77,9 +77,9 @@ function FileRow({ file, current }: { file: FileMeta; current: boolean }) {
{!current && (
<button
type="button"
aria-label={armed ? `delete ${file.name} for good` : `delete ${file.name}`}
title={armed ? "click again to delete" : "delete"}
onClick={() => (armed ? st().deleteFile(file.id) : arm())}
aria-label={file.agentId ? `remove ${file.name} from recent files` : armed ? `delete ${file.name} for good` : `delete ${file.name}`}
title={file.agentId ? "remove from recent files" : armed ? "click again to delete" : "delete"}
onClick={() => (file.agentId || armed ? st().deleteFile(file.id) : arm())}
className={`absolute right-1.5 flex size-6 items-center justify-center rounded-chrome-sm transition-opacity ${
armed
? "text-destructive opacity-100"
Expand Down
6 changes: 6 additions & 0 deletions docs/agent-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ coherent batches. An existing local drawing becomes shared through **Connect
agent** in the editor, retaining its objects and the user's current view.
Share provides the editable invitation. Connect agent provides the key and MCP config. Both are popovers in the top-right toolbar, beside the sidebar toggle.

Successfully opened shared canvases appear in Open recent and the command
palette. This browser remembers their names and IDs separately from local
drawings; reopening fetches the live canvas with the saved invitation or
workspace key. Removing a shared entry only forgets the shortcut. It does not
delete the online canvas or revoke its invitation.

Any Streamable HTTP MCP client can connect to `/mcp`; any HTTP agent can use
`/api/v1/tools/{name}` with identical inputs. The catalog is Squig’s actual
component registry. All six node types and canvas operations use the same
Expand Down
31 changes: 31 additions & 0 deletions lib/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface FileMeta {
id: string
name: string
updatedAt: number
/** A shortcut to a shared canvas; its contents and credentials live elsewhere. */
agentId?: string
}

export interface StoredDoc {
Expand All @@ -48,6 +50,7 @@ export interface Prefs {

/** exported so another tab writing the drawer can be noticed */
export const INDEX_KEY = "squig:files:v1"
export const SHARED_INDEX_KEY = "squig:shared-files:v1"
const PREFS_KEY = "squig:prefs:v1"
const LEGACY_KEY = "squig:doc:v1"
/** exported so a tab can notice another one writing the document it has open */
Expand Down Expand Up @@ -106,6 +109,34 @@ export function listFiles(): FileMeta[] {
return parsed.filter(isMeta).sort(byRecent)
}

function listSharedFiles(): FileMeta[] {
const parsed = readJSON(SHARED_INDEX_KEY)
if (!Array.isArray(parsed)) return []
return parsed.filter((f): f is FileMeta =>
isMeta(f) && typeof f.agentId === "string" &&
/^[A-Za-z0-9_-]{1,80}$/.test(f.agentId) && f.id === `agent_${f.agentId}`,
).sort(byRecent)
}

export function listRecentFiles(): FileMeta[] {
const shared = process.env.NEXT_PUBLIC_SQUIG_OFFLINE === "1" ? [] : listSharedFiles()
return [...listFiles(), ...shared].sort(byRecent)
}

/** Keep shortcuts separate so visiting shared canvases never evicts local drawings. */
export function rememberSharedFile(agentId: string, name: string, updatedAt = Date.now()): boolean {
if (!/^[A-Za-z0-9_-]{1,80}$/.test(agentId)) return false
const id = `agent_${agentId}`
return writeJSON(SHARED_INDEX_KEY, [
{ id, agentId, name, updatedAt },
...listSharedFiles().filter((f) => f.id !== id),
].slice(0, MAX_FILES))
}

export function forgetSharedFile(id: string): boolean {
return writeJSON(SHARED_INDEX_KEY, listSharedFiles().filter((f) => f.id !== id))
}

/** Same contract as writeJSON: false when the browser refused it. */
function writeIndex(list: FileMeta[]): boolean {
return writeJSON(INDEX_KEY, list)
Expand Down
44 changes: 37 additions & 7 deletions lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ import {
} from "./theme"
import {
INDEX_KEY,
SHARED_INDEX_KEY,
deleteFile as dropFile,
fileKey,
forgetSharedFile,
listFiles,
listRecentFiles,
loadPrefs,
migrateLegacyDoc,
readFile,
rememberSharedFile,
saveFile,
savePrefs,
type FileMeta,
Expand Down Expand Up @@ -276,6 +280,7 @@ interface SquigState {
newFile: () => void
/** swap the canvas to another file in the drawer, saving this one first */
openFile: (id: string) => void
rememberSharedFile: (agentId: string) => void
deleteFile: (id: string) => void
/** write to the drawer right now instead of waiting out the debounce */
saveNow: () => void
Expand Down Expand Up @@ -597,7 +602,7 @@ function flushSave(get: () => SquigState, force = false) {
const known = s.files.some((f) => f.id === s.docId)
if (!s.order.length && !known && !force) return
const at = Date.now()
const { index, full, stale } = saveFile(
const { full, stale } = saveFile(
{
id: s.docId,
name: s.fileName,
Expand All @@ -621,7 +626,7 @@ function flushSave(get: () => SquigState, force = false) {
// be saying the same thing about the same drawing. The line under the file
// name carries it from there, for as long as it lasts.
if (full && !s.drawerFull) s.setNotice("no room left in this browser — export this one to keep it")
useSquig.setState({ files: index, drawerFull: full })
useSquig.setState({ files: listRecentFiles(), drawerFull: full })
// a refused write leaves the drawing unsaved, so it stays owed: the next
// edit, or the tab closing, tries again — which is how squig comes back on
// its own once the user has made room
Expand Down Expand Up @@ -736,8 +741,8 @@ function watchWindow(get: () => SquigState) {
if (document.visibilityState === "hidden") save()
})
window.addEventListener("storage", (e) => {
if (e.key === INDEX_KEY) {
useSquig.setState({ files: listFiles() })
if (e.key === INDEX_KEY || e.key === SHARED_INDEX_KEY) {
useSquig.setState({ files: listRecentFiles() })
return
}
const s = get()
Expand Down Expand Up @@ -1351,7 +1356,7 @@ export const useSquig = create<SquigState>((set, get) => ({
selection: [],
selectionGroupId: null,
selectionGroups: [],
files,
files: listRecentFiles(),
contextRow: prefs.contextRow,
bigNudge: prefs.bigNudge,
hydrated: true,
Expand Down Expand Up @@ -1830,10 +1835,22 @@ export const useSquig = create<SquigState>((set, get) => ({
openFile: (id) => {
if (id === get().docId) return
flushSave(get)
const recent = listRecentFiles().find((f) => f.id === id)
if (recent?.agentId) {
if (!get().docId.startsWith("agent_") && dirty && (get().drawerFull || get().stale)) {
get().setNotice("Export this drawing before opening a shared canvas. Its changes could not be saved.")
return
}
// A fresh connection fetches the current revision using this browser's
// saved credential, and keeps the bridge's unsaved-work unload guard.
window.location.assign(`/?agent=${encodeURIComponent(recent.agentId)}`)
return
}
const doc = readFile(id)
if (!doc) {
// the index knew about it but the document itself is gone
set({ files: dropFile(id) })
dropFile(id)
set({ files: listRecentFiles() })
return
}
const clean = sanitizeDoc(doc.nodes, doc.order)
Expand Down Expand Up @@ -1864,8 +1881,21 @@ export const useSquig = create<SquigState>((set, get) => ({
flushSave(get)
},

rememberSharedFile: (agentId) => {
if (get().docId !== `agent_${agentId}`) return
if (!rememberSharedFile(agentId, get().fileName))
get().setNotice("Could not remember this canvas. Keep its invitation link to reopen it.")
set({ files: listRecentFiles() })
},

deleteFile: (id) => {
const files = dropFile(id)
if (listRecentFiles().some((f) => f.id === id && f.agentId)) {
if (!forgetSharedFile(id)) get().setNotice("Could not remove this canvas from recent files.")
set({ files: listRecentFiles() })
return
}
dropFile(id)
const files = listRecentFiles()
set({ files })
if (id !== get().docId) return
// The file you had open just went away. Let go of it before landing
Expand Down
102 changes: 101 additions & 1 deletion scripts/agent/security-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Start pnpm dev, then run pnpm test:agent:security-browser.
import { chromium, expect } from "@playwright/test"
import { randomBytes } from "node:crypto"
import { mkdir } from "node:fs/promises"
const base = process.env.SQUIG_TEST_URL ?? "http://localhost:3000"
const owner = `sq_${randomBytes(32).toString("base64url")}`
const saved = `sq_canvas_${randomBytes(32).toString("base64url")}`
Expand Down Expand Up @@ -54,6 +55,7 @@ try {
await expect(invalid.page.getByLabel("Editable canvas link", { exact: true })).toHaveCount(0)
await invalid.page.waitForTimeout(1200)
expect(await invalid.page.evaluate((slot) => localStorage.getItem(slot), slot)).toBe(saved)
expect(await invalid.page.evaluate(() => localStorage.getItem("squig:shared-files:v1"))).toBeNull()
expect(invalid.calls.every((c) => c.method === "GET" && c.key === `Bearer ${candidate}`)).toBe(true)
if (fragment !== candidate) expect(invalid.calls).toEqual([])
const stoppedAt = invalid.calls.length
Expand Down Expand Up @@ -95,6 +97,104 @@ try {
expect(valid.calls.every((c) => c.method === "GET" && c.key === `Bearer ${candidate}`)).toBe(true)
await valid.context.close()

let sharedDocument = { ...document, fileName: "Agent recent canvas" }
const localFile = { id: "local", name: "Local recent drawing", updatedAt: 1, nodes: document.nodes, order: document.order, look: document.look }
let recentRevoked = false
const recent = await fixture({
"squig:files:v1": JSON.stringify([{ id: localFile.id, name: localFile.name, updatedAt: localFile.updatedAt }]),
"squig:file:local": JSON.stringify(localFile),
}, async (route) => {
if (recentRevoked) return route.fulfill({ status: 401, json: { error: "Invalid or revoked canvas key" } })
if (route.request().method() === "POST") sharedDocument = route.request().postDataJSON().document
await route.fulfill({ json: { id: "one", revision: 1, document: sharedDocument } })
})
const openRecents = async () => {
await recent.page.getByRole("button", { name: "squig", exact: true }).click()
await recent.page.getByRole("menuitem", { name: "Open recent", exact: true }).hover()
await expect(recent.page.getByRole("menuitem", { name: /Local recent drawing/ })).toBeVisible()
}
await recent.page.goto(`${base}/?agent=one#${candidate}`)
await expect(recent.page.locator(".agent-sync")).toHaveAttribute("data-connected", "true")
await openRecents()
await expect(recent.page.getByRole("menuitem", { name: /Agent recent canvas shared/ })).toBeVisible()
await mkdir("test-results/agent-recents", { recursive: true })
await recent.page.screenshot({ path: "test-results/agent-recents/menu.png" })
await recent.page.getByRole("menuitem", { name: /Local recent drawing/ }).click()
await expect(recent.page.locator(".agent-sync")).toHaveAttribute("data-connected", "false")
expect(new URL(recent.page.url()).search).toBe("")
await recent.page.reload()
await openRecents()
sharedDocument = { ...sharedDocument, fileName: "Latest agent canvas" }
await recent.page.getByRole("menuitem", { name: /Agent recent canvas shared/ }).click()
await expect(recent.page.locator(".agent-sync")).toHaveAttribute("data-connected", "true")
await expect(recent.page.getByRole("button", { name: "Latest agent canvas", exact: true })).toBeVisible()
expect(new URL(recent.page.url()).search).toBe("?agent=one")
expect(recent.calls.every((c) => c.key === `Bearer ${candidate}` && c.method === "GET")).toBe(true)
expect(await recent.page.evaluate(() => JSON.parse(localStorage.getItem("squig:shared-files:v1")).length)).toBe(1)
expect(await recent.page.evaluate(() => localStorage.getItem("squig:file:agent_one"))).toBeNull()
// Both remote and local renames update the shortcut without another visit.
sharedDocument = { ...sharedDocument, fileName: "Remote rename" }
await expect(recent.page.getByRole("button", { name: "Remote rename", exact: true })).toBeVisible()
await recent.page.getByRole("button", { name: "Remote rename", exact: true }).click()
await recent.page.getByRole("textbox", { name: "file name", exact: true }).fill("Renamed shared canvas")
await recent.page.getByRole("textbox", { name: "file name", exact: true }).press("Enter")
await expect.poll(() => sharedDocument.fileName).toBe("Renamed shared canvas")
await expect.poll(() => recent.page.evaluate(() => JSON.parse(localStorage.getItem("squig:shared-files:v1"))[0].name)).toBe("Renamed shared canvas")
// Another tab's drawer refresh must not detach this live canvas.
const otherTab = await recent.context.newPage()
await otherTab.goto(base)
await otherTab.waitForFunction(() => !!window.squig)
await otherTab.evaluate(() => window.squig.addText("Another tab's local edit", { x: 80, y: 80 }))
await expect.poll(() => otherTab.evaluate(() => JSON.parse(localStorage.getItem("squig:file:local")).order.length)).toBe(localFile.order.length + 1)
await expect(recent.page.locator(".agent-sync")).toHaveAttribute("data-connected", "true")
await otherTab.close()
await openRecents()
await recent.page.getByRole("menuitem", { name: /Local recent drawing/ }).click()
await recent.page.getByRole("button", { name: /^Search / }).click()
await recent.page.getByRole("textbox", { name: "Search commands, layers, components, blocks, and icons" }).fill("Renamed shared canvas")
await recent.page.getByRole("button", { name: /Renamed shared canvas/ }).click()
await expect(recent.page.locator(".agent-sync")).toHaveAttribute("data-connected", "true")
await openRecents()
await recent.page.getByRole("menuitem", { name: /Local recent drawing/ }).click()
recentRevoked = true
await openRecents()
await recent.page.getByRole("menuitem", { name: /Renamed shared canvas shared/ }).click()
await expect(recent.page.getByText("Invalid or revoked canvas key", { exact: true })).toBeVisible()
await expect(recent.page.getByRole("button", { name: "Local recent drawing", exact: true })).toBeVisible()
await openRecents()
await recent.page.getByRole("button", { name: "remove Renamed shared canvas from recent files", exact: true }).click()
await expect(recent.page.getByRole("menuitem", { name: /Renamed shared canvas shared/ })).toHaveCount(0)
expect(await recent.page.evaluate(() => JSON.parse(localStorage.getItem("squig:shared-files:v1")))).toEqual([])
expect(await recent.page.evaluate(() => localStorage.getItem("squig:file:local"))).not.toBeNull()
expect(await recent.page.evaluate((slot) => localStorage.getItem(slot), slot)).toBe(candidate)
expect(recent.calls.some((c) => c.method === "DELETE")).toBe(false)
await recent.context.close()

const unsaved = await fixture({
"squig:files:v1": JSON.stringify([{ id: localFile.id, name: localFile.name, updatedAt: localFile.updatedAt }]),
"squig:file:local": JSON.stringify(localFile),
"squig:shared-files:v1": JSON.stringify([{ id: "agent_one", agentId: "one", name: "Shared shortcut", updatedAt: 2 }]),
[slot]: candidate,
})
await unsaved.page.goto(base)
await unsaved.page.waitForFunction(() => !!window.squig)
await unsaved.page.evaluate(() => {
const set = Storage.prototype.setItem
Storage.prototype.setItem = function (key, value) {
if (key.startsWith("squig:file:")) throw new DOMException("Full", "QuotaExceededError")
return set.call(this, key, value)
}
})
await unsaved.page.evaluate(() => window.squig.addText("Unsaved local work", { x: 40, y: 40 }))
await unsaved.page.getByRole("button", { name: "squig", exact: true }).click()
await unsaved.page.getByRole("menuitem", { name: "Open recent", exact: true }).hover()
await unsaved.page.getByRole("menuitem", { name: /Shared shortcut shared/ }).click()
await expect(unsaved.page.getByText("Export this drawing before opening a shared canvas. Its changes could not be saved.", { exact: true })).toBeVisible()
expect(new URL(unsaved.page.url()).search).toBe("")
expect(unsaved.calls).toEqual([])
expect(await unsaved.page.evaluate(() => Object.values(window.squig.doc().nodes).some((n) => n.text === "Unsaved local work"))).toBe(true)
await unsaved.context.close()

const poisoned = await fixture({ "squig:agent-key": owner, [slot]: owner })
await poisoned.page.goto(`${base}/?agent=one`)
await poisoned.page.getByRole("button", { name: "Share", exact: true }).click()
Expand Down Expand Up @@ -149,7 +249,7 @@ try {
expect(await connect.page.evaluate(() => localStorage.getItem("squig:agent-key"))).toBeNull()
await connect.context.close()
expect(errors).toEqual([])
console.log("✓ Browser security regressions passed: local persistence without API calls, framing, unvalidated invitations, pending/revoked keys, workspace separation")
console.log("✓ Browser security regressions passed: local persistence, shared recents and live reopening, renames, quota protection, framing, invitation validation, revoked keys, workspace separation")
} finally {
await browser.close()
}
Loading
Loading