Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d5ff3c1
feat(agents): add canvas_read and canvas_write scopes
jaylfc Jul 14, 2026
9874328
fix(agents): drop unused SCOPE_LABELS map in ConsentActions
jaylfc Jul 14, 2026
5c3d5e2
feat(agents): canvas routes join the agent-token allowlist (#1800 sli…
jaylfc Jul 14, 2026
45eb164
Merge branch 'feat/lead-identity-mw-allowlist' into feat/lead-identit…
jaylfc Jul 14, 2026
f5f0253
feat(agents): consent approval sets registry handle from sanitized id…
jaylfc Jul 14, 2026
297fd18
fix(agents): reject blank project_id for project-scoped grants
jaylfc Jul 14, 2026
e97bdc1
feat(canvas): agent scope + permission gating and honest attribution
jaylfc Jul 14, 2026
be2ed6e
fix(agents): escape the dot in the canvas snapshot allowlist regexes
jaylfc Jul 14, 2026
bffab44
fix(canvas): fold adversarial review findings 1, 2, 4 into route gating
jaylfc Jul 14, 2026
e02e87f
feat(projects): members canvas checkboxes + exclusive Lead (#1800 sli…
jaylfc Jul 14, 2026
1c05d54
feat(canvas): payload cap + agent write rate limit (#1800 slice 5)
jaylfc Jul 14, 2026
98a31bd
feat(canvas): gate stream + snapshot + keepalive recheck (#1800 slice 4)
jaylfc Jul 14, 2026
de21830
fix(agents): close consent handle-set TOCTOU and active-without-handl…
jaylfc Jul 14, 2026
4f5ee42
Docs-Reviewed: restore slice-3 enforcement tests dropped during slice…
jaylfc Jul 14, 2026
81a84c4
test(agents): update ConsentActions test to the new project-picker label
jaylfc Jul 14, 2026
29c1e6c
Merge remote-tracking branch 'origin/feat/lead-identity-scope-vocab' …
jaylfc Jul 15, 2026
d70128e
Merge remote-tracking branch 'origin/feat/lead-identity-mw-allowlist'…
jaylfc Jul 15, 2026
91ee945
Merge remote-tracking branch 'origin/feat/lead-identity-route-gating'…
jaylfc Jul 15, 2026
d4ff06e
Merge remote-tracking branch 'origin/feat/lead-identity-stream-gating…
jaylfc Jul 15, 2026
3cb2155
Merge remote-tracking branch 'origin/feat/lead-identity-limits' into …
jaylfc Jul 15, 2026
a1aef1c
Merge remote-tracking branch 'origin/feat/lead-identity-members-ui' i…
jaylfc Jul 15, 2026
aa44876
Merge remote-tracking branch 'origin/feat/lead-identity-consent-handl…
jaylfc Jul 15, 2026
6dd90e9
fix(agents): create active-handle index after status migration
jaylfc Jul 15, 2026
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
181 changes: 158 additions & 23 deletions desktop/src/apps/ProjectsApp/ProjectMembers.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, act } from "@testing-library/react";
import { render, screen, act, fireEvent, within } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ProjectMembers } from "./ProjectMembers";
import type { Project } from "@/lib/projects";
Expand All @@ -24,27 +24,22 @@ async function flush() {
});
}

const project = { id: "prj-test", name: "taOS", slug: "taos" } as unknown as Project;
const baseProject = { id: "prj-test", name: "taOS", slug: "taos" } as unknown as Project;

// A consent-flow external agent: the member row keys on the canonical id, and
// the registry entry has an EMPTY handle (this is what the approve flow writes).
const externalMember = {
project_id: "prj-test",
member_id: "grok-taos-20260711-000736",
member_kind: "native",
role: "member",
is_lead: 0,
can_edit_canvas: 0,
};
const agentA = { id: "agent-a", name: "Alpha", display_name: "Alpha", emoji: "🅰️" };
const agentB = { id: "agent-b", name: "Beta", display_name: "Beta", emoji: "🅱️" };

