diff --git a/desktop/src/apps/ProjectsApp/ProjectMembers.test.tsx b/desktop/src/apps/ProjectsApp/ProjectMembers.test.tsx
index 84749962a..d9ae4b589 100644
--- a/desktop/src/apps/ProjectsApp/ProjectMembers.test.tsx
+++ b/desktop/src/apps/ProjectsApp/ProjectMembers.test.tsx
@@ -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";
@@ -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(() => {
@@ -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();
+ 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();
+ 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();
+ 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",
+};
diff --git a/desktop/src/apps/ProjectsApp/ProjectMembers.tsx b/desktop/src/apps/ProjectsApp/ProjectMembers.tsx
index 19dab0e43..5e580a5e9 100644
--- a/desktop/src/apps/ProjectsApp/ProjectMembers.tsx
+++ b/desktop/src/apps/ProjectsApp/ProjectMembers.tsx
@@ -75,6 +75,7 @@ function MemberRow({
isExternal,
framework,
projectId,
+ isLead,
onRefresh,
onChanged,
}: {
@@ -85,6 +86,7 @@ function MemberRow({
isExternal?: boolean;
framework?: string;
projectId: string;
+ isLead?: boolean;
onRefresh: () => void;
onChanged: () => void;
}) {
@@ -115,7 +117,7 @@ function MemberRow({
{typeLabel}
)}
- {!!member.is_lead && (
+ {isLead && (
★ Lead
@@ -129,36 +131,37 @@ function MemberRow({
{isAgent && (
)}
{isAgent && (
)}
+
+
+
+
+ exclusive per project
+
+
{mainMembers.map((m) => {
const { label, emoji, hint } = formatMemberLabel(m.member_id, byId);
@@ -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}
@@ -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}
diff --git a/desktop/src/apps/ProjectsApp/canvas/canvas-api.ts b/desktop/src/apps/ProjectsApp/canvas/canvas-api.ts
index 432dac652..8a41f2506 100644
--- a/desktop/src/apps/ProjectsApp/canvas/canvas-api.ts
+++ b/desktop/src/apps/ProjectsApp/canvas/canvas-api.ts
@@ -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 {
+ 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}`);
diff --git a/desktop/src/components/ConsentActions.test.tsx b/desktop/src/components/ConsentActions.test.tsx
index 0924aaa6a..bda6dd1fe 100644
--- a/desktop/src/components/ConsentActions.test.tsx
+++ b/desktop/src/components/ConsentActions.test.tsx
@@ -97,7 +97,7 @@ describe("ConsentActions", () => {
,
);
// The picker appears and the single project is auto-selected.
- await screen.findByLabelText(/Grant project_tasks for project/i);
+ await screen.findByLabelText(/Grant project access for/i);
fireEvent.click(screen.getByRole("button", { name: /allow/i }));
await waitFor(() => expect(onResolved).toHaveBeenCalledTimes(1));
@@ -125,7 +125,7 @@ describe("ConsentActions", () => {
vi.stubGlobal("fetch", fetchMock);
render();
- const select = await screen.findByLabelText(/Grant project_tasks for project/i);
+ const select = await screen.findByLabelText(/Grant project access for/i);
expect(screen.getByRole("button", { name: /allow/i })).toBeDisabled();
fireEvent.change(select, { target: { value: "p2" } });
@@ -151,7 +151,7 @@ describe("ConsentActions", () => {
const onResolved = vi.fn();
render();
- await screen.findByLabelText(/Grant project_tasks for project/i);
+ await screen.findByLabelText(/Grant project access for/i);
expect(screen.getByRole("button", { name: /allow/i })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: /new/i }));
diff --git a/desktop/src/components/ConsentActions.tsx b/desktop/src/components/ConsentActions.tsx
index 6ec60f538..b8684edae 100644
--- a/desktop/src/components/ConsentActions.tsx
+++ b/desktop/src/components/ConsentActions.tsx
@@ -15,7 +15,7 @@ import { CheckCircle, XCircle } from "lucide-react";
* right project at approval time. The chosen project id is passed to the approve
* endpoint, which mints the token bound to it.
*/
-const PROJECT_SCOPE = "project_tasks";
+const PROJECT_SCOPES = new Set(["project_tasks", "canvas_read", "canvas_write"]);
interface ProjectOption {
id: string;
@@ -43,7 +43,7 @@ export function ConsentActions({
requestedProjectId?: string;
onResolved?: () => void;
}) {
- const needsProject = scopes.includes(PROJECT_SCOPE);
+ const needsProject = scopes.some((s) => PROJECT_SCOPES.has(s));
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [projects, setProjects] = useState([]);
@@ -128,7 +128,7 @@ export function ConsentActions({
projectId = created;
}
if (approved && needsProject && !projectId) {
- setError("Select or create a project to grant project_tasks.");
+ setError("Select or create a project for the requested project access.");
setBusy(false);
return;
}
@@ -172,7 +172,7 @@ export function ConsentActions({
htmlFor={`consent-project-${requestId}`}
className="block text-[11px] text-shell-text-secondary mb-1"
>
- Grant project_tasks for project
+ Grant project access for
{!creating ? (
diff --git a/desktop/src/lib/projects.test.ts b/desktop/src/lib/projects.test.ts
index 19de43981..ba2f7e846 100644
--- a/desktop/src/lib/projects.test.ts
+++ b/desktop/src/lib/projects.test.ts
@@ -269,27 +269,27 @@ describe("projectsApi.members.remove", () => {
});
});
-describe("projectsApi.members.setLead", () => {
+describe("projectsApi.setLead (D7, exclusive project pointer)", () => {
it("returns result on 200", async () => {
- mockFetch({ ok: true, is_lead: true });
- const result = await projectsApi.members.setLead("p-1", "m-1", true);
- expect(result.is_lead).toBe(true);
+ mockFetch({ ok: true, lead_member_id: "m-1" });
+ const result = await projectsApi.setLead("p-1", "m-1");
+ expect(result.lead_member_id).toBe("m-1");
});
- it("patches with is_lead body", async () => {
- const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true, is_lead: true }) });
+ it("patches the project lead endpoint with a member_id body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true, lead_member_id: null }) });
global.fetch = fetchMock;
- await projectsApi.members.setLead("p-1", "m-1", false);
+ await projectsApi.setLead("p-1", null);
const [url, opts] = fetchMock.mock.calls[0];
- expect(url).toBe("/api/projects/p-1/members/m-1/lead");
+ expect(url).toBe("/api/projects/p-1/lead");
expect(opts.method).toBe("PATCH");
const body = JSON.parse(opts.body);
- expect(body.is_lead).toBe(false);
+ expect(body.member_id).toBe(null);
});
it("throws on non-ok response", async () => {
mockFetchError(403, "forbidden");
- await expect(projectsApi.members.setLead("p-1", "m-1", true)).rejects.toThrow("403");
+ await expect(projectsApi.setLead("p-1", "m-1")).rejects.toThrow("403");
});
});
diff --git a/desktop/src/lib/projects.ts b/desktop/src/lib/projects.ts
index 6ff385133..51578dfd0 100644
--- a/desktop/src/lib/projects.ts
+++ b/desktop/src/lib/projects.ts
@@ -7,6 +7,7 @@ export type Project = {
created_by: string;
created_at: number;
updated_at: number;
+ lead_member_id?: string | null;
};
export type ProjectMember = {
@@ -18,6 +19,7 @@ export type ProjectMember = {
memory_seed: "none" | "snapshot" | "empty";
added_at: number;
can_edit_canvas?: boolean;
+ can_read_canvas?: boolean;
is_lead?: number;
};
@@ -155,6 +157,13 @@ export const projectsApi = {
http
(`/api/projects/${id}/archive`, { method: "POST" }),
remove: (id: string) =>
http(`/api/projects/${id}`, { method: "DELETE" }),
+ // D7: the Lead is an exclusive, project-level designation. Pass a member id
+ // to promote that member, or null to clear the lead.
+ setLead: (id: string, member_id: string | null) =>
+ http<{ ok: boolean; lead_member_id: string | null }>(
+ `/api/projects/${id}/lead`,
+ { method: "PATCH", body: JSON.stringify({ member_id }) },
+ ),
members: {
list: (pid: string) =>
@@ -171,11 +180,6 @@ export const projectsApi = {
}),
remove: (pid: string, member_id: string) =>
http<{ ok: boolean }>(`/api/projects/${pid}/members/${member_id}`, { method: "DELETE" }),
- setLead: (pid: string, member_id: string, is_lead: boolean) =>
- http<{ ok: boolean; is_lead: boolean }>(
- `/api/projects/${pid}/members/${member_id}/lead`,
- { method: "PATCH", body: JSON.stringify({ is_lead }) },
- ),
},
tasks: {
diff --git a/tests/projects/test_a2a.py b/tests/projects/test_a2a.py
index d6b6290c4..5d0235e07 100644
--- a/tests/projects/test_a2a.py
+++ b/tests/projects/test_a2a.py
@@ -370,13 +370,13 @@ async def enqueue_user_message(self, slug: str, msg: dict) -> None:
# ---------------------------------------------------------------------------
-# Lead agent — ensure_a2a_channel syncs settings.leads
+# Lead agent — ensure_a2a_channel syncs settings.leads from lead_member_id (D7)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
-async def test_ensure_populates_leads_from_is_lead_flag(stores):
- """When a member has is_lead=1, their name appears in settings.leads."""
+async def test_ensure_populates_leads_from_lead_member_id(stores):
+ """When a member is the project's lead_member_id, their name appears in settings.leads."""
project_store, channel_store = stores
p = await project_store.create_project(name="P", slug="lead-basic", created_by="u1")
@@ -386,7 +386,7 @@ async def test_ensure_populates_leads_from_is_lead_flag(stores):
await project_store.add_member(p["id"], coord_id, member_kind="native")
await project_store.add_member(p["id"], worker_id, member_kind="native")
- await project_store.set_member_lead(p["id"], coord_id, True)
+ await project_store.set_lead(p["id"], coord_id)
ch = await ensure_a2a_channel(channel_store, project_store, p["id"], config=config)
@@ -396,7 +396,7 @@ async def test_ensure_populates_leads_from_is_lead_flag(stores):
@pytest.mark.asyncio
async def test_ensure_leads_empty_when_no_leads(stores):
- """settings.leads is an empty list when no member is marked as lead."""
+ """settings.leads is an empty list when no member is the lead."""
project_store, channel_store = stores
p = await project_store.create_project(name="P", slug="no-leads", created_by="u1")
@@ -411,7 +411,7 @@ async def test_ensure_leads_empty_when_no_leads(stores):
@pytest.mark.asyncio
async def test_ensure_updates_leads_on_subsequent_call(stores):
- """Toggling is_lead and calling ensure again updates settings.leads."""
+ """Changing lead_member_id and calling ensure again updates settings.leads."""
project_store, channel_store = stores
p = await project_store.create_project(name="P", slug="lead-update", created_by="u1")
@@ -422,18 +422,18 @@ async def test_ensure_updates_leads_on_subsequent_call(stores):
ch1 = await ensure_a2a_channel(channel_store, project_store, p["id"], config=config)
assert ch1["settings"]["leads"] == []
- await project_store.set_member_lead(p["id"], coord_id, True)
+ await project_store.set_lead(p["id"], coord_id)
ch2 = await ensure_a2a_channel(channel_store, project_store, p["id"], config=config)
assert ch2["settings"]["leads"] == ["coord"]
- await project_store.set_member_lead(p["id"], coord_id, False)
+ await project_store.set_lead(p["id"], None)
ch3 = await ensure_a2a_channel(channel_store, project_store, p["id"], config=config)
assert ch3["settings"]["leads"] == []
@pytest.mark.asyncio
-async def test_ensure_multiple_leads(stores):
- """Multiple lead members all appear in settings.leads, sorted."""
+async def test_ensure_lead_is_exclusive(stores):
+ """Setting a second lead replaces the first; only one name is ever in settings.leads."""
project_store, channel_store = stores
p = await project_store.create_project(name="P", slug="multi-lead", created_by="u1")
@@ -443,17 +443,20 @@ async def test_ensure_multiple_leads(stores):
await project_store.add_member(p["id"], alpha_id, member_kind="native")
await project_store.add_member(p["id"], beta_id, member_kind="native")
- await project_store.set_member_lead(p["id"], alpha_id, True)
- await project_store.set_member_lead(p["id"], beta_id, True)
+ await project_store.set_lead(p["id"], alpha_id)
- ch = await ensure_a2a_channel(channel_store, project_store, p["id"], config=config)
+ ch1 = await ensure_a2a_channel(channel_store, project_store, p["id"], config=config)
+ assert ch1["settings"]["leads"] == ["alpha"]
- assert ch["settings"]["leads"] == ["alpha", "beta"]
+ # Promoting beta must atomically unset alpha.
+ await project_store.set_lead(p["id"], beta_id)
+ ch2 = await ensure_a2a_channel(channel_store, project_store, p["id"], config=config)
+ assert ch2["settings"]["leads"] == ["beta"]
@pytest.mark.asyncio
async def test_ensure_lead_removed_drops_from_leads(stores):
- """Removing a lead member from the project naturally drops them from settings.leads."""
+ """Removing the lead member from the project naturally drops them from settings.leads."""
project_store, channel_store = stores
p = await project_store.create_project(name="P", slug="lead-drop", created_by="u1")
@@ -463,7 +466,7 @@ async def test_ensure_lead_removed_drops_from_leads(stores):
await project_store.add_member(p["id"], coord_id, member_kind="native")
await project_store.add_member(p["id"], worker_id, member_kind="native")
- await project_store.set_member_lead(p["id"], coord_id, True)
+ await project_store.set_lead(p["id"], coord_id)
ch1 = await ensure_a2a_channel(channel_store, project_store, p["id"], config=config)
assert ch1["settings"]["leads"] == ["coord"]
@@ -473,3 +476,28 @@ async def test_ensure_lead_removed_drops_from_leads(stores):
ch2 = await ensure_a2a_channel(channel_store, project_store, p["id"], config=config)
assert ch2["settings"]["leads"] == []
assert ch2["members"] == ["worker"]
+
+
+@pytest.mark.asyncio
+async def test_ensure_lead_follows_backfilled_column(stores):
+ """A legacy is_lead=1 flag is backfilled into lead_member_id on init and synced."""
+ project_store, channel_store = stores
+ p = await project_store.create_project(name="P", slug="lead-backfill", created_by="u1")
+
+ coord_id = "iiiijjjjjjjj"
+ config = _config(_agent("coord", coord_id))
+ await project_store.add_member(p["id"], coord_id, member_kind="native")
+
+ # Simulate pre-D7 data: a per-member is_lead flag, no project pointer yet.
+ await project_store._db.execute(
+ "UPDATE project_members SET is_lead = 1 WHERE project_id = ? AND member_id = ?",
+ (p["id"], coord_id),
+ )
+ await project_store._db.commit()
+ await project_store._backfill_lead_member_id()
+
+ refreshed = await project_store.get_project(p["id"])
+ assert refreshed["lead_member_id"] == coord_id
+
+ ch = await ensure_a2a_channel(channel_store, project_store, p["id"], config=config)
+ assert ch["settings"]["leads"] == ["coord"]
diff --git a/tests/projects/test_canvas_integration.py b/tests/projects/test_canvas_integration.py
index c7930bfb2..39b732ce7 100644
--- a/tests/projects/test_canvas_integration.py
+++ b/tests/projects/test_canvas_integration.py
@@ -30,6 +30,17 @@ async def app_env(tmp_path):
await snap._ensure_subscribed(p["id"])
app = FastAPI()
+
+ # The gated canvas routes authorize via request.state.user_id / is_admin
+ # (set by the auth middleware in production). This bare test app has no
+ # middleware, so inject a fixed admin session to mirror the unchanged
+ # session-owner behavior the routes rely on.
+ @app.middleware("http")
+ async def _inject_session(request, call_next):
+ request.state.user_id = "admin"
+ request.state.is_admin = True
+ return await call_next(request)
+
app.state.project_store = ps
app.state.project_canvas_store = cs
app.state.canvas_snapshotter = snap
diff --git a/tests/projects/test_canvas_mcp_tools.py b/tests/projects/test_canvas_mcp_tools.py
index 4bc191c2d..d37b4bcc9 100644
--- a/tests/projects/test_canvas_mcp_tools.py
+++ b/tests/projects/test_canvas_mcp_tools.py
@@ -28,7 +28,16 @@ async def env(tmp_path):
ctx = ct.CanvasToolContext(
project_store=ps, canvas_store=cs, snapshotter=snap, data_root=data_root,
)
- yield p, ctx, ps
+
+ async def _grant_edit():
+ await ps._db.execute(
+ "UPDATE project_members SET can_edit_canvas = 1 "
+ "WHERE project_id = ? AND member_id = ?",
+ (p["id"], "agent-1"),
+ )
+ await ps._db.commit()
+
+ yield p, ctx, ps, _grant_edit
await snap.stop()
await cs.close()
await ps.close()
@@ -36,7 +45,8 @@ async def env(tmp_path):
@pytest.mark.asyncio
async def test_canvas_add_note_creates_element(env):
- p, ctx, _ = env
+ p, ctx, _ps, grant = env
+ await grant()
res = await ct.canvas_add_note(
ctx, project_id=p["id"], agent_id="agent-1",
text="agent-said-hello", x=10, y=20,
@@ -48,7 +58,8 @@ async def test_canvas_add_note_creates_element(env):
@pytest.mark.asyncio
async def test_canvas_add_text_creates_element(env):
- p, ctx, _ = env
+ p, ctx, _ps, grant = env
+ await grant()
res = await ct.canvas_add_text(
ctx, project_id=p["id"], agent_id="agent-1",
text="an idea", x=5, y=5, font_size=20,
@@ -62,7 +73,8 @@ async def test_canvas_add_text_creates_element(env):
@pytest.mark.asyncio
async def test_canvas_add_mermaid_and_flowchart_carry_source(env):
- p, ctx, _ = env
+ p, ctx, _ps, grant = env
+ await grant()
m = await ct.canvas_add_mermaid(
ctx, project_id=p["id"], agent_id="agent-1",
source="graph TD; A-->B", x=0, y=0,
@@ -79,7 +91,8 @@ async def test_canvas_add_mermaid_and_flowchart_carry_source(env):
@pytest.mark.asyncio
async def test_canvas_add_mindmap_edge_links_endpoints(env):
- p, ctx, _ = env
+ p, ctx, _ps, grant = env
+ await grant()
a = await ct.canvas_add_text(
ctx, project_id=p["id"], agent_id="agent-1", text="a", x=0, y=0,
)
@@ -100,7 +113,8 @@ async def test_canvas_add_mindmap_edge_links_endpoints(env):
@pytest.mark.asyncio
async def test_canvas_add_mindmap_edge_rejects_unknown_endpoint(env):
- p, ctx, _ = env
+ p, ctx, _ps, grant = env
+ await grant()
a = await ct.canvas_add_text(
ctx, project_id=p["id"], agent_id="agent-1", text="a", x=0, y=0,
)
@@ -112,32 +126,71 @@ async def test_canvas_add_mindmap_edge_rejects_unknown_endpoint(env):
@pytest.mark.asyncio
-async def test_canvas_update_denied_without_permission(env):
- p, ctx, _ = env
- note = await ct.canvas_add_note(
+async def test_canvas_add_denied_without_permission(env):
+ p, ctx, _ps, _grant = env
+ res = await ct.canvas_add_note(
ctx, project_id=p["id"], agent_id="agent-1",
text="x", x=0, y=0,
)
+ assert res["error"] == "permission_denied"
+
+
+@pytest.mark.asyncio
+async def test_canvas_list_denied_without_read(env):
+ p, ctx, _ps, grant = env
+ # can_edit_canvas only: the read checkbox stays off, so the in-process
+ # read path must be blocked even though the agent can write.
+ await grant()
+ res = await ct.canvas_list_elements(
+ ctx, project_id=p["id"], agent_id="agent-1"
+ )
+ assert res["error"] == "permission_denied"
+
+
+@pytest.mark.asyncio
+async def test_canvas_list_allowed_with_read(env):
+ p, ctx, ps, grant = env
+ await grant()
+ await ps._db.execute(
+ "UPDATE project_members SET can_read_canvas = 1 "
+ "WHERE project_id = ? AND member_id = ?",
+ (p["id"], "agent-1"),
+ )
+ await ps._db.commit()
+ el = await ctx.canvas_store.add_element(
+ project_id=p["id"], author_kind="user", author_id="u",
+ element={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, "payload": {"text": "x"}},
+ )
+ res = await ct.canvas_list_elements(
+ ctx, project_id=p["id"], agent_id="agent-1"
+ )
+ assert "elements" in res
+ assert [e["id"] for e in res["elements"]] == [el["id"]]
+
+
+@pytest.mark.asyncio
+async def test_canvas_update_denied_without_permission(env):
+ p, ctx, _ps, _grant = env
+ # An agent without the edit flag cannot mutate an existing element.
+ el = await ctx.canvas_store.add_element(
+ project_id=p["id"], author_kind="user", author_id="u",
+ element={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, "payload": {"text": "x"}},
+ )
res = await ct.canvas_update_element(
ctx, project_id=p["id"], agent_id="agent-1",
- element_id=note["element"]["id"], patch={"x": 99},
+ element_id=el["id"], patch={"x": 99},
)
assert res["error"] == "permission_denied"
@pytest.mark.asyncio
async def test_canvas_update_succeeds_with_permission(env):
- p, ctx, ps = env
+ p, ctx, ps, grant = env
+ await grant()
note = await ct.canvas_add_note(
ctx, project_id=p["id"], agent_id="agent-1",
text="x", x=0, y=0,
)
- await ps._db.execute(
- "UPDATE project_members SET can_edit_canvas = 1 "
- "WHERE project_id = ? AND member_id = ?",
- (p["id"], "agent-1"),
- )
- await ps._db.commit()
res = await ct.canvas_update_element(
ctx, project_id=p["id"], agent_id="agent-1",
element_id=note["element"]["id"], patch={"x": 99},
@@ -147,7 +200,8 @@ async def test_canvas_update_succeeds_with_permission(env):
@pytest.mark.asyncio
async def test_canvas_get_snapshot_png_writes_file(env):
- p, ctx, _ = env
+ p, ctx, _ps, grant = env
+ await grant()
await ct.canvas_add_note(
ctx, project_id=p["id"], agent_id="agent-1", text="x", x=0, y=0,
)
diff --git a/tests/projects/test_canvas_store.py b/tests/projects/test_canvas_store.py
index 464be1a3f..bd772e946 100644
--- a/tests/projects/test_canvas_store.py
+++ b/tests/projects/test_canvas_store.py
@@ -73,10 +73,17 @@ async def test_user_can_add_ideas_board_kind(store, kind):
@pytest.mark.parametrize("kind", ["text", "mermaid", "flowchart", "mindmap_edge"])
@pytest.mark.asyncio
-async def test_agent_can_emit_ideas_board_kind(store, kind):
- # Agents may paint the ideas-board kinds via the canvas tools (#68).
- e = await store.add_element(
- project_id="p",
+async def test_agent_can_emit_ideas_board_kind(store_with_member, kind):
+ # Agents may paint the ideas-board kinds via the canvas tools (#68). The
+ # store enforces can_edit_canvas, so the member needs the edit flag set.
+ cs, _ = store_with_member
+ await cs._db.execute(
+ "UPDATE project_members SET can_edit_canvas = 1 "
+ "WHERE project_id = 'p1' AND member_id = 'agent-1'"
+ )
+ await cs._db.commit()
+ e = await cs.add_element(
+ project_id="p1",
element={"kind": kind, "x": 0, "y": 0, "w": 1, "h": 1, "payload": {}},
author_kind="agent", author_id="agent-1",
)
diff --git a/tests/projects/test_routes_a2a.py b/tests/projects/test_routes_a2a.py
index 1618080f3..2ce1d3c7b 100644
--- a/tests/projects/test_routes_a2a.py
+++ b/tests/projects/test_routes_a2a.py
@@ -110,13 +110,13 @@ async def test_project_delete_archives_a2a_channel(client):
# ---------------------------------------------------------------------------
-# Lead endpoint — PATCH /api/projects/{pid}/members/{mid}/lead
+# Lead endpoint — PATCH /api/projects/{pid}/lead (D7, exclusive designee)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_set_lead_updates_channel_settings(client):
- """PATCHing the lead flag resynchronises settings.leads in the A2A channel."""
+ """PATCHing the lead pointer resynchronises settings.leads in the A2A channel."""
agent_id, agent_name = await _test_agent_id(client)
pid = (await client.post("/api/projects", json={"name": "P", "slug": "ra2a-lead1"})).json()["id"]
await client.post(f"/api/projects/{pid}/members", json={"mode": "native", "agent_id": agent_id})
@@ -128,11 +128,11 @@ async def test_set_lead_updates_channel_settings(client):
# Promote to lead
res = await client.patch(
- f"/api/projects/{pid}/members/{agent_id}/lead",
- json={"is_lead": True},
+ f"/api/projects/{pid}/lead",
+ json={"member_id": agent_id},
)
assert res.status_code == 200
- assert res.json()["is_lead"] is True
+ assert res.json()["lead_member_id"] == agent_id
a2a_after = _a2a(await _list_channels(client, pid))
assert a2a_after is not None
@@ -140,29 +140,55 @@ async def test_set_lead_updates_channel_settings(client):
@pytest.mark.asyncio
-async def test_set_lead_false_removes_from_leads(client):
- """Unsetting is_lead removes the agent name from settings.leads."""
+async def test_set_lead_null_clears_from_leads(client):
+ """Clearing the lead pointer (member_id: null) removes the agent from settings.leads."""
agent_id, agent_name = await _test_agent_id(client)
pid = (await client.post("/api/projects", json={"name": "P", "slug": "ra2a-lead2"})).json()["id"]
await client.post(f"/api/projects/{pid}/members", json={"mode": "native", "agent_id": agent_id})
- await client.patch(f"/api/projects/{pid}/members/{agent_id}/lead", json={"is_lead": True})
+ await client.patch(f"/api/projects/{pid}/lead", json={"member_id": agent_id})
res = await client.patch(
- f"/api/projects/{pid}/members/{agent_id}/lead",
- json={"is_lead": False},
+ f"/api/projects/{pid}/lead",
+ json={"member_id": None},
)
assert res.status_code == 200
+ assert res.json()["lead_member_id"] is None
a2a = _a2a(await _list_channels(client, pid))
assert a2a is not None
assert agent_name not in (a2a["settings"].get("leads") or [])
+@pytest.mark.asyncio
+async def test_set_lead_exclusive_replaces_previous(client):
+ """Setting a second lead unsets the first; only one lead ever remains."""
+ a1, n1 = await _test_agent_id(client)
+ # A second distinct agent: mint via registry-independent member add is not
+ # possible (members must reference config agents), so drive exclusivity
+ # through two calls against the same test agent path by reusing the route
+ # with the same member after clearing — verify the pointer is a single cell.
+ pid = (await client.post("/api/projects", json={"name": "P", "slug": "ra2a-lead-excl"})).json()["id"]
+ await client.post(f"/api/projects/{pid}/members", json={"mode": "native", "agent_id": a1})
+
+ first = await client.patch(f"/api/projects/{pid}/lead", json={"member_id": a1})
+ assert first.status_code == 200
+ assert first.json()["lead_member_id"] == a1
+
+ # Clear then set again: still exactly one lead (the pointer cannot hold two).
+ cleared = await client.patch(f"/api/projects/{pid}/lead", json={"member_id": None})
+ assert cleared.json()["lead_member_id"] is None
+ second = await client.patch(f"/api/projects/{pid}/lead", json={"member_id": a1})
+ assert second.json()["lead_member_id"] == a1
+
+ project = (await client.get(f"/api/projects/{pid}")).json()
+ assert project["lead_member_id"] == a1
+
+
@pytest.mark.asyncio
async def test_set_lead_nonexistent_member_returns_404(client):
pid = (await client.post("/api/projects", json={"name": "P", "slug": "ra2a-lead3"})).json()["id"]
res = await client.patch(
- f"/api/projects/{pid}/members/nonexistent-id/lead",
- json={"is_lead": True},
+ f"/api/projects/{pid}/lead",
+ json={"member_id": "nonexistent-id"},
)
assert res.status_code == 404
diff --git a/tests/projects/test_routes_canvas.py b/tests/projects/test_routes_canvas.py
index eecf98a49..592b3fa05 100644
--- a/tests/projects/test_routes_canvas.py
+++ b/tests/projects/test_routes_canvas.py
@@ -31,6 +31,17 @@ async def client(tmp_path):
await snap.start()
app = FastAPI()
+
+ # The gated canvas routes authorize via request.state.user_id / is_admin
+ # (set by the auth middleware in production). This bare test app has no
+ # middleware, so inject a fixed admin session for every request to mirror
+ # the unchanged session-owner behavior the routes rely on.
+ @app.middleware("http")
+ async def _inject_session(request, call_next):
+ request.state.user_id = "admin"
+ request.state.is_admin = True
+ return await call_next(request)
+
app.state.project_store = ps
app.state.project_canvas_store = cs
app.state.canvas_snapshotter = snap
diff --git a/tests/test_auth_middleware.py b/tests/test_auth_middleware.py
index 3ff1bd078..798da6657 100644
--- a/tests/test_auth_middleware.py
+++ b/tests/test_auth_middleware.py
@@ -9,6 +9,7 @@
from tinyagentos.auth_middleware import (
AuthMiddleware,
+ _is_agent_canvas_path,
_is_exempt,
_is_loopback_client,
)
@@ -265,5 +266,127 @@ async def test_prepare_shutdown_denied_from_remote(self):
resp = await middleware.dispatch(req, call_next)
+ assert resp.status_code == 401
+ call_next.assert_not_awaited()
+
+
+class TestIsAgentCanvasPath:
+ def test_list_elements_get_allowed(self):
+ assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/elements") is True
+
+ def test_create_element_post_allowed(self):
+ assert _is_agent_canvas_path("POST", "/api/projects/proj-1/canvas/elements") is True
+
+ def test_delete_element_allowed(self):
+ assert _is_agent_canvas_path("DELETE", "/api/projects/proj-1/canvas/elements/el-1") is True
+
+ def test_snapshot_png_allowed(self):
+ assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/snapshot.png") is True
+
+ def test_snapshot_tldr_allowed(self):
+ assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/snapshot.tldr") is True
+
+ def test_stream_allowed(self):
+ assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/stream") is True
+
+ def test_update_element_patch_allowed(self):
+ # canvas_write-bound agents may PATCH an element (create + update +
+ # delete all live under canvas_write), so the route is on the allowlist.
+ assert _is_agent_canvas_path("PATCH", "/api/projects/proj-1/canvas/elements/el-1") is True
+
+ def test_permissions_patch_not_allowed(self):
+ assert _is_agent_canvas_path("PATCH", "/api/projects/proj-1/canvas/permissions/agent-1") is False
+
+ def test_extra_path_segment_not_allowed(self):
+ assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/elements/el-1/extra") is False
+
+ def test_wrong_method_not_allowed(self):
+ assert _is_agent_canvas_path("POST", "/api/projects/proj-1/canvas/elements/el-1") is False
+
+ def test_nested_element_path_not_allowed(self):
+ assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/elements/x/y") is False
+
+ def test_single_element_get_not_allowed(self):
+ # There is no GET /elements/{id} route in the allowlist; only the
+ # collection GET and the DELETE of a single element are permitted.
+ assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/elements/el-1") is False
+
+ def test_snapshot_dot_is_literal_not_wildcard(self):
+ # The dot in snapshot.png / snapshot.tldr must be a literal, not a regex
+ # wildcard: a near-miss like snapshotXpng must NOT slip through the
+ # agent-token allowlist onto a session-only surface.
+ assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/snapshotXpng") is False
+ assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/snapshot_png") is False
+ assert _is_agent_canvas_path("GET", "/api/projects/proj-1/canvas/snapshotXtldr") is False
+
+
+class TestCanvasAgentTokenDispatch:
+ @pytest.mark.asyncio
+ async def test_canvas_list_elements_bearer_passes(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ auth_mgr = _default_auth_mgr()
+ auth_mgr.validate_local_token.return_value = False
+ req = _request(
+ method="GET",
+ path="/api/projects/proj-1/canvas/elements",
+ headers={"authorization": "Bearer registry-jwt"},
+ auth_mgr=auth_mgr,
+ )
+ call_next = AsyncMock(return_value=JSONResponse({"elements": []}))
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 200
+ assert req.state.via == "registry_jwt_candidate"
+ call_next.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_canvas_delete_element_bearer_passes(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ auth_mgr = _default_auth_mgr()
+ auth_mgr.validate_local_token.return_value = False
+ req = _request(
+ method="DELETE",
+ path="/api/projects/proj-1/canvas/elements/el-1",
+ headers={"authorization": "Bearer registry-jwt"},
+ auth_mgr=auth_mgr,
+ )
+ call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 200
+ assert req.state.via == "registry_jwt_candidate"
+ call_next.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_canvas_permissions_patch_requires_session(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ req = _request(
+ method="PATCH",
+ path="/api/projects/proj-1/canvas/permissions/agent-1",
+ headers={"authorization": "Bearer registry-jwt"},
+ auth_mgr=_default_auth_mgr(),
+ )
+ call_next = AsyncMock()
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 401
+ call_next.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_canvas_extra_segment_requires_session(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ req = _request(
+ method="GET",
+ path="/api/projects/proj-1/canvas/elements/el-1/extra",
+ headers={"authorization": "Bearer registry-jwt"},
+ auth_mgr=_default_auth_mgr(),
+ )
+ call_next = AsyncMock()
+
+ resp = await middleware.dispatch(req, call_next)
+
assert resp.status_code == 401
call_next.assert_not_awaited()
\ No newline at end of file
diff --git a/tests/test_routes_agent_auth_requests.py b/tests/test_routes_agent_auth_requests.py
index 9ef0b6f65..797ae5a92 100644
--- a/tests/test_routes_agent_auth_requests.py
+++ b/tests/test_routes_agent_auth_requests.py
@@ -1,3 +1,4 @@
+import asyncio
import pytest
@@ -316,10 +317,617 @@ async def test_approve_bare_at_claim_falls_back_to_framework(
await grants.close()
-class TestAgentAuthRequestsGet:
+class TestCanvasScopeApproval:
+ """Canvas scopes require a project_id and follow the same narrow-not-widen
+ rules as project_tasks."""
+
+ @pytest.mark.asyncio
+ async def test_approve_canvas_scopes_with_project_succeeds(
+ self, client, monkeypatch, tmp_path
+ ):
+ from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ load_or_create_signing_keypair,
+ )
+ from tinyagentos.auth_requests_store import AuthRequestsStore
+ from tinyagentos.agent_grants_store import AgentGrantsStore
+
+ registry = AgentRegistryStore(tmp_path / "reg-canvas.db")
+ await registry.init()
+ auth_store = AuthRequestsStore(tmp_path / "auth-canvas.db")
+ await auth_store.init()
+ grants = AgentGrantsStore(tmp_path / "grants-canvas.db")
+ await grants.init()
+ priv, pub = load_or_create_signing_keypair(tmp_path / "keys-canvas")
+
+ record = await auth_store.create(
+ identity_claim="canvas-bot",
+ framework="canvas-cli",
+ requested_scopes=["canvas_read", "canvas_write"],
+ requested_skills=None,
+ reason="",
+ duration_secs=None,
+ project_id="prj-canvas-1",
+ )
+
+ monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
+ monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
+ monkeypatch.setattr(
+ client._transport.app.state, "agent_registry_keypair", (priv, pub)
+ )
+
+ resp = await client.post(
+ f"/api/agents/auth-requests/{record['id']}/approve",
+ json={
+ "granted_scopes": ["canvas_read", "canvas_write"],
+ "project_id": "prj-canvas-1",
+ },
+ )
+ assert resp.status_code == 200, resp.text
+ data = resp.json()
+ assert data["status"] == "accepted"
+ assert data["canonical_id"]
+
+ agents = await registry.list_all()
+ assert len(agents) == 1
+ agent_grants = await grants.list_grants(agents[0]["canonical_id"])
+ assert {g["scope"] for g in agent_grants} == {"canvas_read", "canvas_write"}
+
+ await registry.close()
+ await auth_store.close()
+ await grants.close()
+
+ @pytest.mark.asyncio
+ async def test_approve_canvas_scopes_without_project_is_rejected(
+ self, client, monkeypatch, tmp_path
+ ):
+ from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ load_or_create_signing_keypair,
+ )
+ from tinyagentos.auth_requests_store import AuthRequestsStore
+ from tinyagentos.agent_grants_store import AgentGrantsStore
+
+ registry = AgentRegistryStore(tmp_path / "reg-canvas-np.db")
+ await registry.init()
+ auth_store = AuthRequestsStore(tmp_path / "auth-canvas-np.db")
+ await auth_store.init()
+ grants = AgentGrantsStore(tmp_path / "grants-canvas-np.db")
+ await grants.init()
+ priv, pub = load_or_create_signing_keypair(tmp_path / "keys-canvas-np")
+
+ record = await auth_store.create(
+ identity_claim="canvas-bot",
+ framework="canvas-cli",
+ requested_scopes=["canvas_read", "canvas_write"],
+ requested_skills=None,
+ reason="",
+ duration_secs=None,
+ project_id="prj-canvas-1",
+ )
+
+ monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
+ monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
+ monkeypatch.setattr(
+ client._transport.app.state, "agent_registry_keypair", (priv, pub)
+ )
+
+ resp = await client.post(
+ f"/api/agents/auth-requests/{record['id']}/approve",
+ json={"granted_scopes": ["canvas_read", "canvas_write"]},
+ )
+ assert resp.status_code == 400, resp.text
+ assert "project_id" in resp.text
+
+ # No agent should have been registered by the rejected approval.
+ assert await registry.list_all() == []
+
+ await registry.close()
+ await auth_store.close()
+ await grants.close()
+
+ @pytest.mark.asyncio
+ async def test_approve_canvas_scopes_with_blank_project_is_rejected(
+ self, client, monkeypatch, tmp_path
+ ):
+ # A blank or whitespace project_id is not a real binding: it must fail
+ # closed exactly like a missing one, or a downstream truthy check would
+ # treat the token as unbound and allow cross-project canvas access.
+ from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ load_or_create_signing_keypair,
+ )
+ from tinyagentos.auth_requests_store import AuthRequestsStore
+ from tinyagentos.agent_grants_store import AgentGrantsStore
+
+ for blank in ("", " "):
+ registry = AgentRegistryStore(tmp_path / f"reg-canvas-blank-{len(blank)}.db")
+ await registry.init()
+ auth_store = AuthRequestsStore(tmp_path / f"auth-canvas-blank-{len(blank)}.db")
+ await auth_store.init()
+ grants = AgentGrantsStore(tmp_path / f"grants-canvas-blank-{len(blank)}.db")
+ await grants.init()
+ priv, pub = load_or_create_signing_keypair(
+ tmp_path / f"keys-canvas-blank-{len(blank)}"
+ )
+
+ record = await auth_store.create(
+ identity_claim="canvas-bot",
+ framework="canvas-cli",
+ requested_scopes=["canvas_write"],
+ requested_skills=None,
+ reason="",
+ duration_secs=None,
+ project_id="prj-canvas-1",
+ )
+
+ monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
+ monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
+ monkeypatch.setattr(
+ client._transport.app.state, "agent_registry_keypair", (priv, pub)
+ )
+
+ resp = await client.post(
+ f"/api/agents/auth-requests/{record['id']}/approve",
+ json={"granted_scopes": ["canvas_write"], "project_id": blank},
+ )
+ assert resp.status_code == 400, (blank, resp.text)
+ assert "project_id" in resp.text
+ # The blank-project approval must not register an agent.
+ assert await registry.list_all() == []
+
+ await registry.close()
+ await auth_store.close()
+ await grants.close()
+
+ @pytest.mark.asyncio
+ async def test_approve_cannot_widen_canvas_scopes(
+ self, client, monkeypatch, tmp_path
+ ):
+ from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ load_or_create_signing_keypair,
+ )
+ from tinyagentos.auth_requests_store import AuthRequestsStore
+ from tinyagentos.agent_grants_store import AgentGrantsStore
+
+ registry = AgentRegistryStore(tmp_path / "reg-canvas-widen.db")
+ await registry.init()
+ auth_store = AuthRequestsStore(tmp_path / "auth-canvas-widen.db")
+ await auth_store.init()
+ grants = AgentGrantsStore(tmp_path / "grants-canvas-widen.db")
+ await grants.init()
+ priv, pub = load_or_create_signing_keypair(tmp_path / "keys-canvas-widen")
+
+ record = await auth_store.create(
+ identity_claim="canvas-bot",
+ framework="canvas-cli",
+ requested_scopes=["canvas_read"],
+ requested_skills=None,
+ reason="",
+ duration_secs=None,
+ project_id="prj-canvas-1",
+ )
+
+ monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
+ monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
+ monkeypatch.setattr(
+ client._transport.app.state, "agent_registry_keypair", (priv, pub)
+ )
+
+ resp = await client.post(
+ f"/api/agents/auth-requests/{record['id']}/approve",
+ json={
+ "granted_scopes": ["canvas_read", "canvas_write"],
+ "project_id": "prj-canvas-1",
+ },
+ )
+ assert resp.status_code == 400, resp.text
+ assert "subset of the requested scopes" in resp.text
+
+ await registry.close()
+ await auth_store.close()
+ await grants.close()
+
+ @pytest.mark.asyncio
+ async def test_approve_canvas_scopes_cannot_widen_by_adding_project_tasks(
+ self, client, monkeypatch, tmp_path
+ ):
+ from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ load_or_create_signing_keypair,
+ )
+ from tinyagentos.auth_requests_store import AuthRequestsStore
+ from tinyagentos.agent_grants_store import AgentGrantsStore
+
+ registry = AgentRegistryStore(tmp_path / "reg-canvas-mix.db")
+ await registry.init()
+ auth_store = AuthRequestsStore(tmp_path / "auth-canvas-mix.db")
+ await auth_store.init()
+ grants = AgentGrantsStore(tmp_path / "grants-canvas-mix.db")
+ await grants.init()
+ priv, pub = load_or_create_signing_keypair(tmp_path / "keys-canvas-mix")
+
+ record = await auth_store.create(
+ identity_claim="canvas-bot",
+ framework="canvas-cli",
+ requested_scopes=["canvas_read"],
+ requested_skills=None,
+ reason="",
+ duration_secs=None,
+ project_id="prj-canvas-1",
+ )
+
+ monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
+ monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
+ monkeypatch.setattr(
+ client._transport.app.state, "agent_registry_keypair", (priv, pub)
+ )
+
+ resp = await client.post(
+ f"/api/agents/auth-requests/{record['id']}/approve",
+ json={
+ "granted_scopes": ["canvas_read", "project_tasks"],
+ "project_id": "prj-canvas-1",
+ },
+ )
+ assert resp.status_code == 400, resp.text
+
+ await registry.close()
+ await auth_store.close()
+ await grants.close()
@pytest.mark.asyncio
async def test_get_unknown_id_returns_404(self, client, monkeypatch):
store = _FakeAuthRequestsStore()
monkeypatch.setattr(client._transport.app.state, "auth_requests", store)
resp = await client.get("/api/agents/auth-requests/nonexistent123")
assert resp.status_code == 404
+
+
+class TestHandleSetOnApprove:
+ """Slice 7: approve sets the registry handle from the sanitized identity
+ claim; the new agent then passes the a2a bus 'no handle' gate."""
+
+ @pytest.mark.asyncio
+ async def test_approve_sets_handle_on_registry(self, client, monkeypatch, tmp_path):
+ from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ load_or_create_signing_keypair,
+ )
+ from tinyagentos.auth_requests_store import AuthRequestsStore
+ from tinyagentos.agent_grants_store import AgentGrantsStore
+
+ registry = AgentRegistryStore(tmp_path / "reg-handle.db")
+ await registry.init()
+ auth_store = AuthRequestsStore(tmp_path / "auth-handle.db")
+ await auth_store.init()
+ grants = AgentGrantsStore(tmp_path / "grants-handle.db")
+ await grants.init()
+ priv, pub = load_or_create_signing_keypair(tmp_path / "keys-handle")
+
+ record = await auth_store.create(
+ identity_claim="@taOSmd-dev",
+ framework="openclaw",
+ requested_scopes=["memory_read"],
+ requested_skills=None,
+ reason="",
+ duration_secs=None,
+ project_id=None,
+ )
+
+ monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
+ monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
+ monkeypatch.setattr(
+ client._transport.app.state, "agent_registry_keypair", (priv, pub)
+ )
+
+ resp = await client.post(
+ f"/api/agents/auth-requests/{record['id']}/approve",
+ json={"granted_scopes": ["memory_read"]},
+ )
+ assert resp.status_code == 200, resp.text
+
+ agents = await registry.list_all()
+ assert len(agents) == 1
+ assert agents[0]["handle"] == "taosmd-dev"
+
+ handle = (agents[0].get("handle") or "").strip()
+ assert handle, "handle must be set so the a2a 'no handle' gate passes"
+
+ await registry.close()
+ await auth_store.close()
+ await grants.close()
+
+
+class TestHandleCollisionActiveRejects:
+ """Slice 7: a handle collision with an ACTIVE identity returns 409 and
+ leaves the auth request PENDING so the approver can pick another variant."""
+
+ @pytest.mark.asyncio
+ async def test_409_when_handle_collides_with_active(
+ self, client, monkeypatch, tmp_path
+ ):
+ from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ load_or_create_signing_keypair,
+ )
+ from tinyagentos.auth_requests_store import AuthRequestsStore
+ from tinyagentos.agent_grants_store import AgentGrantsStore
+
+ registry = AgentRegistryStore(tmp_path / "reg-collide.db")
+ await registry.init()
+ auth_store = AuthRequestsStore(tmp_path / "auth-collide.db")
+ await auth_store.init()
+ grants = AgentGrantsStore(tmp_path / "grants-collide.db")
+ await grants.init()
+ priv, pub = load_or_create_signing_keypair(tmp_path / "keys-collide")
+
+ existing = await registry.register(
+ framework="openclaw",
+ display_name="existing-agent",
+ user_id="user-existing",
+ origin="taos-deployed",
+ handle="taosmd-dev",
+ )
+
+ record = await auth_store.create(
+ identity_claim="@taOSmd-dev",
+ framework="openclaw",
+ requested_scopes=["memory_read"],
+ requested_skills=None,
+ reason="",
+ duration_secs=None,
+ project_id=None,
+ )
+
+ monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
+ monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
+ monkeypatch.setattr(
+ client._transport.app.state, "agent_registry_keypair", (priv, pub)
+ )
+
+ resp = await client.post(
+ f"/api/agents/auth-requests/{record['id']}/approve",
+ json={"granted_scopes": ["memory_read"]},
+ )
+ assert resp.status_code == 409, resp.text
+
+ pending = await auth_store.get(record["id"])
+ assert pending is not None
+ assert pending["status"] == "pending"
+
+ active_agents = await registry.list_all(status="active")
+ assert len(active_agents) == 1
+
+ await registry.close()
+ await auth_store.close()
+ await grants.close()
+
+
+class TestHandleCollisionSuspendedAllowsReuse:
+ """Slice 7: if the previous holder of the handle is SUSPENDED, the handle
+ may be reused and approval succeeds."""
+
+ @pytest.mark.asyncio
+ async def test_approve_succeeds_after_handle_holder_suspended(
+ self, client, monkeypatch, tmp_path
+ ):
+ from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ load_or_create_signing_keypair,
+ )
+ from tinyagentos.auth_requests_store import AuthRequestsStore
+ from tinyagentos.agent_grants_store import AgentGrantsStore
+
+ registry = AgentRegistryStore(tmp_path / "reg-suspended.db")
+ await registry.init()
+ auth_store = AuthRequestsStore(tmp_path / "auth-suspended.db")
+ await auth_store.init()
+ grants = AgentGrantsStore(tmp_path / "grants-suspended.db")
+ await grants.init()
+ priv, pub = load_or_create_signing_keypair(tmp_path / "keys-suspended")
+
+ old_agent = await registry.register(
+ framework="openclaw",
+ display_name="old-agent",
+ user_id="user-old",
+ origin="taos-deployed",
+ handle="taosmd-dev",
+ )
+ await registry.set_status(
+ old_agent["canonical_id"], "suspended", actor="user-old"
+ )
+
+ record = await auth_store.create(
+ identity_claim="@taOSmd-dev",
+ framework="openclaw",
+ requested_scopes=["memory_read"],
+ requested_skills=None,
+ reason="",
+ duration_secs=None,
+ project_id=None,
+ )
+
+ monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
+ monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
+ monkeypatch.setattr(
+ client._transport.app.state, "agent_registry_keypair", (priv, pub)
+ )
+
+ resp = await client.post(
+ f"/api/agents/auth-requests/{record['id']}/approve",
+ json={"granted_scopes": ["memory_read"]},
+ )
+ assert resp.status_code == 200, resp.text
+
+ new_agents = await registry.list_all()
+ new_active = [a for a in new_agents if a["status"] == "active"]
+ assert len(new_active) == 1
+ assert new_active[0]["handle"] == "taosmd-dev"
+
+ await registry.close()
+ await auth_store.close()
+ await grants.close()
+
+
+class TestPartialUniqueHandleIndex:
+ """The DB (not just the application pre-check) must reject a duplicate
+ active handle, so two concurrent approvals of the same identity_claim
+ cannot both become active with identical handles (a2a 'from' spoofing)."""
+
+ @pytest.mark.asyncio
+ async def test_index_blocks_two_active_with_same_handle(self, tmp_path):
+ from tinyagentos.agent_registry_store import AgentRegistryStore
+
+ registry = AgentRegistryStore(tmp_path / "reg-idx.db")
+ await registry.init()
+
+ # Both born pending with the same handle (allowed: index only covers
+ # active + non-empty handle).
+ a = await registry.register(
+ framework="openclaw", display_name="a", origin="external-selfjoin",
+ handle="dup-handle",
+ )
+ b = await registry.register(
+ framework="openclaw", display_name="b", origin="external-selfjoin",
+ handle="dup-handle",
+ )
+ await registry.set_status(a["canonical_id"], "active")
+
+ # The second activation must be rejected by the partial unique index.
+ with pytest.raises(Exception) as exc:
+ await registry.set_status(b["canonical_id"], "active")
+ assert "ux_agent_active_handle" in str(exc.value) or "UNIQUE" in str(exc.value)
+
+ # And the loser must never be left active with the handle.
+ b_row = await registry.get(b["canonical_id"])
+ assert b_row["status"] == "pending"
+ await registry.close()
+
+
+class TestConcurrentApproveSameIdentity:
+ """Two approvals racing on the SAME identity_claim: exactly one active
+ agent with the handle set, the other returns 409 and leaves no extra
+ active (or pending) agent behind."""
+
+ @pytest.mark.asyncio
+ async def test_concurrent_approve_one_active_other_409(
+ self, client, monkeypatch, tmp_path
+ ):
+ from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ load_or_create_signing_keypair,
+ )
+ from tinyagentos.auth_requests_store import AuthRequestsStore
+ from tinyagentos.agent_grants_store import AgentGrantsStore
+
+ registry = AgentRegistryStore(tmp_path / "reg-race.db")
+ await registry.init()
+ auth_store = AuthRequestsStore(tmp_path / "auth-race.db")
+ await auth_store.init()
+ grants = AgentGrantsStore(tmp_path / "grants-race.db")
+ await grants.init()
+ priv, pub = load_or_create_signing_keypair(tmp_path / "keys-race")
+
+ # Two independent auth requests for the SAME identity_claim. The
+ # per-request lock does NOT protect this case (different request ids),
+ # so this exercises the unique-index race directly.
+ r1 = await auth_store.create(
+ identity_claim="@taOSmd-dev", framework="openclaw",
+ requested_scopes=["memory_read"], requested_skills=None, reason="",
+ duration_secs=None, project_id=None,
+ )
+ r2 = await auth_store.create(
+ identity_claim="@taOSmd-dev", framework="openclaw",
+ requested_scopes=["memory_read"], requested_skills=None, reason="",
+ duration_secs=None, project_id=None,
+ )
+
+ monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
+ monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
+ monkeypatch.setattr(
+ client._transport.app.state, "agent_registry_keypair", (priv, pub)
+ )
+
+ async def _approve(rid):
+ return await client.post(
+ f"/api/agents/auth-requests/{rid}/approve",
+ json={"granted_scopes": ["memory_read"]},
+ )
+
+ # Drive both approvals concurrently so neither pre-check sees an active
+ # handle yet; the unique index decides the winner.
+ resps = await asyncio.gather(_approve(r1["id"]), _approve(r2["id"]))
+
+ statuses = sorted(r.status_code for r in resps)
+ assert statuses == [200, 409], [r.status_code for r in resps]
+
+ active = await registry.list_all(status="active")
+ assert len(active) == 1, active
+ # The winner's handle is set and non-empty.
+ assert active[0]["handle"] == "taosmd-dev"
+ assert active[0]["handle"]
+
+ # The loser is not left behind as an active or pending agent.
+ assert len(await registry.list_all()) == 1
+
+ await registry.close()
+ await auth_store.close()
+ await grants.close()
+
+ @pytest.mark.asyncio
+ async def test_approved_active_handle_is_nonempty(
+ self, client, monkeypatch, tmp_path
+ ):
+ from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ load_or_create_signing_keypair,
+ )
+ from tinyagentos.auth_requests_store import AuthRequestsStore
+ from tinyagentos.agent_grants_store import AgentGrantsStore
+
+ registry = AgentRegistryStore(tmp_path / "reg-nonempty.db")
+ await registry.init()
+ auth_store = AuthRequestsStore(tmp_path / "auth-nonempty.db")
+ await auth_store.init()
+ grants = AgentGrantsStore(tmp_path / "grants-nonempty.db")
+ await grants.init()
+ priv, pub = load_or_create_signing_keypair(tmp_path / "keys-nonempty")
+
+ record = await auth_store.create(
+ identity_claim="grok-bot", framework="grok",
+ requested_scopes=["memory_read"], requested_skills=None, reason="",
+ duration_secs=None, project_id=None,
+ )
+
+ monkeypatch.setattr(client._transport.app.state, "agent_registry", registry)
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", auth_store)
+ monkeypatch.setattr(client._transport.app.state, "agent_grants", grants)
+ monkeypatch.setattr(
+ client._transport.app.state, "agent_registry_keypair", (priv, pub)
+ )
+
+ resp = await client.post(
+ f"/api/agents/auth-requests/{record['id']}/approve",
+ json={"granted_scopes": ["memory_read"]},
+ )
+ assert resp.status_code == 200, resp.text
+
+ active = await registry.list_all(status="active")
+ assert len(active) == 1
+ # Never left active with an empty handle (the orphan-window bug).
+ assert active[0]["handle"]
+ assert active[0]["handle"] == "grok-bot"
+
+ await registry.close()
+ await auth_store.close()
+ await grants.close()
diff --git a/tests/test_routes_project_canvas.py b/tests/test_routes_project_canvas.py
index 220912054..80e16f006 100644
--- a/tests/test_routes_project_canvas.py
+++ b/tests/test_routes_project_canvas.py
@@ -3,27 +3,32 @@
from __future__ import annotations
import asyncio
-import json
-from pathlib import Path
+from types import SimpleNamespace
from unittest.mock import patch, AsyncMock
import pytest
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+
+from tinyagentos.agent_registry_store import mint_registry_token
@pytest.fixture(autouse=True)
def _ensure_canvas_store(client, tmp_path_factory):
- """Initialize project_canvas_store if the lifespan didn't run (test client)."""
+ """Initialize project_canvas_store if the lifespan didn't run (test client).
+
+ Point the canvas store at the same projects.db the project_store uses, so
+ the per-project canvas permission live in the shared project_members table
+ (the store-level edit check reads from there, exactly as in production).
+ """
store = client._transport.app.state.project_canvas_store
if store._db is not None:
try:
asyncio.get_event_loop().run_until_complete(store.close())
except Exception:
pass
- # Use a fresh DB per test session via tmp_path. BaseStore reads self.db_path
- # (a Path) in init(); the previous override set a non-existent `_db_path`
- # string attr, so init() silently fell back to the production canvas DB.
- tmp_dir = tmp_path_factory.mktemp("canvas_test")
- store.db_path = tmp_dir / "test_projects.db"
+ ps = client._transport.app.state.project_store
+ store.db_path = ps.db_path
asyncio.get_event_loop().run_until_complete(store.init())
yield
try:
@@ -368,8 +373,11 @@ async def test_snapshot_tldr_requires_snapshotter(client):
@pytest.mark.asyncio
async def test_set_permission_member_not_found_returns_404(client):
+ resp = await client.post("/api/projects", json={"name": "perm", "slug": "perm"})
+ assert resp.status_code == 200, resp.text
+ pid = resp.json()["id"]
resp = await client.patch(
- "/api/projects/proj-1/canvas/permissions/agent-1",
+ f"/api/projects/{pid}/canvas/permissions/agent-1",
json={"can_edit_canvas": True},
)
assert resp.status_code == 404
@@ -389,3 +397,732 @@ async def test_canvas_stream_endpoint_exists(client):
app = client._transport.app
paths = [r.path for r in app.routes]
assert "/api/projects/{project_id}/canvas/stream" in paths
+
+
+# ---------------------------------------------------------------------------
+# Finding 2 (adversarial review): session users must be gated by project
+# visibility (owner/admin). A non-owner collapses into the same existence-hiding
+# 404 the agent path uses (D3 matrix); owner/admin stays allowed, and an
+# allowed session write is still attributed to the verified user.
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestSessionCanvasOwnerGating:
+ async def test_owner_session_write_allowed(self, ctx):
+ pid = await _new_project(ctx, "owner-gate-write")
+ resp = await ctx.client.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "owned"}},
+ )
+ assert resp.status_code == 201, resp.text
+
+ async def test_non_owner_session_list_is_404(self, ctx):
+ pid = await _new_project(ctx, "nonowner-gate")
+ async with _non_owner_client(ctx.app) as other:
+ resp = await other.get(f"/api/projects/{pid}/canvas/elements")
+ assert resp.status_code == 404
+ assert resp.status_code != 403
+ assert resp.status_code != 200
+
+ async def test_non_owner_session_write_is_404(self, ctx):
+ pid = await _new_project(ctx, "nonowner-gate-write")
+ async with _non_owner_client(ctx.app) as other:
+ resp = await other.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "x"}},
+ )
+ assert resp.status_code == 404
+ assert resp.status_code != 403
+
+
+# ---------------------------------------------------------------------------
+# Slice 3: agent scope + per-project canvas permission gating (D3 matrix)
+# plus honest attribution and the uniform actor stamp (D4).
+# ---------------------------------------------------------------------------
+
+
+@pytest_asyncio.fixture
+async def ctx(client):
+ """Reuse the session-admin `client` app and init the agent registry +
+ grants stores so canvas agent tokens can be minted against real stores."""
+ app = client._transport.app
+ for attr in ("agent_registry", "agent_grants"):
+ store = getattr(app.state, attr)
+ if store._db is None:
+ await store.init()
+ # The test client bypasses the lifespan, so app.state.project_event_broker
+ # is unset; point it at the broker the canvas store already publishes to.
+ if getattr(app.state, "project_event_broker", None) is None:
+ app.state.project_event_broker = app.state.project_canvas_store._broker
+ uid = app.state.auth.find_user("admin")["id"]
+ yield SimpleNamespace(client=client, app=app, uid=uid)
+ for attr in ("agent_registry", "agent_grants"):
+ store = getattr(app.state, attr)
+ if store._db is not None:
+ await store.close()
+
+
+def _bare(app):
+ """Cookieless client so requests carry only the Bearer header."""
+ return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
+
+
+def _hdr(token):
+ return {"Authorization": f"Bearer {token}"}
+
+
+async def _new_project(ctx, slug):
+ resp = await ctx.client.post("/api/projects", json={"name": slug, "slug": slug})
+ assert resp.status_code == 200, resp.text
+ return resp.json()["id"]
+
+
+def _non_owner_client(app):
+ """A non-owner, non-admin session client for a second human user.
+
+ The project under test is owned by the admin `client` fixture user, so a
+ session minted for this user is an "other" human per the D3 matrix and must
+ collapse into an existence-hiding 404 on the canvas.
+ """
+ auth = app.state.auth
+ code = auth.add_user_invite("other", "admin")
+ rec = auth.complete_invite(
+ "other", code, "Other User", "o@example.com", "password123"
+ )
+ token = auth.create_session(user_id=rec["id"], long_lived=True)
+ return AsyncClient(
+ transport=ASGITransport(app=app),
+ base_url="http://test",
+ cookies={"taos_session": token},
+ )
+
+
+async def _add_member(ctx, pid, member_id):
+ await ctx.app.state.project_store.add_member(pid, member_id, member_kind="native")
+
+
+async def _mint_agent(ctx, project_id, scopes):
+ registry = ctx.app.state.agent_registry
+ grants = ctx.app.state.agent_grants
+ priv, _pub = ctx.app.state.agent_registry_keypair
+ rec = await registry.register(
+ framework="grok",
+ display_name="Grok",
+ origin="external-selfjoin",
+ handle="@grok",
+ )
+ cid = rec["canonical_id"]
+ await registry.set_status(cid, "active")
+ for scope in scopes:
+ await grants.add_grant(cid, scope, project_id=project_id)
+ token = mint_registry_token(
+ cid, priv, user_id="u", framework="grok", project_id=project_id
+ )
+ return cid, token
+
+
+async def _grant_canvas(ctx, pid, agent_id, *, read=None, edit=None):
+ body = {}
+ if read is not None:
+ body["can_read_canvas"] = read
+ if edit is not None:
+ body["can_edit_canvas"] = edit
+ resp = await ctx.client.patch(
+ f"/api/projects/{pid}/canvas/permissions/{agent_id}", json=body
+ )
+ assert resp.status_code == 200, resp.text
+ return resp
+
+
+@pytest.mark.asyncio
+class TestAgentCanvasReadGating:
+ async def test_read_allowed_with_scope_and_flag(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_read",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, read=True)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.get(
+ f"/api/projects/{pid}/canvas/elements", headers=_hdr(token)
+ )
+ assert resp.status_code == 200
+
+ async def test_read_without_scope_is_403(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ # canvas_write only: read scope is missing.
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, read=True)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.get(
+ f"/api/projects/{pid}/canvas/elements", headers=_hdr(token)
+ )
+ assert resp.status_code == 403
+
+ async def test_read_without_flag_is_403(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_read",))
+ await _add_member(ctx, pid, cid)
+ # No can_read_canvas grant.
+ async with _bare(ctx.app) as bare:
+ resp = await bare.get(
+ f"/api/projects/{pid}/canvas/elements", headers=_hdr(token)
+ )
+ assert resp.status_code == 403
+
+@pytest.mark.asyncio
+class TestAgentCanvasWriteGating:
+ async def test_write_allowed_with_scope_and_flag(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, edit=True)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "a"}},
+ headers=_hdr(token),
+ )
+ assert resp.status_code == 201, resp.text
+
+ async def test_write_without_scope_is_403(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ # canvas_read only: write scope is missing.
+ cid, token = await _mint_agent(ctx, pid, ("canvas_read",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, edit=True)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "a"}},
+ headers=_hdr(token),
+ )
+ assert resp.status_code == 403
+
+ async def test_write_without_flag_is_403(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ # No can_edit_canvas grant.
+ async with _bare(ctx.app) as bare:
+ resp = await bare.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "a"}},
+ headers=_hdr(token),
+ )
+ assert resp.status_code == 403
+
+ async def test_write_flag_does_not_grant_read(self, ctx):
+ """The edit flag (and canvas_write scope) must NOT satisfy a read."""
+ pid = await _new_project(ctx, "alpha")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, edit=True)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.get(
+ f"/api/projects/{pid}/canvas/elements", headers=_hdr(token)
+ )
+ assert resp.status_code == 403
+
+
+@pytest.mark.asyncio
+class TestAgentCanvasCrossProject:
+ async def test_different_project_is_404(self, ctx):
+ pid_a = await _new_project(ctx, "alpha")
+ pid_b = await _new_project(ctx, "bravo")
+ cid, token_a = await _mint_agent(ctx, pid_a, ("canvas_read",))
+ await _add_member(ctx, pid_a, cid)
+ await _grant_canvas(ctx, pid_a, cid, read=True)
+ async with _bare(ctx.app) as bare:
+ listing = await bare.get(
+ f"/api/projects/{pid_b}/canvas/elements", headers=_hdr(token_a)
+ )
+ assert listing.status_code == 404
+
+
+@pytest.mark.asyncio
+class TestCanvasAttribution:
+ async def test_agent_write_carry_agent_fields(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, edit=True)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "a"}},
+ headers=_hdr(token),
+ )
+ assert resp.status_code == 201, resp.text
+ el = resp.json()["element"]
+ assert el["author_kind"] == "agent"
+ assert el["author_id"] == cid
+
+ async def test_user_write_attributed_to_user_not_system(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ resp = await ctx.client.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "a"}},
+ )
+ assert resp.status_code == 201, resp.text
+ el = resp.json()["element"]
+ assert el["author_kind"] == "user"
+ assert el["author_id"] == ctx.uid
+ assert el["author_id"] != "system"
+
+
+@pytest.mark.asyncio
+class TestCanvasEventActor:
+ """D4: a uniform actor object {kind, id} is stamped on every canvas.* event."""
+
+ async def _capture(self, ctx, pid, coro):
+ broker = ctx.app.state.project_event_broker
+ queue = await broker.subscribe(pid)
+ # Drain any replayed events from earlier actions in this test so we
+ # observe only the event produced by the action under test.
+ while True:
+ try:
+ queue.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+ await coro()
+ return await asyncio.wait_for(queue.get(), timeout=5.0)
+
+ async def test_actor_on_create_event(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+
+ async def action():
+ await ctx.client.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "a"}},
+ )
+
+ ev = await self._capture(ctx, pid, action)
+ assert ev.kind == "canvas.element_added"
+ assert ev.payload["actor"] == {"kind": "user", "id": ctx.uid}
+
+ async def test_actor_on_delete_event(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ created = await ctx.client.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "a"}},
+ )
+ eid = created.json()["element"]["id"]
+
+ async def action():
+ await ctx.client.delete(
+ f"/api/projects/{pid}/canvas/elements/{eid}"
+ )
+
+ ev = await self._capture(ctx, pid, action)
+ assert ev.kind == "canvas.element_deleted"
+ assert ev.payload["actor"] == {"kind": "user", "id": ctx.uid}
+
+ async def test_actor_on_permission_change_event(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ cid, _token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+
+ async def action():
+ await ctx.client.patch(
+ f"/api/projects/{pid}/canvas/permissions/{cid}",
+ json={"can_edit_canvas": True},
+ )
+
+ ev = await self._capture(ctx, pid, action)
+ assert ev.kind == "canvas.permission_changed"
+ assert ev.payload["actor"] == {"kind": "user", "id": ctx.uid}
+ assert ev.payload["can_edit_canvas"] is True
+
+
+@pytest.mark.asyncio
+class TestPermissionPatchExtendsFlags:
+ async def test_patch_can_set_read_flag_only(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ cid, _token = await _mint_agent(ctx, pid, ("canvas_read",))
+ await _add_member(ctx, pid, cid)
+ resp = await ctx.client.patch(
+ f"/api/projects/{pid}/canvas/permissions/{cid}",
+ json={"can_read_canvas": True},
+ )
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["can_read_canvas"] is True
+ assert resp.json()["can_edit_canvas"] is False
+ members = await ctx.app.state.project_store.list_members(pid)
+ me = next(m for m in members if m["member_id"] == cid)
+ assert me["can_read_canvas"] == 1
+ assert me["can_edit_canvas"] == 0
+
+ async def test_patch_can_set_edit_flag_only(self, ctx):
+ pid = await _new_project(ctx, "alpha")
+ cid, _token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ resp = await ctx.client.patch(
+ f"/api/projects/{pid}/canvas/permissions/{cid}",
+ json={"can_edit_canvas": True},
+ )
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["can_edit_canvas"] is True
+ assert resp.json()["can_read_canvas"] is False
+
+ async def test_patch_rejects_agent_token(self, ctx):
+ """The permissions PATCH is owner/admin only; an agent token must not
+ reach it (the middleware leaves it off the agent allowlist -> 401)."""
+ pid = await _new_project(ctx, "alpha")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.patch(
+ f"/api/projects/{pid}/canvas/permissions/{cid}",
+ json={"can_edit_canvas": True},
+ headers=_hdr(token),
+ )
+ assert resp.status_code == 401
+
+
+# ---------------------------------------------------------------------------
+# Slice 4: the SSE stream and the snapshot endpoints are gated by canvas_read
+# for agent principals, and the keepalive tick re-checks the live read flag so
+# a revoked agent cannot keep a long-lived stream open (lead-agent-identity
+# design, Slice 4 + Edge cases).
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestCanvasStreamAgentGating:
+ async def _connect_status(self, ctx, pid, token):
+ """Open the SSE route directly so we never read the infinite body.
+
+ A denied agent either raises HTTPException (missing scope) or returns a
+ JSONResponse (flag off); an allowed agent returns a StreamingResponse.
+ """
+ from fastapi import HTTPException as _HTTPExc
+ from starlette.responses import StreamingResponse as _SR
+ from starlette.responses import JSONResponse as _JR
+ from tinyagentos.routes.project_canvas import canvas_stream
+
+ try:
+ resp = await canvas_stream(pid, _stream_req(ctx.app, token=token))
+ except _HTTPExc as exc:
+ return exc.status_code
+ if isinstance(resp, _SR):
+ return 200
+ if isinstance(resp, _JR):
+ return resp.status_code
+ return None
+
+ async def test_stream_connect_allowed_with_read_scope_and_flag(self, ctx):
+ pid = await _new_project(ctx, "stream-read")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_read",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, read=True)
+ assert await self._connect_status(ctx, pid, token) == 200
+
+ async def test_stream_connect_without_scope_is_403(self, ctx):
+ pid = await _new_project(ctx, "stream-noscope")
+ # canvas_write only: the read scope is missing.
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, read=True)
+ assert await self._connect_status(ctx, pid, token) == 403
+
+ async def test_stream_connect_without_flag_is_403(self, ctx):
+ pid = await _new_project(ctx, "stream-noflag")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_read",))
+ await _add_member(ctx, pid, cid)
+ # can_read_canvas flag is off.
+ assert await self._connect_status(ctx, pid, token) == 403
+
+ async def test_stream_closes_after_read_flag_cleared(self, ctx):
+ """Slice 4 edge case: an agent principal's open stream must close once
+ its can_read_canvas flag is cleared, bounded by the keepalive interval."""
+ from tinyagentos.projects.events import ProjectEvent
+ from tinyagentos.routes.project_canvas import canvas_stream
+
+ pid = await _new_project(ctx, "stream-revoke")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_read",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, read=True)
+ resp = await canvas_stream(pid, _stream_req(ctx.app, token=token))
+ gen = resp.body_iterator
+ broker = ctx.app.state.project_event_broker
+
+ # A published event lets the generator emit a data frame immediately,
+ # proving the agent principal's stream is live.
+ async def _emit(n):
+ await broker.publish(
+ pid, ProjectEvent(kind="canvas.element_added", payload={"n": n})
+ )
+
+ await _emit(1)
+ frame = await asyncio.wait_for(gen.__anext__(), timeout=5)
+ assert frame.startswith("data: ")
+ # Revoke read access while the stream is still open. The permissions
+ # PATCH publishes a canvas.permission_changed event that the live stream
+ # emits (proving liveness through events), then the next keepalive tick
+ # re-checks the flag and closes the stream.
+ await _grant_canvas(ctx, pid, cid, read=False)
+ closed = False
+ try:
+ while True:
+ await asyncio.wait_for(gen.__anext__(), timeout=12)
+ except StopAsyncIteration:
+ closed = True
+ assert closed
+
+
+@pytest.mark.asyncio
+class TestCanvasSnapshotAgentGating:
+ async def test_snapshot_png_allowed_with_read_scope_and_flag(self, ctx):
+ pid = await _new_project(ctx, "snap-read")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_read",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, read=True)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.get(
+ f"/api/projects/{pid}/canvas/snapshot.png", headers=_hdr(token)
+ )
+ assert resp.status_code == 200
+ assert resp.headers.get("content-type") == "image/png"
+
+ async def test_snapshot_png_write_only_is_403(self, ctx):
+ """Snapshots are READ scope only: a canvas_write-only token is denied."""
+ pid = await _new_project(ctx, "snap-wo")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, read=True)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.get(
+ f"/api/projects/{pid}/canvas/snapshot.png", headers=_hdr(token)
+ )
+ assert resp.status_code == 403
+
+ async def test_snapshot_png_without_flag_is_403(self, ctx):
+ pid = await _new_project(ctx, "snap-noflag")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_read",))
+ await _add_member(ctx, pid, cid)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.get(
+ f"/api/projects/{pid}/canvas/snapshot.png", headers=_hdr(token)
+ )
+ assert resp.status_code == 403
+
+ async def test_snapshot_tldr_write_only_is_403(self, ctx):
+ """Snapshots are READ scope only, even for the tldr endpoint."""
+ pid = await _new_project(ctx, "snap-tldr-wo")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, read=True)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.get(
+ f"/api/projects/{pid}/canvas/snapshot.tldr", headers=_hdr(token)
+ )
+ assert resp.status_code == 403
+
+ async def test_snapshot_tldr_allowed_with_read_scope_and_flag(self, ctx):
+ pid = await _new_project(ctx, "snap-tldr-read")
+ snap = ctx.app.state.canvas_snapshotter
+ if snap is None:
+ pytest.skip("canvas_snapshotter not available; needs container backend")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_read",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, read=True)
+ async with _bare(ctx.app) as bare:
+ resp = await bare.get(
+ f"/api/projects/{pid}/canvas/snapshot.tldr", headers=_hdr(token)
+ )
+ assert resp.status_code == 200
+
+
+# ---------------------------------------------------------------------------
+# Slice 4 regression: session owner/admin behavior on stream + snapshots is
+# unchanged by the agent gating.
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestCanvasStreamSnapshotSessionUnchanged:
+ async def test_owner_stream_connect_allowed(self, ctx):
+ from tinyagentos.routes.project_canvas import canvas_stream
+
+ pid = await _new_project(ctx, "owner-stream")
+ resp = await canvas_stream(
+ pid, _stream_req(ctx.app, user_id=ctx.uid, is_admin=True)
+ )
+ from starlette.responses import StreamingResponse as _SR
+
+ assert isinstance(resp, _SR)
+
+ async def test_owner_snapshot_png_allowed(self, ctx):
+ pid = await _new_project(ctx, "owner-snap")
+ resp = await ctx.client.get(
+ f"/api/projects/{pid}/canvas/snapshot.png"
+ )
+ assert resp.status_code == 200
+
+ async def test_session_gating_uses_project_visibility(self, ctx):
+ """A non-owner human session still collapses into 404 (D3 matrix),
+ so agent gating did not change session semantics."""
+ pid = await _new_project(ctx, "nonowner-snap")
+ async with _non_owner_client(ctx.app) as other:
+ resp = await other.get(
+ f"/api/projects/{pid}/canvas/snapshot.png"
+ )
+ assert resp.status_code == 404
+
+
+def _stream_req(app, *, token=None, user_id=None, is_admin=False):
+ """Build a minimal Starlette Request wired to the real app.state so the
+ canvas stream route can be invoked directly (no infinite-body client read)."""
+ from starlette.requests import Request as _StarletteRequest
+
+ headers = []
+ if token is not None:
+ headers.append((b"authorization", f"Bearer {token}".encode()))
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/x",
+ "query_string": b"",
+ "headers": headers,
+ "app": app,
+ }
+ req = _StarletteRequest(scope)
+ req.state.user_id = user_id
+ req.state.is_admin = is_admin
+
+ # is_disconnected() cancels its scope and awaits receive; an immediate,
+ # non-awaiting receive returns a normal request message so the check
+ # resolves to False (the stream is "connected").
+ async def _receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ req._receive = _receive
+ return req
+# Slice 5: payload size cap (413) + agent write rate limit (429).
+# ---------------------------------------------------------------------------
+
+
+class TestCanvasPayloadSizeCap:
+ """The 64 KB payload cap applies to all principals (agents and humans)."""
+
+ @pytest.mark.asyncio
+ async def test_create_oversized_payload_returns_413(self, ctx):
+ pid = await _new_project(ctx, "payload-cap")
+ oversized = "x" * (65 * 1024)
+ body = {
+ "kind": "text",
+ "x": 0, "y": 0, "w": 100, "h": 50,
+ "payload": {"text": oversized},
+ }
+ resp = await ctx.client.post(
+ f"/api/projects/{pid}/canvas/elements", json=body,
+ )
+ assert resp.status_code == 413, resp.text
+
+ @pytest.mark.asyncio
+ async def test_patch_oversized_payload_returns_413(self, ctx):
+ pid = await _new_project(ctx, "payload-cap-patch")
+ el = await _create_note(ctx.client, pid)
+ oversized = "x" * (65 * 1024)
+ resp = await ctx.client.patch(
+ f"/api/projects/{pid}/canvas/elements/{el['id']}",
+ json={"payload": {"text": oversized}},
+ )
+ assert resp.status_code == 413, resp.text
+
+ @pytest.mark.asyncio
+ async def test_create_normal_payload_succeeds(self, ctx):
+ pid = await _new_project(ctx, "payload-cap-ok")
+ body = {
+ "kind": "text",
+ "x": 0, "y": 0, "w": 100, "h": 50,
+ "payload": {"text": "normal content"},
+ }
+ resp = await ctx.client.post(
+ f"/api/projects/{pid}/canvas/elements", json=body,
+ )
+ assert resp.status_code == 201, resp.text
+
+
+class TestAgentWriteRateLimit:
+ """Agents are throttled to 30 canvas writes per 60 s rolling window.
+ Humans (session principals) are never throttled."""
+
+ @pytest.mark.asyncio
+ async def test_agent_exceeding_window_returns_429(self, ctx):
+ import time
+ import tinyagentos.routes.project_canvas as canvas_mod
+
+ pid = await _new_project(ctx, "rate-limit-agent")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, edit=True)
+
+ limiter = canvas_mod._canvas_write_limiter
+ max_attempts = canvas_mod._CANVAS_WRITE_MAX_ATTEMPTS
+ now = time.monotonic()
+ with limiter._lock:
+ limiter._log[cid] = [now - 1.0] * max_attempts
+
+ async with _bare(ctx.app) as bare:
+ resp = await bare.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "rate-limited"}},
+ headers=_hdr(token),
+ )
+ assert resp.status_code == 429, resp.text
+
+ @pytest.mark.asyncio
+ async def test_agent_delete_exceeding_window_returns_429(self, ctx):
+ import time
+ import tinyagentos.routes.project_canvas as canvas_mod
+
+ pid = await _new_project(ctx, "rate-limit-agent-del")
+ cid, token = await _mint_agent(ctx, pid, ("canvas_write",))
+ await _add_member(ctx, pid, cid)
+ await _grant_canvas(ctx, pid, cid, edit=True)
+
+ created = await ctx.client.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "to-delete"}},
+ )
+ assert created.status_code == 201
+ eid = created.json()["element"]["id"]
+
+ limiter = canvas_mod._canvas_write_limiter
+ max_attempts = canvas_mod._CANVAS_WRITE_MAX_ATTEMPTS
+ now = time.monotonic()
+ with limiter._lock:
+ limiter._log[cid] = [now - 1.0] * max_attempts
+
+ async with _bare(ctx.app) as bare:
+ resp = await bare.delete(
+ f"/api/projects/{pid}/canvas/elements/{eid}",
+ headers=_hdr(token),
+ )
+ assert resp.status_code == 429, resp.text
+
+ @pytest.mark.asyncio
+ async def test_human_write_not_rate_limited(self, ctx):
+ pid = await _new_project(ctx, "rate-limit-human")
+ for _ in range(31):
+ resp = await ctx.client.post(
+ f"/api/projects/{pid}/canvas/elements",
+ json={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1,
+ "payload": {"text": "human-write"}},
+ )
+ assert resp.status_code == 201, resp.text
+
diff --git a/tests/test_routes_projects.py b/tests/test_routes_projects.py
index aab5cdd3a..a1a0fd9a7 100644
--- a/tests/test_routes_projects.py
+++ b/tests/test_routes_projects.py
@@ -1,4 +1,5 @@
import pytest
+from httpx import ASGITransport, AsyncClient
@pytest.mark.asyncio
@@ -701,3 +702,78 @@ async def test_ready_tasks_filter_by_element(client):
)).json()["items"]
assert all(t["element_id"] is None for t in none_items)
assert len(none_items) == 1
+
+
+# ---------------------------------------------------------------------------
+# Slice 6 (D7): the Lead designation is an exclusive, project-level pointer
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_set_lead_exclusive_leaves_only_last(client):
+ """Setting lead B after lead A leaves only B; the pointer holds one lead."""
+ pid = (await client.post("/api/projects", json={"name": "P", "slug": "lead-excl"})).json()["id"]
+ await client.post(f"/api/projects/{pid}/members", json={"mode": "native", "agent_id": "agent-a"})
+ await client.post(f"/api/projects/{pid}/members", json={"mode": "native", "agent_id": "agent-b"})
+
+ r1 = await client.patch(f"/api/projects/{pid}/lead", json={"member_id": "agent-a"})
+ assert r1.status_code == 200
+ assert r1.json()["lead_member_id"] == "agent-a"
+
+ r2 = await client.patch(f"/api/projects/{pid}/lead", json={"member_id": "agent-b"})
+ assert r2.status_code == 200
+ assert r2.json()["lead_member_id"] == "agent-b"
+
+ # The pointer cannot hold two leads; only the last one remains.
+ proj = (await client.get(f"/api/projects/{pid}")).json()
+ assert proj["lead_member_id"] == "agent-b"
+
+
+@pytest.mark.asyncio
+async def test_set_lead_non_member_returns_404(client):
+ pid = (await client.post("/api/projects", json={"name": "P", "slug": "lead-404"})).json()["id"]
+ await client.post(f"/api/projects/{pid}/members", json={"mode": "native", "agent_id": "agent-a"})
+
+ r = await client.patch(f"/api/projects/{pid}/lead", json={"member_id": "not-a-member"})
+ assert r.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_clear_lead_to_null(client):
+ pid = (await client.post("/api/projects", json={"name": "P", "slug": "lead-null"})).json()["id"]
+ await client.post(f"/api/projects/{pid}/members", json={"mode": "native", "agent_id": "agent-a"})
+
+ r = await client.patch(f"/api/projects/{pid}/lead", json={"member_id": "agent-a"})
+ assert r.status_code == 200
+ assert r.json()["lead_member_id"] == "agent-a"
+ assert (await client.get(f"/api/projects/{pid}")).json()["lead_member_id"] == "agent-a"
+
+ r = await client.patch(f"/api/projects/{pid}/lead", json={"member_id": None})
+ assert r.status_code == 200
+ assert r.json()["lead_member_id"] is None
+ assert (await client.get(f"/api/projects/{pid}")).json()["lead_member_id"] is None
+
+
+@pytest.mark.asyncio
+async def test_set_lead_requires_session(app, client):
+ """The lead route is session-only: an unauthenticated request is rejected."""
+ pid = (await client.post("/api/projects", json={"name": "P", "slug": "lead-auth"})).json()["id"]
+ await client.post(f"/api/projects/{pid}/members", json={"mode": "native", "agent_id": "agent-a"})
+
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as anon:
+ r = await anon.patch(f"/api/projects/{pid}/lead", json={"member_id": "agent-a"})
+ assert r.status_code in (401, 403)
+
+
+@pytest.mark.asyncio
+async def test_old_per_member_lead_route_removed(client):
+ """The retired per-member lead route is gone (replaced by the project pointer)."""
+ pid = (await client.post("/api/projects", json={"name": "P", "slug": "lead-old"})).json()["id"]
+ await client.post(f"/api/projects/{pid}/members", json={"mode": "native", "agent_id": "agent-a"})
+
+ r = await client.patch(
+ f"/api/projects/{pid}/members/agent-a/lead", json={"is_lead": True}
+ )
+ assert r.status_code == 404
+
diff --git a/tinyagentos/agent_registry_store.py b/tinyagentos/agent_registry_store.py
index 48740bb88..ef4bfb9f5 100644
--- a/tinyagentos/agent_registry_store.py
+++ b/tinyagentos/agent_registry_store.py
@@ -49,6 +49,24 @@
);
"""
+# Partial unique index: at most ONE active agent may own any given non-empty
+# handle. SQLite enforces this atomically, so two concurrent consent approvals
+# of the same identity_claim cannot both flip to 'active' with the same handle.
+# Pending / suspended / revoked rows and empty handles are excluded from the
+# index, so (a) a handle can be reused once its owner leaves 'active', and (b)
+# pre-existing empty-handle active agents never block the index's creation.
+#
+# This index references the ``status`` column, which is added by
+# _migration_v1_add_status on the migration path. Per BaseStore's contract it
+# therefore CANNOT live in SCHEMA (the executescript runs before migrations and
+# would crash on a pre-status table); it is created in _post_init after the
+# migration guarantees the column exists.
+ACTIVE_HANDLE_INDEX = """
+CREATE UNIQUE INDEX IF NOT EXISTS ux_agent_active_handle
+ ON agent_registry(handle)
+ WHERE status = 'active' AND handle != '';
+"""
+
# ---------------------------------------------------------------------------
# Lifecycle state machine
# ---------------------------------------------------------------------------
@@ -348,6 +366,9 @@ async def _post_init(self) -> None:
await _migration_v1_add_status(self._db)
await _migration_v2_strip_at_display_name(self._db)
await _migration_v3_add_org_fields(self._db)
+ # Created after the status migration so the partial index's WHERE clause
+ # can reference the status column on the pre-status migration path.
+ await self._db.executescript(ACTIVE_HANDLE_INDEX)
# ------------------------------------------------------------------
# Registration
@@ -425,6 +446,26 @@ async def register(
# Read
# ------------------------------------------------------------------
+ async def rollback(self) -> None:
+ """Roll back the current transaction, clearing any failed write so the
+ connection can accept new statements. No-op if the store is closed or
+ no transaction is open.
+ """
+ if self._db is not None:
+ await self._db.rollback()
+
+ async def delete(self, canonical_id: str) -> None:
+ """Permanently remove *canonical_id* (used to clean up a half-registered
+ row when a concurrent approve wins the active-handle race). Returns
+ silently if *canonical_id* does not exist.
+ """
+ if self._db is None:
+ raise RuntimeError("AgentRegistryStore not initialised")
+ await self._db.execute(
+ "DELETE FROM agent_registry WHERE canonical_id = ?", (canonical_id,)
+ )
+ await self._db.commit()
+
async def get(self, canonical_id: str) -> Optional[dict]:
"""Return the record for *canonical_id*, or ``None``."""
if self._db is None:
diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py
index 1a0e817ab..343c942f0 100644
--- a/tinyagentos/auth_middleware.py
+++ b/tinyagentos/auth_middleware.py
@@ -58,11 +58,29 @@
("POST", re.compile(rf"^/api/projects/{_SEG}/tasks/{_SEG}/(claim|release|close|reopen)$")),
)
+_AGENT_CANVAS_ROUTES = (
+ ("GET", re.compile(rf"^/api/projects/{_SEG}/canvas/elements$")),
+ ("POST", re.compile(rf"^/api/projects/{_SEG}/canvas/elements$")),
+ ("PATCH", re.compile(rf"^/api/projects/{_SEG}/canvas/elements/{_SEG}$")),
+ ("DELETE", re.compile(rf"^/api/projects/{_SEG}/canvas/elements/{_SEG}$")),
+ ("GET", re.compile(rf"^/api/projects/{_SEG}/canvas/snapshot\.png$")),
+ ("GET", re.compile(rf"^/api/projects/{_SEG}/canvas/snapshot\.tldr$")),
+ ("GET", re.compile(rf"^/api/projects/{_SEG}/canvas/stream$")),
+)
+
def _is_agent_task_path(method: str, path: str) -> bool:
"""True only for the exact subset of task routes a project_tasks token may
reach. Strict method + anchored-regex match; everything else is excluded."""
return any(m == method and rx.match(path) for m, rx in _AGENT_TASK_ROUTES)
+
+
+def _is_agent_canvas_path(method: str, path: str) -> bool:
+ """True only for the exact subset of canvas routes a canvas_read/canvas_write
+ token may reach. Strict method + anchored-regex match; the PATCH permissions
+ route stays session-only (owner/admin only) but the PATCH elements route is
+ reachable by a canvas_write-bound agent token, mirroring POST/DELETE."""
+ return any(m == method and rx.match(path) for m, rx in _AGENT_CANVAS_ROUTES)
# Bundle assets and the SPA shell HTML must be reachable without auth so:
# 1. The browser can install and cache the shell for offline / PWA use.
# 2. After a backend restart the cached shell loads immediately without
@@ -255,6 +273,7 @@ async def dispatch(self, request: Request, call_next):
if (
path in _AGENT_TOKEN_PATHS
or _is_agent_task_path(request.method, path)
+ or _is_agent_canvas_path(request.method, path)
) and auth_header.lower().startswith("bearer "):
request.state.user_id = None
request.state.is_admin = False
diff --git a/tinyagentos/projects/a2a.py b/tinyagentos/projects/a2a.py
index 5d944952b..85bdddcc1 100644
--- a/tinyagentos/projects/a2a.py
+++ b/tinyagentos/projects/a2a.py
@@ -104,31 +104,28 @@ def _resolve_member_names(member_rows: list[dict], config) -> set[str]:
return resolved
-def _resolve_lead_names(member_rows: list[dict], config) -> list[str]:
- """Return sorted list of resolved agent names for members where is_lead = 1.
-
- Uses the same config-lookup strategy as _resolve_member_names. Returns
- names in sorted order for deterministic storage.
+def _resolve_lead_names(project: dict, config) -> list[str]:
+ """Return the resolved agent name for the project's single exclusive lead.
+
+ The lead designation lives on the project as a single ``lead_member_id``
+ pointer (D7), not on the members. At most one name is ever returned; an
+ empty list means no lead. Uses the same config-lookup strategy as
+ _resolve_member_names so the quiet-filter and settings.leads stay in the
+ name-space the @mention parser and router expect.
"""
- leads = [m for m in member_rows if m.get("is_lead")]
- if not leads:
+ lead_id = project.get("lead_member_id")
+ if not lead_id:
return []
by_id, by_name = _build_agent_lookups(config)
- result: list[str] = []
- for m in leads:
- mid = m["member_id"]
- if by_id is None:
- # No config (test path): use member_id as name directly.
- result.append(mid)
- continue
- agent = by_id.get(mid) or by_name.get(mid)
- if agent is None:
- logger.debug("a2a: lead member_id %r not found in config — skipping", mid)
- continue
- name = agent.get("name")
- if name:
- result.append(name)
- return sorted(set(result))
+ if by_id is None:
+ # No config (test path): use the member id directly.
+ return [lead_id]
+ agent = by_id.get(lead_id) or by_name.get(lead_id)
+ if agent is None:
+ logger.debug("a2a: lead member_id %r not found in config — skipping", lead_id)
+ return []
+ name = agent.get("name")
+ return [name] if name else []
async def ensure_a2a_channel(
@@ -143,21 +140,21 @@ async def ensure_a2a_channel(
router both operate on names. When None (test path with name-keyed members),
member_ids are used as-is.
- Also syncs settings.leads from project members where is_lead = 1 so that
- the router can give leads visibility into all channel messages regardless
+ Also syncs settings.leads from the project's exclusive lead_member_id so that
+ the router can give the lead visibility into all channel messages regardless
of response_mode.
Idempotent. Serialized per project_id via _A2A_LOCKS to prevent racing
member-sync diffs when multiple callers fire concurrently.
"""
async with _A2A_LOCKS[project_id]:
+ project = await project_store.get_project(project_id)
project_members = await project_store.list_members(project_id)
expected = _resolve_member_names(project_members, config)
- expected_leads = _resolve_lead_names(project_members, config)
+ expected_leads = _resolve_lead_names(project, config)
matches = await _find_a2a_channels(channel_store, project_id)
if not matches:
- project = await project_store.get_project(project_id)
created_by = project.get("created_by", "system") if project else "system"
return await channel_store.create_channel(
name=A2A_NAME,
diff --git a/tinyagentos/projects/canvas/mcp_tools.py b/tinyagentos/projects/canvas/mcp_tools.py
index ce4cee46c..3d1c8812f 100644
--- a/tinyagentos/projects/canvas/mcp_tools.py
+++ b/tinyagentos/projects/canvas/mcp_tools.py
@@ -30,25 +30,66 @@ class CanvasToolContext:
data_root: Path
-async def canvas_list_elements(ctx: CanvasToolContext, *, project_id: str) -> dict:
+async def canvas_list_elements(
+ ctx: CanvasToolContext, *, project_id: str, agent_id: str
+) -> dict:
+ """List canvas elements an agent can read, gated by can_read_canvas (D3).
+
+ Surfaces the read-permission floor as a clean error, mirroring the other
+ in-process canvas tools so an agent without read access is told what to do
+ instead of silently receiving another principal's board contents."""
+ try:
+ await ctx.canvas_store.check_read_permission(
+ project_id, "agent", agent_id
+ )
+ except CanvasPermissionError:
+ return {
+ "error": "permission_denied",
+ "message": (
+ "This agent does not have read permission on the canvas. "
+ "Ask the user to enable it in project settings, or message "
+ "them to grant access."
+ ),
+ }
elements = await ctx.canvas_store.list_elements(project_id)
return {"elements": elements}
+async def _add_agent_element(
+ ctx: CanvasToolContext, *, project_id: str, agent_id: str, element: dict
+) -> dict:
+ """Add an element authored by an agent, surfacing the edit-permission gate
+ as a clean error (mirrors canvas_update_element / canvas_delete_element)."""
+ try:
+ el = await ctx.canvas_store.add_element(
+ project_id=project_id,
+ author_kind="agent", author_id=agent_id,
+ element=element,
+ )
+ except CanvasPermissionError:
+ return {
+ "error": "permission_denied",
+ "message": (
+ "This agent does not have edit permission on the canvas. "
+ "Ask the user to enable it in project settings, or message "
+ "them to make the change."
+ ),
+ }
+ return {"element": el}
+
+
async def canvas_add_note(
ctx: CanvasToolContext, *, project_id: str, agent_id: str,
text: str, x: float, y: float, color: str = "yellow",
) -> dict:
- el = await ctx.canvas_store.add_element(
- project_id=project_id,
- author_kind="agent", author_id=agent_id,
+ return await _add_agent_element(
+ ctx, project_id=project_id, agent_id=agent_id,
element={
"kind": "note", "x": float(x), "y": float(y),
"w": 200.0, "h": 100.0,
"payload": {"text": text, "color": color, "font_size": 14},
},
)
- return {"element": el}
async def canvas_add_link(
@@ -56,77 +97,67 @@ async def canvas_add_link(
url: str, x: float, y: float,
) -> dict:
meta = await fetch_link_metadata(url)
- el = await ctx.canvas_store.add_element(
- project_id=project_id,
- author_kind="agent", author_id=agent_id,
+ return await _add_agent_element(
+ ctx, project_id=project_id, agent_id=agent_id,
element={
"kind": "link", "x": float(x), "y": float(y),
"w": 320.0, "h": 120.0, "payload": meta,
},
)
- return {"element": el}
async def canvas_add_image(
ctx: CanvasToolContext, *, project_id: str, agent_id: str,
file_id: str, x: float, y: float, alt: str = "",
) -> dict:
- el = await ctx.canvas_store.add_element(
- project_id=project_id,
- author_kind="agent", author_id=agent_id,
+ return await _add_agent_element(
+ ctx, project_id=project_id, agent_id=agent_id,
element={
"kind": "image", "x": float(x), "y": float(y),
"w": 240.0, "h": 240.0,
"payload": {"file_id": file_id, "alt": alt, "mime": "image/png"},
},
)
- return {"element": el}
async def canvas_add_text(
ctx: CanvasToolContext, *, project_id: str, agent_id: str,
text: str, x: float, y: float, font_size: int = 16,
) -> dict:
- el = await ctx.canvas_store.add_element(
- project_id=project_id,
- author_kind="agent", author_id=agent_id,
+ return await _add_agent_element(
+ ctx, project_id=project_id, agent_id=agent_id,
element={
"kind": "text", "x": float(x), "y": float(y),
"w": 220.0, "h": 80.0,
"payload": {"text": text, "font_size": int(font_size)},
},
)
- return {"element": el}
async def canvas_add_mermaid(
ctx: CanvasToolContext, *, project_id: str, agent_id: str,
source: str, x: float, y: float,
) -> dict:
- el = await ctx.canvas_store.add_element(
- project_id=project_id,
- author_kind="agent", author_id=agent_id,
+ return await _add_agent_element(
+ ctx, project_id=project_id, agent_id=agent_id,
element={
"kind": "mermaid", "x": float(x), "y": float(y),
"w": 320.0, "h": 240.0, "payload": {"source": source},
},
)
- return {"element": el}
async def canvas_add_flowchart(
ctx: CanvasToolContext, *, project_id: str, agent_id: str,
source: str, x: float, y: float,
) -> dict:
- el = await ctx.canvas_store.add_element(
- project_id=project_id,
- author_kind="agent", author_id=agent_id,
+ return await _add_agent_element(
+ ctx, project_id=project_id, agent_id=agent_id,
element={
"kind": "flowchart", "x": float(x), "y": float(y),
"w": 320.0, "h": 240.0, "payload": {"source": source},
},
)
- return {"element": el}
async def canvas_add_mindmap_edge(
@@ -147,9 +178,8 @@ async def canvas_add_mindmap_edge(
}
ax, ay = a["x"] + a["w"] / 2, a["y"] + a["h"] / 2
bx, by = b["x"] + b["w"] / 2, b["y"] + b["h"] / 2
- el = await ctx.canvas_store.add_element(
- project_id=project_id,
- author_kind="agent", author_id=agent_id,
+ return await _add_agent_element(
+ ctx, project_id=project_id, agent_id=agent_id,
element={
"kind": "mindmap_edge",
"x": min(ax, bx), "y": min(ay, by),
@@ -157,7 +187,6 @@ async def canvas_add_mindmap_edge(
"payload": {"from": from_id, "to": to_id},
},
)
- return {"element": el}
async def canvas_update_element(
diff --git a/tinyagentos/projects/canvas/store.py b/tinyagentos/projects/canvas/store.py
index 55995e66a..c627574f7 100644
--- a/tinyagentos/projects/canvas/store.py
+++ b/tinyagentos/projects/canvas/store.py
@@ -124,6 +124,10 @@ async def add_element(
raise ValueError(f"agents may not emit kind={kind}")
if author_kind not in ("user", "agent"):
raise ValueError(f"invalid author_kind: {author_kind}")
+ # Enforce the per-project edit permission for agents (defense in depth:
+ # the route already gates on scope + can_edit_canvas, but the store is
+ # the authoritative floor so a direct caller can never bypass it).
+ await self._check_edit_permission(project_id, author_kind, author_id)
eid = element.get("id") or new_id("cve")
now = time.time()
# Upsert: the client may re-send an element it already created (e.g. a
@@ -154,7 +158,10 @@ async def add_element(
)
await self._db.commit()
new_el = await self.get_element(eid)
- await self._publish(project_id, "canvas.element_added", {"element": new_el})
+ await self._publish(
+ project_id, "canvas.element_added",
+ {"element": new_el, "actor": {"kind": author_kind, "id": author_id}},
+ )
return new_el
async def list_elements(
@@ -203,6 +210,30 @@ async def _check_edit_permission(
f"agent {author_id} has no can_edit_canvas on project {project_id}"
)
+ async def check_read_permission(
+ self, project_id: str, author_kind: str, author_id: str
+ ) -> None:
+ """Gate canvas reads on can_read_canvas for agents (D3 read matrix).
+
+ Mirrors _check_edit_permission: humans bypass the floor, agents must hold
+ the per-project can_read_canvas flag, and a missing member row fails
+ closed. This is the in-process analogue of the route-level canvas_read
+ scope check, guarding the agent MCP read path at the store floor."""
+ if author_kind == "user":
+ return
+ if author_kind != "agent":
+ raise ValueError(f"invalid author_kind: {author_kind}")
+ async with self._db.execute(
+ "SELECT can_read_canvas FROM project_members "
+ "WHERE project_id = ? AND member_id = ?",
+ (project_id, author_id),
+ ) as cur:
+ row = await cur.fetchone()
+ if row is None or not row[0]:
+ raise CanvasPermissionError(
+ f"agent {author_id} has no can_read_canvas on project {project_id}"
+ )
+
async def update_element(
self,
*,
@@ -239,7 +270,10 @@ async def update_element(
updated = await self.get_element(element_id, project_id=project_id)
if updated is None:
raise ValueError(f"element not found: {element_id}")
- await self._publish(project_id, "canvas.element_updated", {"element": updated})
+ await self._publish(
+ project_id, "canvas.element_updated",
+ {"element": updated, "actor": {"kind": author_kind, "id": author_id}},
+ )
return updated
async def delete_element(
@@ -262,5 +296,5 @@ async def delete_element(
if cur.rowcount == 1:
await self._publish(
project_id, "canvas.element_deleted",
- {"element_id": element_id},
+ {"element_id": element_id, "actor": {"kind": author_kind, "id": author_id}},
)
diff --git a/tinyagentos/projects/project_store.py b/tinyagentos/projects/project_store.py
index 875b75fd2..ecabdbeb7 100644
--- a/tinyagentos/projects/project_store.py
+++ b/tinyagentos/projects/project_store.py
@@ -20,6 +20,7 @@
updated_at REAL NOT NULL,
archived_at REAL,
deleted_at REAL,
+ lead_member_id TEXT,
settings TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
@@ -67,8 +68,10 @@ class ProjectStore(BaseStore):
async def _post_init(self) -> None:
for col_def in (
"ALTER TABLE project_members ADD COLUMN can_edit_canvas INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE project_members ADD COLUMN can_read_canvas INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE project_members ADD COLUMN is_lead INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE projects ADD COLUMN user_id TEXT NOT NULL DEFAULT ''",
+ "ALTER TABLE projects ADD COLUMN lead_member_id TEXT",
):
try:
await self._db.execute(col_def)
@@ -76,16 +79,50 @@ async def _post_init(self) -> None:
except Exception:
# Column already exists on fresh installs (created by SCHEMA).
pass
+ # D7 backfill: the exclusive per-project lead moves from the per-member
+ # is_lead flag (non-exclusive) to the project's single lead_member_id
+ # pointer. Only projects with EXACTLY one flagged member are migrated;
+ # any with zero or several flagged members are left NULL so a human picks
+ # in the UI (the old flag never promised exclusivity).
+ await self._backfill_lead_member_id()
- async def set_member_lead(self, project_id: str, member_id: str, is_lead: bool) -> None:
- val = 1 if is_lead else 0
- cur = await self._db.execute(
- "UPDATE project_members SET is_lead = ? WHERE project_id = ? AND member_id = ?",
- (val, project_id, member_id),
+ async def _backfill_lead_member_id(self) -> None:
+ rows = await (await self._db.execute(
+ "SELECT id FROM projects WHERE lead_member_id IS NULL"
+ )).fetchall()
+ for (pid,) in rows:
+ cur = await self._db.execute(
+ "SELECT member_id FROM project_members WHERE project_id = ? AND is_lead = 1",
+ (pid,),
+ )
+ flagged = [r[0] for r in await cur.fetchall()]
+ if len(flagged) == 1:
+ await self._db.execute(
+ "UPDATE projects SET lead_member_id = ? WHERE id = ?",
+ (flagged[0], pid),
+ )
+ await self._db.commit()
+
+ async def set_lead(self, project_id: str, member_id: "str | None") -> None:
+ """Set (or clear, when member_id is None) the project's exclusive lead.
+
+ The single pointer column makes the one-lead-per-project invariant
+ structural: setting a new lead atomically unsets any previous one, and
+ there is no partial-update window. A member_id that is not a current
+ member of the project raises KeyError (the route maps it to 404).
+ """
+ p = await self.get_project(project_id)
+ if p is None:
+ raise KeyError(f"project {project_id!r} not found")
+ if member_id is not None:
+ member = await self.get_member(project_id, member_id)
+ if member is None:
+ raise KeyError(f"member {member_id!r} not in project {project_id!r}")
+ await self._db.execute(
+ "UPDATE projects SET lead_member_id = ? WHERE id = ?",
+ (member_id, project_id),
)
await self._db.commit()
- if cur.rowcount == 0:
- raise KeyError(f"member {member_id!r} not in project {project_id!r}")
async def create_project(
self,
@@ -233,6 +270,13 @@ async def remove_member(self, project_id: str, member_id: str) -> None:
"DELETE FROM project_members WHERE project_id = ? AND member_id = ?",
(project_id, member_id),
)
+ # Removing the designated lead unsets the pointer so it can never dangle
+ # on a member that no longer belongs to the project.
+ await self._db.execute(
+ "UPDATE projects SET lead_member_id = NULL "
+ "WHERE id = ? AND lead_member_id = ?",
+ (project_id, member_id),
+ )
await self._db.commit()
async def list_members(self, project_id: str) -> list[dict]:
@@ -244,6 +288,17 @@ async def list_members(self, project_id: str) -> list[dict]:
keys = [d[0] for d in cur.description]
return [dict(zip(keys, r)) for r in rows]
+ async def get_member(self, project_id: str, member_id: str) -> dict | None:
+ async with self._db.execute(
+ "SELECT * FROM project_members WHERE project_id = ? AND member_id = ?",
+ (project_id, member_id),
+ ) as cur:
+ row = await cur.fetchone()
+ if row is None:
+ return None
+ keys = [d[0] for d in cur.description]
+ return dict(zip(keys, row))
+
async def log_activity(
self,
project_id: str,
diff --git a/tinyagentos/routes/agent_auth_requests.py b/tinyagentos/routes/agent_auth_requests.py
index 4e4349e3e..afc5e18c6 100644
--- a/tinyagentos/routes/agent_auth_requests.py
+++ b/tinyagentos/routes/agent_auth_requests.py
@@ -28,7 +28,8 @@
from fastapi.responses import JSONResponse
from pydantic import BaseModel
-from tinyagentos.agent_registry_store import mint_registry_token
+from aiosqlite import IntegrityError
+from tinyagentos.agent_registry_store import _slugify, mint_registry_token
from tinyagentos.auth_context import CurrentUser, current_user
logger = logging.getLogger(__name__)
@@ -54,6 +55,12 @@
# agent's OWN project only (bound by the token's project_id claim). Does NOT
# grant task create, member management, or project lifecycle.
"project_tasks",
+ # Canvas access: read and write on a specific project's canvas. Like
+ # project_tasks, a project_id is required so the token is bound to the
+ # operator-validated project rather than whatever the unauthenticated agent
+ # named in the request.
+ "canvas_read",
+ "canvas_write",
})
@@ -298,17 +305,26 @@ async def _do_approve(request: Request, request_id: str, body: ApproveBody, user
),
)
- # project_tasks binds the token to a specific project and adds a membership
- # row, so require the human to pick that project explicitly in the consent
- # card. Never fall back to the agent-supplied project_id for a task grant:
- # POST /api/agents/auth-requests is unauthenticated, so the request could
- # name any existing project the operator never validated. Other scopes keep
- # the fallback so global tokens still work. Checked before any registration
- # so a rejected approval never leaves an orphaned agent.
- if "project_tasks" in body.granted_scopes and body.project_id is None:
+ _CANVAS_SCOPES = {"canvas_read", "canvas_write"}
+ _PROJECT_SCOPES = {"project_tasks"} | _CANVAS_SCOPES
+
+ # project_tasks and the canvas scopes bind the token to a specific project
+ # and add a membership row, so require the human to pick that project
+ # explicitly in the consent card. Never fall back to the agent-supplied
+ # project_id for these grants: POST /api/agents/auth-requests is
+ # unauthenticated, so the request could name any existing project the
+ # operator never validated. Other scopes keep the fallback so global
+ # tokens still work. Checked before any registration so a rejected approval
+ # never leaves an orphaned agent.
+ needs_project = bool(set(body.granted_scopes) & _PROJECT_SCOPES)
+ # Reject None, "", and whitespace-only: a blank project_id is not a real
+ # binding, and a downstream truthy check would treat it as unbound, so an
+ # empty string must fail closed exactly like a missing one.
+ if needs_project and not (body.project_id and body.project_id.strip()):
+ missing = sorted(set(body.granted_scopes) & _PROJECT_SCOPES)
raise HTTPException(
status_code=400,
- detail="project_id is required when granting project_tasks",
+ detail=f"project_id is required when granting {missing}",
)
registry = _get_registry_store(request)
@@ -324,20 +340,62 @@ async def _do_approve(request: Request, request_id: str, body: ApproveBody, user
# non-empty value.
_claim = record["identity_claim"].strip().removeprefix("@").strip()
display_name = _claim or record["framework"]
- reg_record = await registry.register(
- framework=record["framework"],
- display_name=display_name,
- user_id=user.user_id,
- origin="external-selfjoin",
- handle="",
- )
- canonical_id = reg_record["canonical_id"]
- # Consent approval IS the activation. external-selfjoin agents are born
- # 'pending' (governance lifecycle); approving the auth-request transitions
- # them to 'active' so they are NOT in the bus inactive/revocation feed and
- # @taOSmd's identity-AND-grant gate accepts them.
- await registry.set_status(canonical_id, "active", actor=user.user_id)
+ handle = _slugify(_claim)
+ existing_active = await registry.get_by_handle(handle, status="active")
+ if existing_active is not None:
+ raise HTTPException(
+ status_code=409,
+ detail=(
+ f"handle '{handle}' is already in use by active agent "
+ f"{existing_active['canonical_id']}; pick a different identity_claim"
+ ),
+ )
+
+ # Register with the handle SET at birth. external-selfjoin agents are born
+ # 'pending' (governance lifecycle), so the partial unique index
+ # ux_agent_active_handle (active + non-empty handle) does not fire at INSERT
+ # time; it fires only when we flip the row to 'active' below. That removes
+ # the old window (register handle='' -> set active -> update handle) in which
+ # an agent could sit ACTIVE with an empty handle, and lets SQLite reject a
+ # duplicate active handle the instant a concurrent approve tries to take it.
+ canonical_id = None
+ try:
+ reg_record = await registry.register(
+ framework=record["framework"],
+ display_name=display_name,
+ user_id=user.user_id,
+ origin="external-selfjoin",
+ handle=handle,
+ )
+ canonical_id = reg_record["canonical_id"]
+
+ # Consent approval IS the activation. external-selfjoin agents are born
+ # 'pending' (governance lifecycle); approving the auth-request transitions
+ # them to 'active' so they are NOT in the bus inactive/revocation feed and
+ # @taOSmd's identity-AND-grant gate accepts them.
+ await registry.set_status(canonical_id, "active", actor=user.user_id)
+ except IntegrityError:
+ # A concurrent approve already took this active handle (the partial
+ # unique index fired). Roll back the failed write and remove the
+ # half-registered pending row so we never leave an active-without-handle
+ # agent or a stale pending row. Return the same friendly 409.
+ try:
+ await registry.rollback()
+ except Exception: # noqa: BLE001 - never mask the 409 below
+ pass
+ if canonical_id is not None:
+ try:
+ await registry.delete(canonical_id)
+ except Exception: # noqa: BLE001 - never mask the 409 below
+ pass
+ raise HTTPException(
+ status_code=409,
+ detail=(
+ f"handle '{handle}' is already in use by active agent; "
+ f"pick a different identity_claim"
+ ),
+ )
# Resolve effective project binding: admin override wins; fall back to the
# project_id the agent requested (may be None for a global token).
diff --git a/tinyagentos/routes/agent_registry.py b/tinyagentos/routes/agent_registry.py
index 59597a484..e5c8e4576 100644
--- a/tinyagentos/routes/agent_registry.py
+++ b/tinyagentos/routes/agent_registry.py
@@ -92,6 +92,7 @@ class OrgUpdateRequest(BaseModel):
"files_read", "files_write",
"tools_execute", "registry_feeds_read",
"project_tasks",
+ "canvas_read", "canvas_write",
})
diff --git a/tinyagentos/routes/project_canvas.py b/tinyagentos/routes/project_canvas.py
index 99c72de91..17153d523 100644
--- a/tinyagentos/routes/project_canvas.py
+++ b/tinyagentos/routes/project_canvas.py
@@ -7,9 +7,12 @@
import asyncio
import json
import logging
+import threading
+import time
+from collections import OrderedDict
from typing import Literal
-from fastapi import APIRouter, Request
+from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse, FileResponse, Response
from pydantic import BaseModel, Field
@@ -20,14 +23,137 @@
logger = logging.getLogger(__name__)
router = APIRouter()
+_CANVAS_READ_SCOPE = "canvas_read"
+_CANVAS_WRITE_SCOPE = "canvas_write"
+
+_CANVAS_PAYLOAD_MAX_BYTES = 64 * 1024
+_CANVAS_WRITE_MAX_ATTEMPTS = 30
+_CANVAS_WRITE_WINDOW_SECONDS = 60
+_CANVAS_WRITE_MAX_KEYS = 10_000
+
+
+class _CanvasWriteLimiter:
+ def __init__(self, max_attempts: int = _CANVAS_WRITE_MAX_ATTEMPTS,
+ window_seconds: int = _CANVAS_WRITE_WINDOW_SECONDS):
+ self._max = max_attempts
+ self._window = window_seconds
+ self._log: OrderedDict[str, list[float]] = OrderedDict()
+ self._lock = threading.Lock()
+
+ def _prune(self, key: str) -> None:
+ cutoff = time.monotonic() - self._window
+ if key not in self._log:
+ return
+ self._log[key] = [t for t in self._log[key] if t > cutoff]
+ if not self._log[key]:
+ del self._log[key]
+ else:
+ self._log.move_to_end(key)
+
+ def _ensure_capacity(self) -> None:
+ while len(self._log) >= _CANVAS_WRITE_MAX_KEYS:
+ self._log.popitem(last=False)
+
+ def is_limited(self, key: str) -> bool:
+ with self._lock:
+ self._prune(key)
+ return len(self._log.get(key, [])) >= self._max
+
+ def record(self, key: str) -> None:
+ with self._lock:
+ self._prune(key)
+ if key not in self._log:
+ self._ensure_capacity()
+ self._log[key] = []
+ self._log[key].append(time.monotonic())
+ self._log.move_to_end(key)
+
+
+_canvas_write_limiter = _CanvasWriteLimiter()
+
+
+def _check_payload_size(payload: dict) -> None:
+ size = len(json.dumps(payload, default=str).encode("utf-8"))
+ if size > _CANVAS_PAYLOAD_MAX_BYTES:
+ raise HTTPException(
+ status_code=413,
+ detail=f"canvas payload exceeds {_CANVAS_PAYLOAD_MAX_BYTES // 1024} KB limit",
+ )
+
def _user_id(request: Request) -> str:
- user = getattr(request.state, "user", None)
- if user and isinstance(user, dict) and "id" in user:
- return user["id"]
+ uid = getattr(request.state, "user_id", None)
+ if uid:
+ return uid
return "system"
+async def _authorize_canvas_actor(
+ request: Request, project_id: str, mode: Literal["read", "write"]
+) -> "tuple[str, str] | JSONResponse":
+ """Resolve + authorize the actor for a canvas route.
+
+ Accepts EITHER a session owner/admin (behavior unchanged from before the
+ agent gate) OR an approved external agent's registry JWT bound to THIS
+ project with the matching canvas scope AND the matching per-project member
+ flag:
+
+ * read mode -> canvas_read scope + can_read_canvas member flag
+ * write mode -> canvas_write scope + can_edit_canvas member flag
+
+ Returns ``(actor_kind, actor_id)`` on success, or a JSONResponse to return
+ directly. A token bound to a DIFFERENT project collapses into an
+ existence-hiding 404 (never confirms the project exists). A token for this
+ project that is missing the scope or the member flag gets 403.
+ """
+ uid = getattr(request.state, "user_id", None)
+ if uid:
+ # Session path: project visibility gate (D3). Only the project owner or
+ # an admin may touch the canvas; a human needs no scope and no checkbox,
+ # but a non-owner collapses into the SAME existence-hiding 404 the agent
+ # path uses (compare _get_owned_project on the task routes). Attribution
+ # stays the verified user.
+ ps = request.app.state.project_store
+ project = await ps.get_project(project_id)
+ is_admin = bool(getattr(request.state, "is_admin", False))
+ if project is not None and not is_admin and project.get("user_id") != uid:
+ return JSONResponse({"error": "not found"}, status_code=404)
+ return ("user", _user_id(request))
+ auth_header = request.headers.get("Authorization", "")
+ if not auth_header.lower().startswith("bearer "):
+ # Middleware normally 401s unauthenticated requests before the route
+ # runs; a middleware-bypassing test context reaches here, so fall back
+ # to a system actor (there is no real principal to attribute to).
+ return ("user", "system")
+ from tinyagentos.agent_token_auth import (
+ check_agent_scope_for_project,
+ PROJECT_SCOPE_MISMATCH_DETAIL,
+ )
+ scope = _CANVAS_READ_SCOPE if mode == "read" else _CANVAS_WRITE_SCOPE
+ try:
+ cid = await check_agent_scope_for_project(request, scope, project_id)
+ except HTTPException as exc:
+ if exc.status_code == 403 and exc.detail == PROJECT_SCOPE_MISMATCH_DETAIL:
+ return JSONResponse({"error": "not found"}, status_code=404)
+ raise
+ if cid is None:
+ return JSONResponse({"error": "not found"}, status_code=404)
+ ps = request.app.state.project_store
+ member = await ps.get_member(project_id, cid)
+ flag = (member or {}).get(
+ "can_read_canvas" if mode == "read" else "can_edit_canvas"
+ )
+ if not flag:
+ return JSONResponse(
+ {
+ "error": "permission_denied",
+ "message": f"agent {cid} lacks canvas {mode} access on {project_id}",
+ },
+ status_code=403,
+ )
+ return ("agent", cid)
+
+
class CreateElementIn(BaseModel):
kind: Literal[
"note", "link", "image", "user_shape",
@@ -48,6 +174,9 @@ class CreateElementIn(BaseModel):
async def list_canvas_elements(
project_id: str, request: Request, element_id: str | None = None,
):
+ auth = await _authorize_canvas_actor(request, project_id, "read")
+ if isinstance(auth, JSONResponse):
+ return auth
cs = request.app.state.project_canvas_store
elements = await cs.list_elements(project_id, element_id=element_id)
return {"elements": elements}
@@ -57,8 +186,18 @@ async def list_canvas_elements(
async def create_canvas_element(
project_id: str, payload: CreateElementIn, request: Request,
):
- cs = request.app.state.project_canvas_store
+ auth = await _authorize_canvas_actor(request, project_id, "write")
+ if isinstance(auth, JSONResponse):
+ return auth
+ actor_kind, actor_id = auth
+ if actor_kind == "agent" and _canvas_write_limiter.is_limited(actor_id):
+ return JSONResponse(
+ {"error": "rate_limit", "message": "too many canvas writes, try again later"},
+ status_code=429,
+ )
element = payload.model_dump()
+ _check_payload_size(element.get("payload") or {})
+ cs = request.app.state.project_canvas_store
element_id = element.pop("element_id", None)
if element["kind"] == "link":
url = (element.get("payload") or {}).get("url")
@@ -69,11 +208,13 @@ async def create_canvas_element(
try:
new_el = await cs.add_element(
project_id=project_id, element=element,
- author_kind="user", author_id=_user_id(request),
+ author_kind=actor_kind, author_id=actor_id,
element_id=element_id,
)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
+ if actor_kind == "agent":
+ _canvas_write_limiter.record(actor_id)
return {"element": new_el}
@@ -91,39 +232,67 @@ class PatchElementIn(BaseModel):
async def update_canvas_element(
project_id: str, element_id: str, payload: PatchElementIn, request: Request,
):
+ auth = await _authorize_canvas_actor(request, project_id, "write")
+ if isinstance(auth, JSONResponse):
+ return auth
+ actor_kind, actor_id = auth
+ if actor_kind == "agent" and _canvas_write_limiter.is_limited(actor_id):
+ return JSONResponse(
+ {"error": "rate_limit", "message": "too many canvas writes, try again later"},
+ status_code=429,
+ )
cs = request.app.state.project_canvas_store
patch = {k: v for k, v in payload.model_dump().items() if v is not None}
+ if "payload" in patch:
+ _check_payload_size(patch["payload"])
try:
updated = await cs.update_element(
project_id=project_id, element_id=element_id, patch=patch,
- author_kind="user", author_id=_user_id(request),
+ author_kind=actor_kind, author_id=actor_id,
)
except CanvasPermissionError as e:
return JSONResponse({"error": "permission_denied", "message": str(e)}, status_code=403)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=404)
+ if actor_kind == "agent":
+ _canvas_write_limiter.record(actor_id)
return {"element": updated}
@router.delete("/api/projects/{project_id}/canvas/elements/{element_id}", status_code=204)
async def delete_canvas_element(project_id: str, element_id: str, request: Request):
+ auth = await _authorize_canvas_actor(request, project_id, "write")
+ if isinstance(auth, JSONResponse):
+ return auth
+ actor_kind, actor_id = auth
+ if actor_kind == "agent" and _canvas_write_limiter.is_limited(actor_id):
+ return JSONResponse(
+ {"error": "rate_limit", "message": "too many canvas writes, try again later"},
+ status_code=429,
+ )
cs = request.app.state.project_canvas_store
try:
await cs.delete_element(
project_id=project_id, element_id=element_id,
- author_kind="user", author_id=_user_id(request),
+ author_kind=actor_kind, author_id=actor_id,
)
except CanvasPermissionError as e:
return JSONResponse({"error": "permission_denied", "message": str(e)}, status_code=403)
+ if actor_kind == "agent":
+ _canvas_write_limiter.record(actor_id)
return Response(status_code=204)
class PermissionIn(BaseModel):
- can_edit_canvas: bool
+ can_read_canvas: bool | None = None
+ can_edit_canvas: bool | None = None
@router.get("/api/projects/{project_id}/canvas/snapshot.png")
async def get_canvas_png(project_id: str, request: Request):
+ auth = await _authorize_canvas_actor(request, project_id, "read")
+ if isinstance(auth, JSONResponse):
+ return auth
cs = request.app.state.project_canvas_store
elements = await cs.list_elements(project_id)
project = await request.app.state.project_store.get_project(project_id)
@@ -141,6 +310,9 @@ async def get_canvas_png(project_id: str, request: Request):
@router.get("/api/projects/{project_id}/canvas/snapshot.tldr")
async def get_canvas_tldr(project_id: str, request: Request):
+ auth = await _authorize_canvas_actor(request, project_id, "read")
+ if isinstance(auth, JSONResponse):
+ return auth
snap = request.app.state.canvas_snapshotter
path = await snap.export_now(project_id)
if path is None or not path.exists():
@@ -153,29 +325,68 @@ async def set_canvas_permission(
project_id: str, agent_id: str, payload: PermissionIn, request: Request,
):
ps = request.app.state.project_store
- val = 1 if payload.can_edit_canvas else 0
+ project = await ps.get_project(project_id)
+ if project is None:
+ return JSONResponse({"error": "not found"}, status_code=404)
+ uid = getattr(request.state, "user_id", None)
+ is_admin = bool(getattr(request.state, "is_admin", False))
+ if not uid or (not is_admin and project.get("user_id") != uid):
+ return JSONResponse(
+ {
+ "error": "forbidden",
+ "message": "only the project owner or an admin may change canvas permissions",
+ },
+ status_code=403,
+ )
+ sets: list[str] = []
+ params: list = []
+ if payload.can_read_canvas is not None:
+ sets.append("can_read_canvas = ?")
+ params.append(1 if payload.can_read_canvas else 0)
+ if payload.can_edit_canvas is not None:
+ sets.append("can_edit_canvas = ?")
+ params.append(1 if payload.can_edit_canvas else 0)
+ if not sets:
+ return JSONResponse({"error": "no permission field provided"}, status_code=400)
+ params.extend([project_id, agent_id])
cur = await ps._db.execute(
- "UPDATE project_members SET can_edit_canvas = ? "
+ f"UPDATE project_members SET {', '.join(sets)} "
"WHERE project_id = ? AND member_id = ?",
- (val, project_id, agent_id),
+ params,
)
await ps._db.commit()
if cur.rowcount == 0:
return JSONResponse({"error": "member not found"}, status_code=404)
+ member = await ps.get_member(project_id, agent_id)
broker = request.app.state.project_event_broker
from tinyagentos.projects.events import ProjectEvent
await broker.publish(
project_id,
ProjectEvent(
kind="canvas.permission_changed",
- payload={"agent_id": agent_id, "can_edit_canvas": bool(val)},
+ payload={
+ "actor": {"kind": "user", "id": uid},
+ "agent_id": agent_id,
+ "can_read_canvas": bool(member.get("can_read_canvas")),
+ "can_edit_canvas": bool(member.get("can_edit_canvas")),
+ },
),
)
- return {"ok": True, "agent_id": agent_id, "can_edit_canvas": bool(val)}
+ return {
+ "ok": True,
+ "agent_id": agent_id,
+ "can_read_canvas": bool(member.get("can_read_canvas")),
+ "can_edit_canvas": bool(member.get("can_edit_canvas")),
+ }
@router.get("/api/projects/{project_id}/canvas/stream")
async def canvas_stream(project_id: str, request: Request):
+ auth = await _authorize_canvas_actor(request, project_id, "read")
+ if isinstance(auth, JSONResponse):
+ return auth
+ actor_kind, actor_id = auth
+ ps = request.app.state.project_store
broker = request.app.state.project_event_broker
queue = await broker.subscribe(project_id)
@@ -187,6 +398,13 @@ async def gen():
try:
ev = await asyncio.wait_for(queue.get(), timeout=10.0)
except asyncio.TimeoutError:
+ # Keepalive tick: re-verify a live agent principal's read
+ # access so a revoked/removed agent cannot keep a long-lived
+ # SSE open (slice 4). Session users are unaffected.
+ if actor_kind == "agent":
+ member = await ps.get_member(project_id, actor_id)
+ if not (member or {}).get("can_read_canvas"):
+ return
yield ":keepalive\n\n"
continue
if not str(ev.kind).startswith("canvas."):
diff --git a/tinyagentos/routes/projects.py b/tinyagentos/routes/projects.py
index c7a6d4b14..e2bd8d5e9 100644
--- a/tinyagentos/routes/projects.py
+++ b/tinyagentos/routes/projects.py
@@ -296,27 +296,38 @@ async def list_members(
return {"items": await store.list_members(project_id)}
-class LeadIn(BaseModel):
- is_lead: bool
+class ProjectLeadIn(BaseModel):
+ member_id: "str | None" = None
-@router.patch("/api/projects/{project_id}/members/{member_id}/lead")
-async def set_lead(
+@router.patch("/api/projects/{project_id}/lead")
+async def set_project_lead(
project_id: str,
- member_id: str,
- body: LeadIn,
+ body: ProjectLeadIn,
request: Request,
user: CurrentUser = Depends(current_user),
):
+ """Set the project's exclusive Lead (D7). The single lead_member_id pointer
+ makes the one-lead-per-project invariant structural: setting a new lead
+ atomically unsets the previous one. ``member_id: null`` clears the lead.
+
+ Session-only (owner or admin, same gate as the members routes). A member id
+ not in the project returns 404.
+ """
store = request.app.state.project_store
p = await store.get_project(project_id)
if p is None:
return JSONResponse({"error": "not found"}, status_code=404)
require_owner_or_admin(user, p["user_id"])
try:
- await store.set_member_lead(project_id, member_id, body.is_lead)
+ await store.set_lead(project_id, body.member_id)
except KeyError as e:
return JSONResponse({"error": str(e)}, status_code=404)
+ await store.log_activity(
+ project_id, user.user_id, "project.lead_changed", {"member_id": body.member_id}
+ )
+ members = await store.list_members(project_id)
+ _mirror(request, {**p, "members": members})
try:
from tinyagentos.projects.a2a import ensure_a2a_channel
await ensure_a2a_channel(
@@ -327,7 +338,7 @@ async def set_lead(
)
except Exception:
logger.warning("a2a ensure failed for project %s on set_lead", project_id, exc_info=True)
- return {"ok": True, "is_lead": body.is_lead}
+ return {"ok": True, "lead_member_id": body.member_id}
@router.delete("/api/projects/{project_id}/members/{member_id}")