From e642558344568d560200b4c3a6d0f8a28ee83d7f Mon Sep 17 00:00:00 2001 From: kate bonner Date: Tue, 28 Jul 2026 14:32:21 -0400 Subject: [PATCH] feat(amicode): context tree marks proprietary vault context locked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not every tree node was openable, but nothing said so: turn nodes wore a pointer cursor that clicked into nothing, and files in non-browsable (proprietary) vaults dead-ended in a Vault-panel refusal after the click. - GET /amicode/vaults now stamps each mount with `browsable`, computed by the vault-browser's fail-closed law (deployment gate + kind/marker rules) - tree leaves in a non-browsable mount render locked: dimmed dot, padlock glyph by the label, not-allowed cursor, click is a no-op, and keyboard nav announces "locked — this vault does not allow browsing" - cursor law is now honest everywhere: pointer only where a click opens something; grab (pan) on turns/skills/agents/actions; the click ring only fires on openable nodes Co-Authored-By: Claude Fable 5 --- .../src/pages/session/context-tree-panel.tsx | 42 +++++++++++++++-- .../opencode/src/server/amicode/vaults.ts | 28 ++++++++++- .../test/server/amicode-vaults.test.ts | 46 ++++++++++++++++++- .../ui/src/amicode/context-tree-data.test.ts | 16 +++++++ packages/ui/src/amicode/context-tree-data.ts | 16 +++++-- .../ui/src/amicode/context-tree-engine.ts | 43 +++++++++++++---- 6 files changed, 172 insertions(+), 19 deletions(-) diff --git a/packages/app/src/pages/session/context-tree-panel.tsx b/packages/app/src/pages/session/context-tree-panel.tsx index ff51dc2a5..69998beac 100644 --- a/packages/app/src/pages/session/context-tree-panel.tsx +++ b/packages/app/src/pages/session/context-tree-panel.tsx @@ -7,11 +7,13 @@ // the real file (project files in a session tab, vault files in the Vault // panel). Hovering a tool row in the log still glances at its node here via // the same amicode:brain-hover event the strip used. -import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, For, Show } from "solid-js" import { IconButton } from "@opencode-ai/ui/icon-button" import { useSync } from "@/context/sync" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { useServer } from "@/context/server" +import { amicodeGet } from "@/utils/amicode-fetch" import { amicoBrainRef } from "@opencode-ai/ui/brain-ref" import { createContextTreeEngine, @@ -66,8 +68,33 @@ function ContextTreeFrame(props: { sessionID: string }) { const sync = useSync() const file = useFile() const language = useLanguage() + const server = useServer() const { tabs, view } = useSessionLayout() + // per-mount browsability from GET /amicode/vaults (`browsable`, stamped by + // the server's fail-closed law) — proprietary mounts mark their nodes + // locked upfront instead of dead-ending in a Vault-panel refusal on click + const [vaultsRaw] = createResource( + () => server.current, + (conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined), + ) + const browsableMounts = createMemo | undefined>(() => { + const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined + if (!raw || !Array.isArray(raw.mounts)) return undefined + return new Map( + raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]), + ) + }) + const vaultLocked = (mount: string) => { + const map = browsableMounts() + // list unavailable → status quo (no lock claims we can't back); + // a mount the server doesn't list can't be browsed → locked; + // `browsable` absent (older server) → unknown, again no lock claim + if (!map) return false + if (!map.has(mount)) return true + return map.get(mount) === false + } + const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) const getParts = (msgId: string) => sync.data.part[msgId] ?? [] const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle") @@ -134,7 +161,7 @@ function ContextTreeFrame(props: { sessionID: string }) { setActive: (tab) => tabs().setActive(tab), }) const onSelect = (node: ContextTreeSelection) => { - if (!node.path) return + if (!node.path || node.locked) return const vaultRef = vaultRefFromPath(node.path) if (vaultRef) { vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel }) @@ -161,7 +188,7 @@ function ContextTreeFrame(props: { sessionID: string }) { window.addEventListener("amicode:brain-hover", onToolHover) onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover)) - const tree = createMemo(() => buildContextTree(turns())) + const tree = createMemo(() => buildContextTree(turns(), { vaultLocked })) createEffect(() => { const brain = engine() if (!brain) return @@ -173,7 +200,8 @@ function ContextTreeFrame(props: { sessionID: string }) { const flatNodes = createMemo(() => { const out: ContextTreeSelection[] = [] const walk = (n: ContextTreeNodeInput) => { - if (n.kind !== "root") out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault }) + if (n.kind !== "root") + out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked }) for (const c of n.children ?? []) walk(c) } walk(tree()) @@ -188,7 +216,11 @@ function ContextTreeFrame(props: { sessionID: string }) { setKbIndex(next) const node = list[next] engine()?.focus(node.id) - setAnnounce(`${node.label} — ${node.kind}${node.path ? ", press Enter to open" : ""}`) + setAnnounce( + `${node.label} — ${node.kind}${ + node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : "" + }`, + ) } const onCanvasKeyDown = (e: KeyboardEvent) => { const list = flatNodes() diff --git a/packages/opencode/src/server/amicode/vaults.ts b/packages/opencode/src/server/amicode/vaults.ts index 887073af1..651a7b2e8 100644 --- a/packages/opencode/src/server/amicode/vaults.ts +++ b/packages/opencode/src/server/amicode/vaults.ts @@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil import { homedir } from "node:os" import path from "node:path" import { run } from "@/util/process" +import { browseAllowed, mountBrowseRefusal, mountDir } from "@/server/amicode/vault-browser" const TIMEOUT_MS = 8_000 const CACHE_MS = 10_000 @@ -65,9 +66,34 @@ function resolveCli(): string | undefined { let cache: { at: number; body: string } | undefined +/** Stamp each mount with `browsable`, computed by the vault-browser's + * fail-closed law (deployment gate + per-mount kind/marker rules), so the + * app can mark proprietary context locked UPFRONT — e.g. grey out context + * tree nodes — instead of discovering the refusal on click. Additive to the + * relayed wire shape; an unparseable body passes through untouched. */ +export function annotateBrowsable( + body: string, + root: string = vaultsRoot(), + env: Record = process.env, +): string { + try { + const parsed = JSON.parse(body) as { mounts?: { id?: unknown; browsable?: boolean }[] } + if (!Array.isArray(parsed.mounts)) return body + const allowed = browseAllowed(env) + for (const m of parsed.mounts) { + if (typeof m?.id !== "string") continue + const dir = allowed ? mountDir(m.id, root) : undefined + m.browsable = !!dir && !mountBrowseRefusal(m.id, dir, env) + } + return JSON.stringify(parsed) + } catch { + return body + } +} + export async function status(): Promise { if (cache && Date.now() - cache.at < CACHE_MS) return cache.body - const body = await statusUncached().catch((err) => synthesize("bad_output", String(err))) + const body = annotateBrowsable(await statusUncached().catch((err) => synthesize("bad_output", String(err)))) cache = { at: Date.now(), body } return body } diff --git a/packages/opencode/test/server/amicode-vaults.test.ts b/packages/opencode/test/server/amicode-vaults.test.ts index 98ab59227..53c247569 100644 --- a/packages/opencode/test/server/amicode-vaults.test.ts +++ b/packages/opencode/test/server/amicode-vaults.test.ts @@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test" import { mkdirSync, mkdtempSync, writeFileSync, existsSync, lstatSync } from "node:fs" import { tmpdir, homedir } from "node:os" import path from "node:path" -import { candidates, synthesize, normalizeRef, sanitizeVaultName, attachVault, scanMounts } from "@/server/amicode/vaults" +import { + annotateBrowsable, + candidates, + synthesize, + normalizeRef, + sanitizeVaultName, + attachVault, + scanMounts, +} from "@/server/amicode/vaults" describe("synthesize", () => { test("emits the plural failure shape the UI parser expects", () => { @@ -118,3 +126,39 @@ describe("scanMounts (CLI-less fallback)", () => { expect(out.mounts[1]).toMatchObject({ id: "armonissima", kind: "team", writable: false }) }) }) + +describe("annotateBrowsable", () => { + const root = mkdtempSync(path.join(tmpdir(), "vaults-annotate-")) + const mk = (name: string, marker: string) => { + mkdirSync(path.join(root, name), { recursive: true }) + writeFileSync(path.join(root, name, ".amico-vault.toml"), marker) + } + mk("personal-v", 'kind = "personal"\nname = "personal-v"\n') + mk("team-dark", 'kind = "team"\nname = "team-dark"\n') + mk("team-open", 'kind = "team"\nname = "team-open"\nbrowse = true\n') + mk("personal-off", 'kind = "personal"\nname = "personal-off"\nbrowse = false\n') + const env = { AMICO_VAULT_BROWSER: "1" } + + test("stamps browsable per the fail-closed law (kind default + browse override)", () => { + const out = JSON.parse(annotateBrowsable(scanMounts(root), root, env)) as { + mounts: { id: string; browsable: boolean }[] + } + const by = Object.fromEntries(out.mounts.map((m) => [m.id, m.browsable])) + expect(by["personal-v"]).toBe(true) + expect(by["team-dark"]).toBe(false) + expect(by["team-open"]).toBe(true) + expect(by["personal-off"]).toBe(false) + }) + test("a mount the browser can't resolve is not browsable; junk passes through", () => { + const body = JSON.stringify({ ok: true, mounts: [{ id: "ghost", kind: "personal" }], error: null }) + const out = JSON.parse(annotateBrowsable(body, root, env)) as { mounts: { browsable: boolean }[] } + expect(out.mounts[0].browsable).toBe(false) + expect(annotateBrowsable("not json", root, env)).toBe("not json") + }) + test("deployment gate off (AMICO_VAULT_BROWSER=0) darkens every mount", () => { + const out = JSON.parse(annotateBrowsable(scanMounts(root), root, { AMICO_VAULT_BROWSER: "0" })) as { + mounts: { browsable: boolean }[] + } + for (const m of out.mounts) expect(m.browsable).toBe(false) + }) +}) diff --git a/packages/ui/src/amicode/context-tree-data.test.ts b/packages/ui/src/amicode/context-tree-data.test.ts index 1c1c54743..5e3424886 100644 --- a/packages/ui/src/amicode/context-tree-data.test.ts +++ b/packages/ui/src/amicode/context-tree-data.test.ts @@ -76,6 +76,22 @@ describe("buildContextTree", () => { ]) expect(tree.children![0].children![0].vault).toBe(true) }) + test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => { + const tree = buildContextTree( + [ + turn("m1", [ + { label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" }, + { label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" }, + { label: "solve.jl", type: "package", path: "/p/solve.jl" }, + ]), + ], + { vaultLocked: (mount) => mount === "armonissima" }, + ) + const [team, personal, project] = tree.children![0].children! + expect(team.locked).toBe(true) + expect(personal.locked).toBe(false) + expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted + }) test("marathon sessions fold old turns into one earlier branch", () => { const turns = Array.from({ length: 30 }, (_, i) => turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]), diff --git a/packages/ui/src/amicode/context-tree-data.ts b/packages/ui/src/amicode/context-tree-data.ts index 66a4b8c14..25ee67394 100644 --- a/packages/ui/src/amicode/context-tree-data.ts +++ b/packages/ui/src/amicode/context-tree-data.ts @@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } | const dedupKey = (ref: ContextRef, kind: ContextTreeKind) => ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}` -export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput { +export type ContextTreeOpts = { + rootLabel?: string + /** true when the mount refuses browsing (proprietary data/software) — its + * leaves render locked: dimmed, padlocked, not openable */ + vaultLocked?: (mount: string) => boolean +} + +export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput { const root: ContextTreeNodeInput = { id: "root", label: opts.rootLabel ?? "amico", @@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin const key = dedupKey(ref, kind) if (seen.has(key)) continue seen.set(key, `ctx-${seen.size}`) - earlier.children!.push(leafOf(ref, kind, seen.get(key)!)) + earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts)) } } const seen = seenOf(root) @@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin } const id = `ctx-${seen.size}` seen.set(key, id) - const leaf = leafOf(ref, kind, id) + const leaf = leafOf(ref, kind, id, opts) node.children!.push(leaf) lastLeaf = leaf } @@ -138,7 +145,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin return root } -function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput { +function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput { const vault = ref.path ? vaultRefFromPath(ref.path) : undefined return { id, @@ -146,6 +153,7 @@ function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTree kind, path: ref.path, vault: !!vault, + locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined, } } diff --git a/packages/ui/src/amicode/context-tree-engine.ts b/packages/ui/src/amicode/context-tree-engine.ts index c4dd21778..0717d3c5b 100644 --- a/packages/ui/src/amicode/context-tree-engine.ts +++ b/packages/ui/src/amicode/context-tree-engine.ts @@ -44,6 +44,9 @@ export type ContextTreeNodeInput = { path?: string /** the file lives in a vault mount (open via the Vault panel, not a tab) */ vault?: boolean + /** the vault refuses browsing (proprietary data/software) — the node renders + * dimmed with a padlock and is NOT openable, path or not */ + locked?: boolean /** where the agent currently works — wears the thought-color cursor */ active?: boolean children?: ContextTreeNodeInput[] @@ -57,6 +60,7 @@ export type ContextTreeSelection = { kind: ContextTreeKind path?: string vault?: boolean + locked?: boolean } export interface ContextTreeEngineOptions { @@ -181,6 +185,7 @@ interface TNode { kind: ContextTreeKind path?: string vault?: boolean + locked?: boolean active: boolean depth: number // world targets (tidy layout) and animated positions @@ -333,6 +338,7 @@ export function createContextTreeEngine( n.kind = input.kind n.path = input.path n.vault = input.vault + n.locked = input.locked n.active = !!input.active n.depth = depth n.half = HALF[input.kind] ?? 4 @@ -456,7 +462,11 @@ export function createContextTreeEngine( kind: n.kind, path: n.path, vault: n.vault, + locked: n.locked, }) + // openable = the click actually goes somewhere; everything else (turns, + // skills, agents, actions, locked vault files) must not wear the pointer + const openable = (n: TNode) => !!n.path && !n.locked let dragging = false let dragMoved = false let lastPX = 0, @@ -499,7 +509,8 @@ export function createContextTreeEngine( const n = id ? (byId.get(id) ?? null) : null if (n !== hovered) { hovered = n - if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab" + // pointer only where a click opens something; a locked node says so + if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab" opts.onHover?.(n ? selection(n) : null) } } @@ -510,7 +521,9 @@ export function createContextTreeEngine( const p = local(e) const id = pick(p.x, p.y) const n = id ? byId.get(id) : undefined - if (n) { + // only openable nodes acknowledge the click — a ring on a dead-end node + // would promise an action that never comes + if (n && openable(n)) { n.ringT = beatNow opts.onSelect?.(selection(n)) } @@ -647,9 +660,11 @@ export function createContextTreeEngine( ctx.lineWidth = 1 ctx.stroke() } else { - ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75) + // locked (non-browsable vault) leaves read clearly non-interactive: + // reduced emphasis, plus the padlock by the label (shape, not color) + ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75) ctx.fill() - ctx.strokeStyle = rgba(color, 0.9) + ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9) ctx.lineWidth = 1 ctx.stroke() } @@ -690,13 +705,25 @@ export function createContextTreeEngine( isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6 ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace` const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label + const lockW = n.locked ? 10 : 0 const tw = ctx.measureText(text).width - const lx = x - tw / 2, + const lx = x - (tw + lockW) / 2, ly = y + half + 10 ctx.fillStyle = css.labelHalo - ctx.fillRect(lx - 2, ly - 7, tw + 4, 14) - ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha) - ctx.fillText(text, lx, ly) + ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14) + const inkA = Math.min(la, 1) * n.alpha + if (n.locked) { + // padlock: shackle arc over a body — the non-color "cannot open" signal + ctx.strokeStyle = rgba(css.fg, inkA) + ctx.lineWidth = 1 + ctx.beginPath() + ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0) + ctx.stroke() + ctx.fillStyle = rgba(css.fg, inkA) + ctx.fillRect(lx, ly - 1.5, 6, 5) + } + ctx.fillStyle = rgba(css.fg, inkA) + ctx.fillText(text, lx + lockW, ly) ctx.globalAlpha = 1 }