const registryEntry = {
canonical_id: "grok-taos-20260711-000736",
handle: "",
display_name: "grok-taOS",
framework: "grok",
origin: "external-selfjoin",
status: "active",
};
function memberRow(memberId: string) {
return {
project_id: "prj-test",
member_id: memberId,
member_kind: "native",
role: "member",
is_lead: 0,
can_edit_canvas: 0,
can_read_canvas: 0,
};
}

describe("ProjectMembers external-agent categorisation", () => {
beforeEach(() => {
Expand All @@ -67,10 +62,150 @@ describe("ProjectMembers external-agent categorisation", () => {
// The section heading only renders when at least one member is classified
// external, so its presence is the regression guard: before the canonical-id
// match this agent fell into the plain Members list.
expect(screen.getByText("External / Connected agents")).toBeInTheDocument();
expect(screen.getByText("grok-taOS")).toBeInTheDocument();
const externalSection = screen.getByText("External / Connected agents").closest("section")!;
expect(externalSection).toBeInTheDocument();
expect(within(externalSection!).getByText("grok-taOS")).toBeInTheDocument();
// The framework badge resolves via the canonical-id keyed lookup, and "grok"
// (not only "grok-build") maps to the friendly Grok label.
expect(screen.getByText("Grok")).toBeInTheDocument();
expect(within(externalSection!).getByText("Grok")).toBeInTheDocument();
});
});

describe("ProjectMembers canvas capability checkboxes (slice 6)", () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it("renders both a read and an edit canvas checkbox for an agent member", async () => {
vi.stubGlobal(
"fetch",
mockFetch({
"/api/projects/prj-test/members": {
ok: true,
body: { items: [memberRow("agent-a")] },
},
"/api/agents": { ok: true, body: [agentA] },
"/api/agents/registry": { ok: true, body: [] },
}),
);

render(<ProjectMembers project={baseProject} onChanged={vi.fn()} />);
await flush();

expect(screen.getByText("Can read canvas")).toBeInTheDocument();
expect(screen.getByText("Can edit canvas")).toBeInTheDocument();
expect(
screen.getByLabelText("Can read canvas for Alpha"),
).toBeInTheDocument();
expect(
screen.getByLabelText("Can edit canvas for Alpha"),
).toBeInTheDocument();
});
});

describe("ProjectMembers exclusive Lead selector (D7)", () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it("reflects the current lead and promotes a new one exclusively via setLead", async () => {
const fetchMock = mockFetch({
"/api/projects/prj-test/members": {
ok: true,
body: { items: [memberRow("agent-a"), memberRow("agent-b")] },
},
"/api/agents": { ok: true, body: [agentA, agentB] },
"/api/agents/registry": { ok: true, body: [] },
"/api/projects/prj-test/lead": { ok: true, body: { ok: true, lead_member_id: "agent-b" } },
});
vi.stubGlobal("fetch", fetchMock);

const project = {
...baseProject,
lead_member_id: "agent-a",
} as unknown as Project;

render(<ProjectMembers project={project} onChanged={vi.fn()} />);
await flush();

const select = screen.getByLabelText("Project lead") as HTMLSelectElement;
// The project's exclusive lead is pre-selected.
expect(select.value).toBe("agent-a");

// Promote Beta. Because the lead is a single project pointer, only one
// option can ever be selected, so setting a new lead is inherently
// exclusive (the server unsets the previous one).
await act(async () => {
fireEvent.change(select, { target: { value: "agent-b" } });
await flush();
});

expect(fetchMock).toHaveBeenCalledWith(
"/api/projects/prj-test/lead",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ member_id: "agent-b" }),
}),
);
expect((screen.getByLabelText("Project lead") as HTMLSelectElement).value).toBe("agent-b");
});

