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
1 change: 1 addition & 0 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion src/app/api/board/[id]/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 16 additions & 1 deletion src/app/api/familiars/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
114 changes: 79 additions & 35 deletions src/app/api/familiars/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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 });
}

Expand Down
15 changes: 15 additions & 0 deletions src/components/board-chat-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand All @@ -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\?\.\(/,
Expand Down Expand Up @@ -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/,
Expand Down
Loading
Loading