diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 1597a98b8..ff480b88b 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -430,6 +430,7 @@ export const SUITES = { "src/components/board-link-browser.test.ts", "src/components/new-card-modal.test.ts", "src/lib/active-familiar.test.ts", + "src/lib/use-project-familiars.test.ts", "src/lib/use-projects.test.ts", "src/lib/use-projects-race.test.ts", "src/components/calendar-view-polish.test.ts", diff --git a/src/app/api/board/[id]/chat/route.ts b/src/app/api/board/[id]/chat/route.ts index 90d719512..950147b36 100644 --- a/src/app/api/board/[id]/chat/route.ts +++ b/src/app/api/board/[id]/chat/route.ts @@ -51,7 +51,10 @@ export async function POST( ); } - if (card.sessionId) { + // Unscoped legacy tasks have no project authorization boundary to recheck. + // Project-backed cards are handled below, after their current familiar has + // been authorized for the assigned project. + if (card.sessionId && !card.projectId) { await recordSessionFamiliar(card.sessionId, familiarId); return NextResponse.json({ ok: true, @@ -113,6 +116,20 @@ export async function POST( } throw error; } + + // A persisted card/session link can outlive a permission change or a + // reassignment made by another Cave client. Never let that legacy link + // bypass the same authorization required to start a new session. + if (card.sessionId) { + await recordSessionFamiliar(card.sessionId, familiarId); + return NextResponse.json({ + ok: true, + reused: true, + card, + sessionId: card.sessionId, + familiarId, + }); + } } else { const rawProjectRoot = body.projectRoot ?? card.cwd; if (!rawProjectRoot) { diff --git a/src/app/api/familiars/route.test.ts b/src/app/api/familiars/route.test.ts index 83d47a933..d4d886990 100644 --- a/src/app/api/familiars/route.test.ts +++ b/src/app/api/familiars/route.test.ts @@ -55,7 +55,22 @@ assert.match( ); assert.match( source, - /rosterResult\.roster\.map\(/, + /const rostersByProject[\s\S]*?filterFamiliarsForProject\(permissions!, rosterResult\.roster, projectId, "session-launch"\)/, + "every project-scoped familiar request filters the roster with session-launch access", +); +assert.match( + source, + /searchParams\s*\.getAll\("projectId"\)/, + "Familiars API accepts repeated projectId scopes for dependent task pickers", +); +assert.match( + source, + /familiarsByProject: Object\.fromEntries/, + "the table can receive every project-scoped roster from one daemon/config lookup", +); +assert.match( + source, + /roster\.map\(/, "daemon roster and declared-only familiars still flow through the same enrichment path", ); assert.match( diff --git a/src/app/api/familiars/route.ts b/src/app/api/familiars/route.ts index db6835c99..3614087a8 100644 --- a/src/app/api/familiars/route.ts +++ b/src/app/api/familiars/route.ts @@ -6,6 +6,7 @@ import { bindingFor, saveConfig } from "@/lib/cave-config"; import { covenHome } from "@/lib/coven-paths"; import { resolveFamiliarAvatar } from "@/lib/server/familiar-avatar"; import { loadVisibleFamiliarRoster } from "@/lib/server/familiar-roster"; +import { filterFamiliarsForProject, loadProjectPermissions } from "@/lib/project-permissions"; import { buildFamiliarsToml, familiarsTomlContainsId, @@ -30,7 +31,7 @@ export type DaemonFamiliar = { memory_freshness?: string; }; -export async function GET() { +export async function GET(req: Request) { const rosterResult = await loadVisibleFamiliarRoster(); if (!rosterResult.ok) { // Auth failures (401/403) mean the hub/daemon rejected our access token @@ -56,6 +57,30 @@ export async function GET() { ); } const { config } = rosterResult; + const projectIds = [...new Set( + new URL(req.url).searchParams + .getAll("projectId") + .map((projectId) => projectId.trim()) + .filter(Boolean), + )]; + // Board task creation selects the project first. Restrict its familiar + // picker using the same read-level session-launch rule enforced by + // /api/board/:id/chat; the route's final assertProjectAccess remains the + // authority for a launch request. + const permissions = projectIds.length > 0 ? await loadProjectPermissions() : null; + const rostersByProject = new Map( + projectIds.map((projectId) => [ + projectId, + filterFamiliarsForProject(permissions!, rosterResult.roster, projectId, "session-launch"), + ]), + ); + const roster = projectIds.length === 0 + ? rosterResult.roster + : projectIds.length === 1 + ? rostersByProject.get(projectIds[0]) ?? [] + : [...new Map( + [...rostersByProject.values()].flat().map((familiar) => [familiar.id, familiar]), + ).values()]; // Pass `emoji` through — it's the daemon-provided default glyph the // glyph picker uses as the starting value. The Cave-local override store // (`cave-glyph-overrides.ts`) wins on render when the user picks something. @@ -64,40 +89,59 @@ export async function GET() { // when one exists, cache-busted by file mtime plus renderer format so both // content changes and server-side encoding changes refetch in desktop // WebViews. Familiars with no on-disk avatar omit it and render the glyph. - const familiars = await Promise.all( - rosterResult.roster.map(async (f) => { - const configEntry = config.familiars[f.id] ?? {}; - const binding = bindingFor(config, f.id); - const avatar = await resolveFamiliarAvatar(f.id); - return { - ...f, - display_name: binding.display_name ?? f.display_name, - role: binding.role ?? f.role, - pronouns: binding.pronouns ?? f.pronouns, - description: binding.description ?? f.description, - color: binding.color, - harness: binding.harness, - defaultHarness: config.defaults.harness, - harnessOverride: configEntry.harness ?? null, - model: binding.model, - note: binding.note, - voiceProvider: binding.voiceProvider, - voiceModel: binding.voiceModel, - voiceName: binding.voiceName, - imageProvider: binding.imageProvider, - imageModel: binding.imageModel, - imageSize: binding.imageSize, - imageQuality: binding.imageQuality, - autoSelfReport: configEntry.autoSelfReport ?? false, - asanaEnabled: configEntry.asanaEnabled, - asanaWorkspaceGid: configEntry.asanaWorkspaceGid, - ...(binding.omnigent ? { omnigent: binding.omnigent } : {}), - avatarUrl: avatar - ? `/api/familiars/${encodeURIComponent(f.id)}/avatar?v=${Math.round(avatar.mtimeMs)}&format=png` - : undefined, - }; - }), - ); + const enrichFamiliar = async (f: (typeof rosterResult.roster)[number]) => { + const configEntry = config.familiars[f.id] ?? {}; + const binding = bindingFor(config, f.id); + const avatar = await resolveFamiliarAvatar(f.id); + return { + ...f, + display_name: binding.display_name ?? f.display_name, + role: binding.role ?? f.role, + pronouns: binding.pronouns ?? f.pronouns, + description: binding.description ?? f.description, + color: binding.color, + harness: binding.harness, + defaultHarness: config.defaults.harness, + harnessOverride: configEntry.harness ?? null, + model: binding.model, + note: binding.note, + voiceProvider: binding.voiceProvider, + voiceModel: binding.voiceModel, + voiceName: binding.voiceName, + imageProvider: binding.imageProvider, + imageModel: binding.imageModel, + imageSize: binding.imageSize, + imageQuality: binding.imageQuality, + autoSelfReport: configEntry.autoSelfReport ?? false, + asanaEnabled: configEntry.asanaEnabled, + asanaWorkspaceGid: configEntry.asanaWorkspaceGid, + ...(binding.omnigent ? { omnigent: binding.omnigent } : {}), + avatarUrl: avatar + ? `/api/familiars/${encodeURIComponent(f.id)}/avatar?v=${Math.round(avatar.mtimeMs)}&format=png` + : undefined, + }; + }; + const familiars = await Promise.all(roster.map(enrichFamiliar)); + + // The table can show cards from many projects at once. Fetching the daemon + // roster once per project is especially expensive for hub and remote-host + // installs, so repeated projectId parameters return each filtered roster + // from one config/permissions/daemon snapshot. + if (projectIds.length > 1) { + const familiarById = new Map(familiars.map((familiar) => [familiar.id, familiar])); + return NextResponse.json({ + ok: true, + familiarsByProject: Object.fromEntries( + projectIds.map((projectId) => [ + projectId, + (rostersByProject.get(projectId) ?? []).flatMap((familiar) => { + const enriched = familiarById.get(familiar.id); + return enriched ? [enriched] : []; + }), + ]), + ), + }); + } return NextResponse.json({ ok: true, familiars }); } diff --git a/src/components/board-chat-link.test.ts b/src/components/board-chat-link.test.ts index 48a796fe0..9f0e98c7e 100644 --- a/src/components/board-chat-link.test.ts +++ b/src/components/board-chat-link.test.ts @@ -17,6 +17,11 @@ assert.match( /fetch\(`\/api\/board\/\$\{id\}\/chat`, \{[\s\S]*method: "POST"/, "Task chat action should POST to the board chat link endpoint", ); +assert.match( + boardView, + /if \(card\?\.projectId && !card\.familiarId\) \{[\s\S]{0,260}Choose an authorized familiar before starting work in this project\.[\s\S]{0,180}return null;[\s\S]{0,260}const fallbackFamiliarId = card\?\.familiarId \?\? activeFamiliarId/, + "project-backed task work must not fall back to an unrelated active familiar when no authorized familiar is assigned", +); assert.doesNotMatch( boardView, /onJumpToSession\?\.\(json\.sessionId, json\.familiarId/, @@ -32,6 +37,11 @@ assert.match( /const openTaskWork = async \(id: string\) =>/, "BoardView should expose one task-scoped work entry path", ); +assert.match( + boardView, + /if \(card\.sessionId && !card\.projectId\)/, + "project-backed task sessions must revisit the board endpoint for current authorization before opening", +); assert.match( boardView, /if \(isMobile\)[\s\S]*onJumpToSession\?\.\(/, @@ -87,6 +97,11 @@ assert.match( /projectById\(card\.projectId, await loadProjects\(\)\)[\s\S]{0,900}assertProjectAccess\(\{ familiarId \}, assignedProject\.id, "session-launch"\)/, "Board chat endpoint should resolve assigned project roots server-side and authorize the familiar", ); +assert.match( + route, + /assertProjectAccess\(\{ familiarId \}, assignedProject\.id, "session-launch"\)[\s\S]{0,700}if \(card\.sessionId\) \{[\s\S]{0,300}reused: true/, + "a project-linked session is reused only after the current familiar passes project authorization", +); assert.match( route, /project root does not match assigned task project/, diff --git a/src/components/board-inspector.tsx b/src/components/board-inspector.tsx index 43acb84b4..03a4afa59 100644 --- a/src/components/board-inspector.tsx +++ b/src/components/board-inspector.tsx @@ -44,6 +44,7 @@ import { attachmentIcon, fileToAttachment, hasDraggedFiles } from "@/lib/chat-at import type { CardPatch } from "@/lib/board-card-ops"; import { sessionStatusTone, sessionStatusWord } from "@/lib/session-status"; import { BoardInspectorDebug } from "@/components/board-inspector-debug"; +import { useProjectFamiliars } from "@/lib/use-project-familiars"; const DEFAULT_TIMEOUT_MS = 2 * 60 * 60 * 1000; @@ -278,7 +279,9 @@ function GitHubAttachSection({ const fam = familiars.find( (f) => f.display_name?.toLowerCase() === item.repo?.toLowerCase() ); - if (fam) onPatch(card.id, { familiarId: fam.id }); + // A session carries the prior familiar's harness/runtime context. Do not + // relabel it as this familiar merely because a GitHub assignment matched. + if (fam) onPatch(card.id, { familiarId: fam.id, sessionId: null }); } const iconName = (k: string) => (KIND_ICON[k] ?? "ph:link") as IconName; @@ -1171,6 +1174,38 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o ]; const resolvedFamiliarList = useResolvedFamiliars(currentFamiliar ? [currentFamiliar] : [], { includeArchived: true }); const resolvedFamiliar = resolvedFamiliarList[0] ?? null; + const { + familiars: eligibleFamiliars, + loading: eligibleFamiliarsLoading, + loadedSuccessfully: eligibleFamiliarsLoaded, + } = useProjectFamiliars({ projectId: card.projectId ?? null }); + + // Preserve an assignment that remains authorized, but fail closed if a + // project edit makes the card's familiar ineligible for its task launch. + useEffect(() => { + if (!card.projectId || !card.familiarId || !eligibleFamiliarsLoaded) return; + if (!eligibleFamiliars.some((familiar) => familiar.id === card.familiarId)) { + // A linked session belongs to the prior familiar/project context. Keep + // it from being reopened after the task is reassigned to an authorized + // familiar, which can use a different runtime or harness. + onPatch(card.id, { familiarId: null, sessionId: null }); + } + }, [card.familiarId, card.id, card.projectId, eligibleFamiliars, eligibleFamiliarsLoaded, onPatch]); + + const familiarPickerReady = !card.projectId || (eligibleFamiliarsLoaded && !eligibleFamiliarsLoading); + const familiarOptions = !card.projectId + ? [ + { value: "", label: "Unassigned" }, + ...familiars.map((familiar) => ({ value: familiar.id, label: familiar.display_name })), + ] + : eligibleFamiliarsLoading + ? [{ value: "", label: "Loading authorized familiars…", disabled: true }] + : !eligibleFamiliarsLoaded + ? [{ value: "", label: "Could not load authorized familiars", disabled: true }] + : [ + { value: "", label: "Unassigned" }, + ...eligibleFamiliars.map((familiar) => ({ value: familiar.id, label: familiar.display_name })), + ]; const close = () => { setClosing(true); setTimeout(onClose, 180); }; @@ -1275,6 +1310,61 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o +
+
+ Project + +
+
+ + + + { + const selectedProject = projects.find((project) => project.id === next) ?? null; + // A Project → Familiar assignment is only valid after the + // target project's authorized roster resolves. Clear an + // existing familiar immediately so Start work cannot race + // that lookup with the old project's assignment. + onPatch(card.id, { + projectId: selectedProject?.id ?? null, + cwd: selectedProject?.root ?? null, + familiarId: null, + sessionId: null, + }); + }} + options={[ + { value: "", label: "No project" }, + ...projects.map((project) => ({ value: project.id, label: project.name })), + ]} + showCaret={false} + /> + +
+ {projects.length === 0 ? ( +

+ No projects yet. Open Projects to add one, then choose it here. +

+ ) : null} + {projects.length > 0 && !card.projectId && !card.cwd ? ( +

+ No project set — task work can't start, and linked sessions won't open in the + right project, until you pick one. +

+ ) : null} +
+
Familiar
@@ -1288,15 +1378,19 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o { setModelCustomMode(false); - persistTaskModelPatch({ familiarId: next || null, ...taskModelPatch(null) }); + // Rebinding changes the runtime context; its linked session and + // task-specific model cannot safely follow the new familiar. + persistTaskModelPatch({ + familiarId: next || null, + sessionId: null, + ...taskModelPatch(null), + }); }} - options={[ - { value: "", label: "Unassigned" }, - ...familiars.map((f) => ({ value: f.id, label: f.display_name })), - ]} + options={familiarOptions} + disabled={!familiarPickerReady} showCaret={false} /> @@ -1373,51 +1467,6 @@ export function BoardInspector({ card, familiars, sessions, projects, onClose, o
-
-
- Project - -
-
- - - - { - const selectedProject = projects.find((project) => project.id === next) ?? null; - onPatch(card.id, { projectId: selectedProject?.id ?? null, cwd: selectedProject?.root ?? null }); - }} - options={[ - { value: "", label: "No project" }, - ...projects.map((project) => ({ value: project.id, label: project.name })), - ]} - showCaret={false} - /> - -
- {projects.length === 0 ? ( -

- No projects yet. Open Projects to add one, then choose it here. -

- ) : null} - {projects.length > 0 && !card.projectId && !card.cwd ? ( -

- No project set — task work can't start, and linked sessions won't open in the - right project, until you pick one. -

- ) : null} -
diff --git a/src/components/board-project-picker.test.ts b/src/components/board-project-picker.test.ts index 0ef70cfe3..c4bb55648 100644 --- a/src/components/board-project-picker.test.ts +++ b/src/components/board-project-picker.test.ts @@ -20,8 +20,8 @@ assert.match( ); assert.match( inspector, - /onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/, - "changing the inspector project picker patches projectId and its persisted cwd", + /onPatch\(card\.id, \{[\s\S]{0,180}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,180}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,180}familiarId: null,[\s\S]{0,180}sessionId: null,[\s\S]{0,180}\}\)/, + "changing the inspector project picker clears the old familiar and session before persisting the new project root", ); assert.match(inspector, /\{ value: "", label: "No project" \}/, "inspector offers a No-project option"); assert.match( @@ -34,27 +34,72 @@ assert.match( /Open Projects/, "inspector offers a direct route to the project creation surface", ); +assert.ok( + inspector.indexOf('label="Project"') < inspector.indexOf('label="Familiar"'), + "inspector presents Project before Familiar", +); +assert.match( + inspector, + /useProjectFamiliars\(\{ projectId: card\.projectId \?\? null \}\)/, + "inspector requests only project-authorized familiars", +); +assert.match( + inspector, + /value=\{familiarPickerReady \? card\.familiarId \?\? "" : ""\}[\s\S]{0,700}disabled=\{!familiarPickerReady\}/, + "inspector disables the familiar choice until its project authorization result is ready", +); +assert.match( + inspector, + /const familiarPickerReady = !card\.projectId \|\| \(eligibleFamiliarsLoaded && !eligibleFamiliarsLoading\)/, + "inspector keeps the familiar picker available for unscoped cards", +); +assert.match( + inspector, + /const familiarOptions = !card\.projectId[\s\S]{0,360}\.{3}familiars\.map\(\(familiar\)/, + "inspector preserves the complete familiar roster for unscoped cards", +); +assert.match( + inspector, + /!eligibleFamiliars\.some\([\s\S]{0,500}onPatch\(card\.id, \{ familiarId: null, sessionId: null \}\)/, + "inspector clears an existing familiar and its stale session when it is ineligible for the selected project", +); +assert.match( + inspector, + /label="Familiar"[\s\S]{0,700}onChange=\{\(next\) => \{[\s\S]{0,500}familiarId: next \|\| null,[\s\S]{0,150}sessionId: null,[\s\S]{0,150}\.\.\.taskModelPatch\(null\)/, + "changing an authorized familiar unlinks its prior runtime session and model", +); // ── New-card modal can set a project at creation time ────────────────────── assert.match(newCard, /projectId: string \| null;/, "NewCardDraft carries projectId"); -// The modal sources its own familiar-scoped project list (rather than taking an -// unscoped `projects` prop) so the Project picker only offers projects the -// assigned familiar has been granted — matching the server-side grant filter. assert.match( newCard, - /useProjects\(\{ familiarId, enabled: open \}\)/, - "new-card modal scopes its project list to the selected familiar", + /useProjects\(\{ enabled: open \}\)/, + "new-card modal loads the project list before choosing a familiar", ); assert.match(newCard, /setProjectId\(null\)/, "new-card modal resets projectId when reopened"); assert.match( newCard, - /setFamiliarId\(v \|\| null\);[\s\S]{0,400}setProjectId\(null\);/, - "switching the familiar clears the selected project so an ungranted project can't ride along the re-scope", + /useProjectFamiliars\(\{ projectId, enabled: open \}\)/, + "new-card modal fetches familiar options scoped to its selected project", +); +assert.match( + newCard, + /!eligibleFamiliars\.some\([\s\S]{0,180}setFamiliarId\(null\);[\s\S]{0,100}setSessionId\(null\);/, + "new-card modal clears an incompatible familiar and linked session after a project change", +); +assert.ok( + newCard.indexOf('') < newCard.indexOf(''), + "new-card modal presents Project before Familiar", +); +assert.match( + newCard, + /value=\{familiarPickerReady \? familiarId \?\? "" : ""\}[\s\S]{0,200}disabled=\{!familiarPickerReady\}/, + "new-card modal gates project-backed familiar choices on authorization", ); assert.match( newCard, - /[\s\S]{0,600}label: projectsLoading \? "Loading projects…" : "No project" \}/, - "new-card modal renders a Project field whose default reads 'Loading projects…' while the scoped list is in flight, else 'No project'", + /onChange=\{\(v\) => \{[\s\S]{0,700}setProjectId\(v \|\| null\);[\s\S]{0,180}setFamiliarId\(null\);[\s\S]{0,180}setSessionId\(null\);/, + "changing a new task's project immediately clears the prior familiar while its authorized roster loads", ); assert.match( newCard, diff --git a/src/components/board-table-familiar-select.test.ts b/src/components/board-table-familiar-select.test.ts index 32ac2f497..eb4c1853a 100644 --- a/src/components/board-table-familiar-select.test.ts +++ b/src/components/board-table-familiar-select.test.ts @@ -12,8 +12,23 @@ assert.match( ); assert.match( boardTable, - / onPatch\(card\.id, \{ familiarId: next \|\| null \}\)\}[\s\S]*options=\{familiarOptions\}/, - "Familiar column should render an inline StandardSelect that patches card.familiarId", + /useProjectFamiliarsByProject\(\{ projectIds \}\)/, + "Table mode fetches authorized familiar rosters for every project it displays", +); +assert.match( + boardTable, + /const rowFamiliarOptions = !projectId[\s\S]*?familiarOptions[\s\S]*?Loading authorized familiars…[\s\S]*?scopedFamiliars\.map/, + "project-backed table rows only offer their authorized roster while unscoped rows preserve every familiar", +); +assert.match( + boardTable, + /loadingProjectIds\.has\(projectId\)\s*\?\s*"Loading authorized familiars…"\s*:\s*"Could not load authorized familiars"/, + "a failed project roster load is distinguished from an in-flight request instead of displaying Loading forever", +); +assert.match( + boardTable, + / onPatch\(card\.id, \{ familiarId: next \|\| null, sessionId: null \}\)\}[\s\S]*options=\{rowFamiliarOptions\}[\s\S]*disabled=\{!familiarPickerReady\}/, + "Familiar column gates project-backed choices until authorization has loaded and unlinks the prior runtime session", ); assert.match( boardTable, diff --git a/src/components/board-table.tsx b/src/components/board-table.tsx index b24289544..e67738a43 100644 --- a/src/components/board-table.tsx +++ b/src/components/board-table.tsx @@ -11,6 +11,7 @@ import { useResolvedFamiliars } from "@/lib/familiar-resolve"; import { useRovingTabIndex } from "@/lib/use-roving-tabindex"; import { RelativeTime } from "@/components/ui/relative-time"; import { StandardSelect } from "@/components/ui/select"; +import { useProjectFamiliarsByProject } from "@/lib/use-project-familiars"; export type GroupBy = "status" | "familiar" | "project"; export type SortKey = "title" | "status" | "priority" | "familiar" | "lifecycle" | "startDate" | "endDate" | "updatedAt"; @@ -204,6 +205,19 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD ], [familiars], ); + // Table mode can assign a familiar inline, bypassing the inspector. Scope + // each project-backed row to the same server-authorized roster as the other + // Board pickers; cards with no project intentionally retain every installed + // runtime/harness choice. + const projectIds = useMemo( + () => cards.flatMap((card) => card.projectId ? [card.projectId] : []), + [cards], + ); + const { + familiarsByProject, + loadingProjectIds, + loadedProjectIds, + } = useProjectFamiliarsByProject({ projectIds }); // Columns rendered in the user's saved order; any unknown/new key is dropped // and any column missing from the saved order is appended so the table is @@ -497,6 +511,29 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD ); break; case "familiar": + { + const projectId = card.projectId; + const scopedFamiliars = projectId ? familiarsByProject.get(projectId) ?? [] : familiars; + const familiarPickerReady = !projectId || ( + loadedProjectIds.has(projectId) && !loadingProjectIds.has(projectId) + ); + const rowFamiliarOptions = !projectId + ? familiarOptions + : !familiarPickerReady + ? [{ + value: "", + label: loadingProjectIds.has(projectId) + ? "Loading authorized familiars…" + : "Could not load authorized familiars", + disabled: true, + }] + : [ + { value: "", label: "Unassigned" }, + ...scopedFamiliars.map((familiar) => ({ + value: familiar.id, + label: familiar.display_name, + })), + ]; content = ( @@ -506,13 +543,18 @@ export function BoardTable({ cards, familiars, projects, groupBy, sortKey, sortD onPatch(card.id, { familiarId: next || null })} - options={familiarOptions} + value={familiarPickerReady ? card.familiarId ?? "" : ""} + // A linked session was created with the prior familiar's + // harness/runtime. Reassignment must start fresh instead + // of relabelling that session under a different runtime. + onChange={(next) => onPatch(card.id, { familiarId: next || null, sessionId: null })} + options={rowFamiliarOptions} + disabled={!familiarPickerReady} /> ); + } break; case "lifecycle": content = ; diff --git a/src/components/board-task-model.test.ts b/src/components/board-task-model.test.ts index 57d5a4def..dcc6df812 100644 --- a/src/components/board-task-model.test.ts +++ b/src/components/board-task-model.test.ts @@ -28,7 +28,7 @@ assert.match( "the inspector renders every runtime-provided model option, including authenticated catalogs", ); assert.match(inspector, /const taskModelPatch = \(modelOverride: string \| null\): CardPatch => \(\{[\s\S]{0,120}modelOverrideHarness: modelOverride \? modelHarness \|\| null : null/, "the inspector tags each task model with its selected runtime"); -assert.match(inspector, /persistTaskModelPatch\(\{ familiarId: next \|\| null, \.\.\.taskModelPatch\(null\) \}\)/, "changing familiar clears the prior task model and runtime"); +assert.match(inspector, /persistTaskModelPatch\(\{[\s\S]{0,120}familiarId: next \|\| null,[\s\S]{0,120}sessionId: null,[\s\S]{0,120}\.\.\.taskModelPatch\(null\)/, "changing familiar clears the linked session, prior task model, and runtime"); assert.match(inspector, /label="Model"/, "inspector exposes a Model control"); assert.match( inspector, diff --git a/src/components/board-ux-polish.test.ts b/src/components/board-ux-polish.test.ts index 0a49e6930..e200637f7 100644 --- a/src/components/board-ux-polish.test.ts +++ b/src/components/board-ux-polish.test.ts @@ -153,6 +153,11 @@ assert.match( "quick-add inherits the swimlane's familiar so the card lands in the right lane", ); assert.match(view, /onQuickAdd=\{quickAdd\}/, "BoardView wires the quick-add create path"); +assert.match( + view, + /familiarId: lane\.projectId \? null : \(lane\.familiarId !== undefined \? lane\.familiarId : \(activeFamiliarId \?\? null\)\)/, + "quick-add leaves project-lane tasks unassigned until their authorized familiar is selected", +); // ── WIP limits per column (#3) ── assert.match(kanban, /wipLimits\?:\s*WipLimits/, "BoardKanban accepts WIP limits"); diff --git a/src/components/board-view.tsx b/src/components/board-view.tsx index 24f1e6ae7..0cfd1de2a 100644 --- a/src/components/board-view.tsx +++ b/src/components/board-view.tsx @@ -516,7 +516,10 @@ export function BoardView({ // Inline quick-add from a kanban column: title-only card in that column's // status, scoped to the swimlane it was dropped under (familiar/project) or - // the active familiar when ungrouped. + // the active familiar when ungrouped. Project lanes deliberately begin + // unassigned: the active familiar may use a different harness/runtime and + // lack that project's session-launch grant, so the dependent picker must + // choose an authorized familiar first. const quickAdd = async ( status: CardStatus, title: string, @@ -527,7 +530,7 @@ export function BoardView({ notes: "", status, priority: "medium", - familiarId: lane.familiarId !== undefined ? lane.familiarId : (activeFamiliarId ?? null), + familiarId: lane.projectId ? null : (lane.familiarId !== undefined ? lane.familiarId : (activeFamiliarId ?? null)), sessionId: null, projectId: lane.projectId !== undefined ? lane.projectId : null, cwd: null, @@ -744,6 +747,16 @@ export function BoardView({ projectRoot?: string, ): Promise<{ sessionId: string; familiarId: string | null } | null> => { const card = cards.find((candidate) => candidate.id === id); + // Project-backed tasks must retain an explicit familiar chosen from the + // project's authorized roster. Falling back to the active/default + // familiar here would bypass the Project → Familiar picker and produce a + // late `project access denied` for installations whose active runtime is + // not granted this project. + if (card?.projectId && !card.familiarId) { + setChatLinkError("Choose an authorized familiar before starting work in this project."); + setChatLinkErrorCardId(id); + return null; + } const fallbackFamiliarId = card?.familiarId ?? activeFamiliarId ?? familiars[0]?.id ?? null; setChatLinkingId(id); setChatLinkError(null); @@ -785,7 +798,11 @@ export function BoardView({ const openTaskWork = async (id: string) => { const card = cards.find((candidate) => candidate.id === id); if (!card) return; - if (card.sessionId) { + // Project-backed sessions must still traverse the board-chat endpoint so + // a grant revoked after the session was created cannot be reopened from + // the Board without a fresh authorization check. Unscoped legacy cards + // have no project boundary to revalidate and can keep the direct path. + if (card.sessionId && !card.projectId) { if (isMobile) { const linkedSession = sessions.find((session) => session.id === card.sessionId); onJumpToSession?.(card.sessionId, linkedSession?.familiarId ?? card.familiarId ?? null); diff --git a/src/components/new-card-modal.test.ts b/src/components/new-card-modal.test.ts index 0be9e78d6..eb7d5a0bc 100644 --- a/src/components/new-card-modal.test.ts +++ b/src/components/new-card-modal.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; const modal = readFileSync(new URL("./new-card-modal.tsx", import.meta.url), "utf8"); +const projectFamiliars = readFileSync(new URL("../lib/use-project-familiars.ts", import.meta.url), "utf8"); assert.ok(modal.includes('import { Button } from "@/components/ui/button"'), "new-card modal action buttons use the shared Button primitive"); assert.ok(modal.includes('import { StandardSelect } from "@/components/ui/select"'), "new-card modal dropdowns use StandardSelect"); @@ -10,24 +11,57 @@ assert.doesNotMatch(modal, /\(null\)/, + "project-scoped familiar results retain the project that produced them", +); +assert.match( + projectFamiliars, + /loadedSuccessfully: enabled && Boolean\(projectId\) && loadedProjectId === projectId/, + "a familiar roster from the previous project never enables the picker during a project change", +); +assert.match( + projectFamiliars, + /catch \{[\s\S]{0,220}finally/, + "a failed familiar request leaves the dependent picker in its load-failure state without an unhandled rejection", +); +assert.match( + projectFamiliars, + /for \(const projectId of ids\) search\.append\("projectId", projectId\)/, + "table project rosters are requested together so remote/hub installs do not repeat daemon lookups per project", ); console.log("new-card-modal.test.ts: ok"); diff --git a/src/components/new-card-modal.tsx b/src/components/new-card-modal.tsx index 0310f6f04..9a7194011 100644 --- a/src/components/new-card-modal.tsx +++ b/src/components/new-card-modal.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import type { Familiar, SessionRow } from "@/lib/types"; import { useProjects } from "@/lib/use-projects"; +import { useProjectFamiliars } from "@/lib/use-project-familiars"; import { Modal } from "@/components/ui/modal"; import { Button } from "@/components/ui/button"; import { PropertyPill } from "@/components/ui/property-pill"; @@ -73,11 +74,16 @@ export function NewCardModal({ const [error, setError] = useState(null); const coarse = useIsCoarsePointer(); - // Only offer projects the assigned familiar can actually reach — the board - // POST starts the card's chat under that familiar, which the server rejects - // (403) for an ungranted project. Re-scopes when the Familiar picker below - // changes; a null familiar ("Default familiar") loads the operator-wide list. - const { projects, loading: projectsLoading } = useProjects({ familiarId, enabled: open }); + // Project-backed familiar choices are fetched server-side with the + // session-launch access requirement, matching the final launch + // authorization boundary. Unscoped cards retain the complete roster so + // users can deliberately choose the installed harness/runtime they need. + const { projects, loading: projectsLoading } = useProjects({ enabled: open }); + const { + familiars: eligibleFamiliars, + loading: eligibleFamiliarsLoading, + loadedSuccessfully: eligibleFamiliarsLoaded, + } = useProjectFamiliars({ projectId, enabled: open }); useEffect(() => { if (!open) return; @@ -95,6 +101,14 @@ export function NewCardModal({ setError(null); }, [open, defaultStatus, defaultFamiliarId, defaultTitle, defaultLinks, defaultNotes, defaultLabels]); + useEffect(() => { + if (!projectId || !familiarId || !eligibleFamiliarsLoaded) return; + if (!eligibleFamiliars.some((familiar) => familiar.id === familiarId)) { + setFamiliarId(null); + setSessionId(null); + } + }, [eligibleFamiliars, eligibleFamiliarsLoaded, familiarId, projectId]); + const eligibleSessions = familiarId ? sessions.filter((s) => s.familiarId === familiarId) : sessions; @@ -102,6 +116,26 @@ export function NewCardModal({ // The selected project drives the card's working directory — there is no // free-form cwd field, so drafts never carry machine-specific paths. const selectedProject = projects.find((p) => p.id === projectId) ?? null; + const familiarPickerReady = !projectId || (eligibleFamiliarsLoaded && !eligibleFamiliarsLoading); + const familiarOptions = !projectId + ? [ + { value: "", label: "Unassigned" }, + ...familiars.map((familiar) => ({ + value: familiar.id, + label: `${familiar.display_name} · ${familiar.harness ?? "?"}`, + })), + ] + : eligibleFamiliarsLoading + ? [{ value: "", label: "Loading authorized familiars…", disabled: true }] + : !eligibleFamiliarsLoaded + ? [{ value: "", label: "Could not load authorized familiars", disabled: true }] + : [ + { value: "", label: "Unassigned" }, + ...eligibleFamiliars.map((familiar) => ({ + value: familiar.id, + label: `${familiar.display_name} · ${familiar.harness ?? "?"}`, + })), + ]; const create = async () => { if (!title.trim() || busy) return; @@ -224,38 +258,34 @@ export function NewCardModal({ /> - + setProjectId(v || null)} - options={[ - // While the familiar-scoped list is in flight, suppress the - // options entirely: the retained list belongs to the *previous* - // familiar, so offering it lets the user pick a project this - // familiar can't reach (the board chat-launch then 403s). - { value: "", label: projectsLoading ? "Loading projects…" : "No project" }, - ...(projectsLoading ? [] : projects.map((p) => ({ value: p.id, label: p.name }))), - ]} + value={familiarPickerReady ? familiarId ?? "" : ""} + onChange={(v) => { + setFamiliarId(v || null); + setSessionId(null); + }} + options={familiarOptions} + disabled={!familiarPickerReady} />
@@ -343,10 +373,12 @@ function Select({ value, onChange, options, + disabled = false, }: { value: string; onChange: (v: string) => void; - options: { value: string; label: string }[]; + options: { value: string; label: string; disabled?: boolean }[]; + disabled?: boolean; }) { return ( ); diff --git a/src/components/task-chat-cwd.test.ts b/src/components/task-chat-cwd.test.ts index 8e34a4bb1..e847263b5 100644 --- a/src/components/task-chat-cwd.test.ts +++ b/src/components/task-chat-cwd.test.ts @@ -129,17 +129,17 @@ assert.match( ); assert.match( boardInspector, - /
[\s\S]{0,120}Project<\/span>[\s\S]{0,1100}onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/, - "The task Project field should set the runtime root for chat starts", + /
[\s\S]{0,120}Project<\/span>[\s\S]{0,1700}onPatch\(card\.id, \{[\s\S]{0,180}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,180}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,180}familiarId: null,[\s\S]{0,180}\}\)/, + "The task Project field should set the runtime root and clear the prior familiar", ); assert.match( boardInspector, - /onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/, - "Changing the task project should persist both projectId and cwd", + /onPatch\(card\.id, \{[\s\S]{0,180}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,180}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,180}familiarId: null,[\s\S]{0,180}\}\)/, + "Changing the task project should persist projectId and cwd, then clear the familiar", ); assert.match( boardInspector, - / \(\{ value: project\.id, label: project\.name \}\)\)/, + / \(\{ value: project\.id, label: project\.name \}\)\)/, "The task project picker should render the persisted project registry through the shared select", ); assert.doesNotMatch( diff --git a/src/lib/board-search.test.ts b/src/lib/board-search.test.ts index 5f7fc898c..0db5c9153 100644 --- a/src/lib/board-search.test.ts +++ b/src/lib/board-search.test.ts @@ -103,5 +103,9 @@ const boardInspector = await readFile(new URL("../components/board-inspector.tsx assert.match(boardInspector, /link-item-anchor/, "Task inspector should render task links"); assert.match(boardInspector, /card\.sessionId/, "Task inspector should render task session context"); assert.match(boardInspector, /
[\s\S]{0,120}Project<\/span>/, "Task inspector should expose the task project selector"); -assert.match(boardInspector, /onPatch\(card\.id, \{ projectId: selectedProject\?\.id \?\? null, cwd: selectedProject\?\.root \?\? null \}\)/, "Task project changes should set the persisted cwd"); +assert.match( + boardInspector, + /onPatch\(card\.id, \{[\s\S]{0,160}projectId: selectedProject\?\.id \?\? null,[\s\S]{0,160}cwd: selectedProject\?\.root \?\? null,[\s\S]{0,160}familiarId: null,[\s\S]{0,160}\}\)/, + "Task project changes should set the persisted cwd and clear the prior familiar", +); assert.doesNotMatch(boardInspector, /function openCwdInExplorer|aria-label="Open CWD in directory explorer"/, "Task inspector should not expose a separate CWD open action"); diff --git a/src/lib/project-permissions.test.ts b/src/lib/project-permissions.test.ts index 944bc34c8..50ce42176 100644 --- a/src/lib/project-permissions.test.ts +++ b/src/lib/project-permissions.test.ts @@ -20,6 +20,7 @@ try { createGrantProposal, deleteAccessGroup, effectiveProjectAccess, + filterFamiliarsForProject, filterProjectsForFamiliar, grantProjectToFamiliar, listAccessibleProjects, @@ -68,6 +69,11 @@ try { ["cave"], "project picker results are filtered server-side for normal familiars", ); + assert.deepEqual( + filterFamiliarsForProject(permissions, [{ id: "nova" }, { id: "sage" }], "cave"), + [{ id: "nova" }], + "familiar picker results use session-launch access for the selected project", + ); assert.deepEqual( (await filterProjectsForFamiliar(projects, "supreme")).map((project) => project.id), [], diff --git a/src/lib/project-permissions.ts b/src/lib/project-permissions.ts index 374b4389d..95882d9f2 100644 --- a/src/lib/project-permissions.ts +++ b/src/lib/project-permissions.ts @@ -440,6 +440,25 @@ export function canAccessProject( return accessLevelSatisfies(effective.level, required); } +/** + * Filters a roster to the familiars that can use a project surface. Keeping + * this alongside the project filter makes the two sides of a Project → + * Familiar picker use the same effective direct and group-grant rules as the + * final server-side authorization check. + */ +export function filterFamiliarsForProject( + file: Pick & + Partial>, + familiars: readonly T[], + projectId: string, + surface: ProjectPermissionSurface = "session-launch", +): T[] { + const required = requiredAccessLevel(surface); + return familiars.filter((familiar) => + canAccessProject(file, { familiarId: familiar.id }, projectId, required), + ); +} + /** Every project the familiar can reach, with its effective level. */ export async function listAccessibleProjects( projects: CaveProject[], diff --git a/src/lib/use-project-familiars.test.ts b/src/lib/use-project-familiars.test.ts new file mode 100644 index 000000000..81358ed5d --- /dev/null +++ b/src/lib/use-project-familiars.test.ts @@ -0,0 +1,13 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const source = readFileSync(new URL("./use-project-familiars.ts", import.meta.url), "utf8"); + +assert.match( + source, + /ids\.length === 1 && Array\.isArray\(payload\.familiars\)[\s\S]*?\[ids\[0\]\]: payload\.familiars/, + "the batch project-familiar hook accepts the API's single-project response shape", +); + +console.log("project familiar hook response compatibility passed"); diff --git a/src/lib/use-project-familiars.ts b/src/lib/use-project-familiars.ts new file mode 100644 index 000000000..49f08b0c0 --- /dev/null +++ b/src/lib/use-project-familiars.ts @@ -0,0 +1,155 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { Familiar } from "@/lib/types"; + +export type ProjectFamiliarsState = { + familiars: Familiar[]; + loading: boolean; + loadedSuccessfully: boolean; +}; + +export type ProjectFamiliarsByProjectState = { + familiarsByProject: ReadonlyMap; + loadingProjectIds: ReadonlySet; + loadedProjectIds: ReadonlySet; +}; + +/** + * Loads only familiars that may launch a session in the selected project. + * Clearing the prior result before each fetch prevents a picker from briefly + * offering a familiar authorized for the previously selected project. + */ +export function useProjectFamiliars({ + projectId, + enabled = true, +}: { + projectId: string | null; + enabled?: boolean; +}): ProjectFamiliarsState { + const [familiars, setFamiliars] = useState([]); + const [loading, setLoading] = useState(false); + // Keep the result tied to the project that produced it. Effects run after + // render, so clearing state inside the effect alone would briefly expose + // the previous project's roster after projectId changes. + const [loadedProjectId, setLoadedProjectId] = useState(null); + const generationRef = useRef(0); + + useEffect(() => { + generationRef.current += 1; + const generation = generationRef.current; + + if (!enabled || !projectId) { + setFamiliars([]); + setLoading(false); + setLoadedProjectId(null); + return; + } + + setFamiliars([]); + setLoading(true); + setLoadedProjectId(null); + void (async () => { + try { + const response = await fetch(`/api/familiars?projectId=${encodeURIComponent(projectId)}`, { + cache: "no-store", + }); + const payload = await response.json().catch(() => null) as { ok?: boolean; familiars?: Familiar[] } | null; + if (generationRef.current !== generation) return; + if (response.ok && payload?.ok) { + setFamiliars(Array.isArray(payload.familiars) ? payload.familiars : []); + setLoadedProjectId(projectId); + } + } catch { + // Keep the picker disabled and show its existing load-failure state. + // Fetch can reject when a locally hosted or remote Cave is restarting. + } finally { + if (generationRef.current === generation) setLoading(false); + } + })(); + }, [enabled, projectId]); + + return { + familiars, + loading, + loadedSuccessfully: enabled && Boolean(projectId) && loadedProjectId === projectId, + }; +} + +/** + * Fetches the authorized roster once per distinct project. Table mode exposes + * an inline familiar picker for many cards at once, so sharing this lookup + * keeps project-backed cards constrained without hiding the complete roster + * for intentionally unscoped tasks. + */ +export function useProjectFamiliarsByProject({ + projectIds, + enabled = true, +}: { + projectIds: readonly string[]; + enabled?: boolean; +}): ProjectFamiliarsByProjectState { + const [familiarsByProject, setFamiliarsByProject] = useState>(() => new Map()); + const [loadingProjectIds, setLoadingProjectIds] = useState>(() => new Set()); + const [loadedProjectIds, setLoadedProjectIds] = useState>(() => new Set()); + const generationRef = useRef(0); + const projectIdsKey = [...new Set(projectIds.map((projectId) => projectId.trim()).filter(Boolean))] + .sort() + .join("\u0000"); + + useEffect(() => { + generationRef.current += 1; + const generation = generationRef.current; + const ids = projectIdsKey ? projectIdsKey.split("\u0000") : []; + + if (!enabled || ids.length === 0) { + setFamiliarsByProject(new Map()); + setLoadingProjectIds(new Set()); + setLoadedProjectIds(new Set()); + return; + } + + setFamiliarsByProject(new Map()); + setLoadingProjectIds(new Set(ids)); + setLoadedProjectIds(new Set()); + const search = new URLSearchParams(); + for (const projectId of ids) search.append("projectId", projectId); + void (async () => { + try { + const response = await fetch(`/api/familiars?${search}`, { cache: "no-store" }); + const payload = await response.json().catch(() => null) as { + ok?: boolean; + familiars?: Familiar[]; + familiarsByProject?: Record; + } | null; + if (!response.ok || !payload?.ok) return; + if (generationRef.current !== generation) return; + // `/api/familiars?projectId=…` deliberately retains its established + // single-project `{ familiars }` response for inspector/modal callers. + // A board can currently contain only one distinct project, though, in + // which case this batch hook makes that same one-id request. Accept + // both response forms so the inline picker works for single-project + // boards and during client/server version-skewed desktop updates. + const familiarsByProject = payload.familiarsByProject + ?? (ids.length === 1 && Array.isArray(payload.familiars) + ? { [ids[0]]: payload.familiars } + : null); + if (!familiarsByProject) return; + const loaded = ids.map((projectId) => [ + projectId, + Array.isArray(familiarsByProject[projectId]) + ? familiarsByProject[projectId] + : [], + ] as const); + setFamiliarsByProject(new Map(loaded)); + setLoadedProjectIds(new Set(ids)); + } catch { + // Keep every affected picker disabled and show its existing failure state. + } finally { + if (generationRef.current === generation) setLoadingProjectIds(new Set()); + } + })(); + }, [enabled, projectIdsKey]); + + return { familiarsByProject, loadingProjectIds, loadedProjectIds }; +}