it("offers a 'No lead' option that clears the lead via setLead(null)", async () => {
const fetchMock = mockFetch({
"/api/projects/prj-test/members": {
ok: true,
body: { items: [memberRow("agent-a")] },
},
"/api/agents": { ok: true, body: [agentA] },
"/api/agents/registry": { ok: true, body: [] },
"/api/projects/prj-test/lead": { ok: true, body: { ok: true, lead_member_id: null } },
});
vi.stubGlobal("fetch", fetchMock);

const project = {
...baseProject,
lead_member_id: "agent-a",
} as unknown as Project;

render(<ProjectMembers project={project} onChanged={vi.fn()} />);
await flush();

const select = screen.getByLabelText("Project lead") as HTMLSelectElement;
expect(select.value).toBe("agent-a");

await act(async () => {
fireEvent.change(select, { target: { value: "" } });
await flush();
});

expect(fetchMock).toHaveBeenCalledWith(
"/api/projects/prj-test/lead",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ member_id: null }),
}),
);
});
});

const project = { id: "prj-test", name: "taOS", slug: "taos" } as unknown as Project;

// A consent-flow external agent: the member row keys on the canonical id, and
// the registry entry has an EMPTY handle (this is what the approve flow writes).
const externalMember = {
project_id: "prj-test",
member_id: "grok-taos-20260711-000736",
member_kind: "native",
role: "member",
is_lead: 0,
can_edit_canvas: 0,
};

const registryEntry = {
canonical_id: "grok-taos-20260711-000736",
handle: "",
display_name: "grok-taOS",
framework: "grok",
origin: "external-selfjoin",
status: "active",
};
80 changes: 70 additions & 10 deletions desktop/src/apps/ProjectsApp/ProjectMembers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function MemberRow({
isExternal,
framework,
projectId,
isLead,
onRefresh,
onChanged,
}: {
Expand All @@ -85,6 +86,7 @@ function MemberRow({
isExternal?: boolean;
framework?: string;
projectId: string;
isLead?: boolean;
onRefresh: () => void;
onChanged: () => void;
}) {
Expand Down Expand Up @@ -115,7 +117,7 @@ function MemberRow({
{typeLabel}
</span>
)}
{!!member.is_lead && (
{isLead && (
<span className="ml-1 text-xs text-yellow-400 font-medium" aria-label="Lead agent">
★ Lead
</span>
Expand All @@ -129,36 +131,37 @@ function MemberRow({
{isAgent && (
<label
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
title="When off, this agent can add new elements but cannot modify or delete existing ones."
title="When on, this agent can read (list, stream, snapshot) the canvas."
>
<input
type="checkbox"
checked={!!member.can_edit_canvas}
checked={!!member.can_read_canvas}
aria-label={`Can read canvas for ${label}`}
onChange={async (e) => {
await canvasApi.setPermission(projectId, member.member_id, e.target.checked);
await canvasApi.setPermission(projectId, member.member_id, "read", e.target.checked);
onRefresh();
onChanged();
}}
/>
<span className="text-xs">Can edit canvas</span>
<span className="text-xs">Can read canvas</span>
</label>
)}
{isAgent && (
<label
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
title="Lead agents see all messages in the project channel, even without being @mentioned."
title="When off, this agent can add new elements but cannot modify or delete existing ones."
>
<input
type="checkbox"
checked={!!member.is_lead}
aria-label={`Toggle lead for ${label}`}
checked={!!member.can_edit_canvas}
aria-label={`Can edit canvas for ${label}`}
onChange={async (e) => {
await projectsApi.members.setLead(projectId, member.member_id, e.target.checked);
await canvasApi.setPermission(projectId, member.member_id, "edit", e.target.checked);
onRefresh();
onChanged();
}}
/>
<span className="text-xs">Lead</span>
<span className="text-xs">Can edit canvas</span>
</label>
)}
<button
Expand All @@ -184,6 +187,32 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
const [externalAgents, setExternalAgents] = useState<ExternalAgentSummary[]>([]);
const [externalRegistryLoaded, setExternalRegistryLoaded] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
// D7: the Lead is an exclusive, project-level designation. We keep a local
// mirror of project.lead_member_id so the selector reflects changes instantly
// (the backend enforces the one-lead invariant structurally on write).
const [leadMemberId, setLeadMemberId] = useState<string | null>(project.lead_member_id ?? null);

useEffect(() => {
setLeadMemberId(project.lead_member_id ?? null);
}, [project.lead_member_id]);

const handleLeadChange = async (value: string) => {
const next = value || null;
setLeadMemberId(next);
try {
await projectsApi.setLead(project.id, next);
} catch {
// Revert on failure so the UI stays consistent with the server.
setLeadMemberId(project.lead_member_id ?? null);
}
onChanged();
Comment on lines +199 to +208

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Serialize lead updates and ignore stale completions.

Rapid changes issue concurrent PATCH requests, so response/write ordering can leave the server with an earlier selection. Disable the selector while saving or queue writes, and guard completion handling against project.id changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src/apps/ProjectsApp/ProjectMembers.tsx` around lines 199 - 208,
Update handleLeadChange in ProjectMembers so lead changes cannot issue
concurrent setLead requests: disable the selector while saving or serialize
updates through a queue. Track the project identity and requested value for each
save, and only apply success or rollback state when the completion still belongs
to the current project and latest request; ensure onChanged is likewise not
triggered by stale completions.

};

const labelFor = (m: ProjectMember): string => {
if (byId.has(m.member_id)) return formatMemberLabel(m.member_id, byId).label;
if (byHandle.has(m.member_id)) return formatExternalMemberLabel(m.member_id, byHandle).label;
return m.member_id;
};

const refresh = () =>
projectsApi.members.list(project.id).then(setMembers).catch(() => setMembers([]));
Expand Down Expand Up @@ -289,6 +318,11 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
return { mainMembers: main, externalMembers: external };
}, [members, byId, byHandle, externalRegistryLoaded]);

const leadOptions = useMemo(
() => [...mainMembers, ...externalMembers],
[mainMembers, externalMembers],
);

return (
<section>
<header className="flex justify-between mb-3">
Expand All @@ -301,6 +335,30 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
+ Add agent
</button>
</header>
<div className="mb-3 flex flex-wrap items-center gap-2">
<label htmlFor="project-lead-select" className="text-xs text-zinc-400">
Lead
</label>
<select
id="project-lead-select"
aria-label="Project lead"
value={leadMemberId ?? ""}
onChange={(e) => {
void handleLeadChange(e.target.value);
}}
className="text-xs bg-zinc-800 border border-zinc-700 rounded px-2 py-1"
>
<option value="">No lead</option>
{leadOptions.map((m) => (
<option key={m.member_id} value={m.member_id}>
{labelFor(m)}
</option>
))}
</select>
<span className="text-[10px] text-zinc-500">
exclusive per project
</span>
</div>
<ul className="space-y-1" aria-label="Project members">
{mainMembers.map((m) => {
const { label, emoji, hint } = formatMemberLabel(m.member_id, byId);
Expand All @@ -311,6 +369,7 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
label={label}
emoji={emoji}
hint={hint}
isLead={m.member_id === leadMemberId}
projectId={project.id}
onRefresh={refresh}
onChanged={onChanged}
Expand All @@ -330,6 +389,7 @@ export function ProjectMembers({ project, onChanged }: { project: Project; onCha
member={m}
label={label}
hint={hint}
isLead={m.member_id === leadMemberId}
isExternal
framework={byHandle.get(m.member_id)?.framework}
projectId={project.id}
Expand Down
11 changes: 9 additions & 2 deletions desktop/src/apps/ProjectsApp/canvas/canvas-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,22 @@ export const canvasApi = {
return r.ok;
},

// Set a single canvas capability checkbox for an agent member. `flag` selects
// which capability to flip: "read" (can_read_canvas) or "edit" (can_edit_canvas).
// Both default OFF in the store, so the human ticks exactly what each agent
// may do. The backend permission PATCH accepts either field independently.
async setPermission(
projectId: string, agentId: string, canEdit: boolean,
projectId: string, agentId: string, flag: "read" | "edit", allowed: boolean,
): Promise<void> {
const body = flag === "read"
? { can_read_canvas: allowed }
: { can_edit_canvas: allowed };
const r = await fetch(
`/api/projects/${projectId}/canvas/permissions/${agentId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ can_edit_canvas: canEdit }),
body: JSON.stringify(body),
},
);
if (!r.ok) throw new Error(`setPermission failed: ${r.status}`);
Expand Down
Loading
Loading