diff --git a/.github/workflows/e2e-api.yml b/.github/workflows/e2e-api.yml
index bc9e629b..782cbedc 100644
--- a/.github/workflows/e2e-api.yml
+++ b/.github/workflows/e2e-api.yml
@@ -172,6 +172,9 @@ jobs:
- name: Run poll-mode + since_id cursor E2E (#2339)
if: needs.detect-changes.outputs.api == 'true'
run: bash tests/e2e/test_poll_mode_e2e.sh
+ - name: Run poll-mode chat upload E2E (RFC #2891)
+ if: needs.detect-changes.outputs.api == 'true'
+ run: bash tests/e2e/test_poll_mode_chat_upload_e2e.sh
- name: Dump platform log on failure
if: failure() && needs.detect-changes.outputs.api == 'true'
run: cat workspace-server/platform.log || true
diff --git a/canvas/src/app/orgs/page.tsx b/canvas/src/app/orgs/page.tsx
index 3c5576ef..a137ac2e 100644
--- a/canvas/src/app/orgs/page.tsx
+++ b/canvas/src/app/orgs/page.tsx
@@ -18,7 +18,7 @@
// quick bounce between signup and either Checkout or the tenant UI.
import { useEffect, useState } from "react";
-import { fetchSession, redirectToLogin, type Session } from "@/lib/auth";
+import { fetchSession, redirectToLogin, signOut, type Session } from "@/lib/auth";
import { PLATFORM_URL } from "@/lib/api";
import { formatCredits, pillTone, bannerKind } from "@/lib/credits";
import { TermsGate } from "@/components/TermsGate";
@@ -129,7 +129,7 @@ export default function OrgsPage() {
return : null} />;
}
return (
-
+
{justCheckedOut && }
{orgs.map((o) => (
@@ -160,11 +160,21 @@ function CheckoutBanner() {
);
}
-function Shell({ children }: { children: React.ReactNode }) {
+function Shell({
+ children,
+ session,
+}: {
+ children: React.ReactNode;
+ // Optional: when present, the header renders the signed-in email +
+ // a Sign-out button. The empty-state Shell call doesn't have a
+ // session in scope, so accept null and skip the header chrome there.
+ session?: Session | null;
+}) {
return (
+ {session ? : null}
Your organizations
Each org is an isolated Molecule workspace.
@@ -177,6 +187,40 @@ function Shell({ children }: { children: React.ReactNode }) {
);
}
+// AccountBar renders the signed-in email + a Sign-out button at the
+// top of the page. Without this the user has no way to log out — the
+// /cp/auth/signout endpoint exists on the control plane but no UI ever
+// called it. Reported externally on 2026-05-05; this is the fix.
+//
+// Click → calls signOut() which POSTs /cp/auth/signout (clears the
+// WorkOS session cookie + revokes at the provider) then bounces to
+// /cp/auth/login. The signOut helper is best-effort — even on a 5xx
+// or network failure the redirect fires so the user never gets stuck
+// on an authed-looking page after they clicked Sign out.
+function AccountBar({ session }: { session: Session }) {
+ const [signingOut, setSigningOut] = useState(false);
+ return (
+
+ {session.email}
+
+
+ );
+}
+
// DataResidencyNotice surfaces where workspace data lives so EU-based
// signups can make an informed choice (GDPR Art. 13 disclosure
// requirement). Plain text, no icon — the goal is clarity, not
diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx
index fc327ea0..074d96fc 100644
--- a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx
+++ b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useMemo, useRef } from "react";
+import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
@@ -184,13 +184,23 @@ function unwrapErrorText(raw: string | null): string {
export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
const [messages, setMessages] = useState([]);
const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(null);
// Dedup by timestamp+type+peer to handle API load + WebSocket race
const seenKeys = useRef(new Set());
const bottomRef = useRef(null);
+ // Mirrors the my-chat scroll behaviour from ChatTab (PR #2903) —
+ // smooth-scroll on a long history gets interrupted by concurrent
+ // renders and lands the panel mid-conversation. Switch the first
+ // arrival to instant; subsequent appends animate.
+ const hasInitialScrollRef = useRef(false);
- // Load history
- useEffect(() => {
+ // Load history. Extracted so the error-state retry button can
+ // re-invoke without remount. ChatTab uses the same shape
+ // (loadInitial → loadError state → retry button).
+ const loadInitial = useCallback(() => {
setLoading(true);
+ setLoadError(null);
+ seenKeys.current.clear();
api.get(`/workspaces/${workspaceId}/activity?source=agent&limit=50`)
.then((entries) => {
const filtered = (entries ?? [])
@@ -234,10 +244,15 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
// the .then body) — the panel just sat on the empty state
// with zero signal.
console.warn("AgentCommsPanel: load activity failed", err);
+ setLoadError(err instanceof Error ? err.message : String(err));
setLoading(false);
});
}, [workspaceId]);
+ useEffect(() => {
+ loadInitial();
+ }, [loadInitial]);
+
// Live updates routed through the global ReconnectingSocket. The
// previous pattern of `new WebSocket(WS_URL)` per panel had no
// onclose / no reconnect, so any drop (idle timeout, browser
@@ -358,7 +373,18 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
} catch { /* ignore */ }
});
- useEffect(() => {
+ // useLayoutEffect (not useEffect) so the scroll runs BEFORE paint —
+ // otherwise the user sees the panel jump for one frame on every
+ // append. Mirrors ChatTab's MyChatPanel scroll block.
+ useLayoutEffect(() => {
+ if (!hasInitialScrollRef.current && messages.length > 0) {
+ // Instant on first arrival — smooth-scroll on a long history
+ // gets interrupted by concurrent renders and lands the panel
+ // mid-conversation (the chat-opens-in-middle bug class).
+ hasInitialScrollRef.current = true;
+ bottomRef.current?.scrollIntoView({ behavior: "instant" as ScrollBehavior });
+ return;
+ }
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
@@ -366,6 +392,27 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
return
Loading agent communications...
;
}
+ if (loadError !== null && messages.length === 0) {
+ // Mirrors ChatTab my-chat error UI — surfaces the load failure
+ // with a retry button instead of silently rendering empty state.
+ return (
+
+
+ Failed to load agent communications: {loadError}
+
+
+
+ );
+ }
+
if (messages.length === 0) {
return (
diff --git a/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.render.test.tsx b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.render.test.tsx
new file mode 100644
index 00000000..80b37982
--- /dev/null
+++ b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.render.test.tsx
@@ -0,0 +1,115 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+
+// API mock — tests can override per case via apiGetMock.mockImplementationOnce.
+const apiGetMock = vi.fn<(url: string) => Promise>();
+vi.mock("@/lib/api", () => ({
+ api: {
+ get: (url: string) => apiGetMock(url),
+ },
+}));
+
+// useSocketEvent — no-op for these render tests; live updates aren't
+// what we're verifying here.
+vi.mock("@/hooks/useSocketEvent", () => ({
+ useSocketEvent: () => {},
+}));
+
+// Canvas store — peer name resolution.
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: {
+ getState: () => ({
+ nodes: [
+ { id: "ws-self", data: { name: "Self" } },
+ { id: "ws-peer", data: { name: "Peer Agent" } },
+ ],
+ }),
+ },
+}));
+
+// Toaster shim — AgentCommsPanel imports showToast.
+vi.mock("../../Toaster", () => ({
+ showToast: vi.fn(),
+}));
+
+import { AgentCommsPanel } from "../AgentCommsPanel";
+
+// jsdom doesn't implement scrollIntoView. Tests that observe the call
+// install a spy here; tests that don't care still need a no-op stub
+// so the component doesn't throw.
+const scrollSpy = vi.fn<(opts?: ScrollIntoViewOptions | boolean) => void>();
+beforeEach(() => {
+ apiGetMock.mockReset();
+ scrollSpy.mockReset();
+ Element.prototype.scrollIntoView = scrollSpy as unknown as Element["scrollIntoView"];
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("AgentCommsPanel — initial-state parity with ChatTab my-chat", () => {
+ it("shows loading text while history fetch is in flight", () => {
+ apiGetMock.mockReturnValueOnce(new Promise(() => { /* never resolves */ }));
+ render();
+ expect(screen.getByText("Loading agent communications...")).toBeDefined();
+ });
+
+ it("renders error UI with a Retry button when the history fetch rejects", async () => {
+ apiGetMock.mockRejectedValueOnce(new Error("network down"));
+ render();
+
+ // Wait for the error state to render — loading→error transition is async.
+ const alert = await waitFor(() => screen.getByRole("alert"));
+ expect(alert.textContent).toMatch(/Failed to load agent communications/);
+ expect(alert.textContent).toMatch(/network down/);
+
+ // Retry button must be present and trigger a refetch.
+ const retry = screen.getByRole("button", { name: "Retry" });
+ apiGetMock.mockResolvedValueOnce([]); // success on retry
+ fireEvent.click(retry);
+
+ // Two calls total: initial load + retry. Pin via mock call count.
+ await waitFor(() => expect(apiGetMock.mock.calls.length).toBe(2));
+ });
+
+ it("falls back to empty-state copy when load succeeds with zero rows", async () => {
+ apiGetMock.mockResolvedValueOnce([]);
+ render();
+ await waitFor(() =>
+ expect(screen.getByText("No agent-to-agent communications yet.")).toBeDefined(),
+ );
+ });
+
+ it("scrollIntoView is called with behavior=instant on the first message arrival", async () => {
+ apiGetMock.mockResolvedValueOnce([
+ {
+ id: "act-1",
+ activity_type: "a2a_send",
+ source_id: "ws-self",
+ target_id: "ws-peer",
+ method: "message/send",
+ summary: "Delegating",
+ request_body: { message: { parts: [{ text: "hi" }] } },
+ response_body: null,
+ status: "ok",
+ created_at: "2026-04-25T18:00:00Z",
+ },
+ ]);
+ render();
+
+ // useLayoutEffect is what makes the first call instant — wait for
+ // the panel to render at least one message.
+ await waitFor(() => expect(scrollSpy.mock.calls.length).toBeGreaterThan(0));
+
+ // The pinned contract: SOME call uses behavior: "instant" — the
+ // first-arrival case. Subsequent appends use "smooth", but those
+ // can't fire here (no live update yet).
+ const sawInstant = scrollSpy.mock.calls.some((args) => {
+ const opts = args[0];
+ return typeof opts === "object" && opts !== null && "behavior" in opts && opts.behavior === "instant";
+ });
+ expect(sawInstant).toBe(true);
+ });
+});
diff --git a/canvas/src/lib/__tests__/auth.test.ts b/canvas/src/lib/__tests__/auth.test.ts
index ee74a521..5f9b76b3 100644
--- a/canvas/src/lib/__tests__/auth.test.ts
+++ b/canvas/src/lib/__tests__/auth.test.ts
@@ -2,7 +2,7 @@
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, afterEach } from "vitest";
-import { fetchSession, redirectToLogin } from "../auth";
+import { fetchSession, redirectToLogin, signOut } from "../auth";
afterEach(() => {
vi.unstubAllGlobals();
@@ -110,3 +110,157 @@ describe("redirectToLogin", () => {
expect((window.location as unknown as { href: string }).href).toBe(signupHref);
});
});
+
+describe("signOut", () => {
+ // Helper — most tests need the same window.location stub.
+ function stubLocation(): void {
+ Object.defineProperty(window, "location", {
+ writable: true,
+ value: {
+ href: "https://acme.moleculesai.app/orgs",
+ pathname: "/orgs",
+ hostname: "acme.moleculesai.app",
+ protocol: "https:",
+ },
+ });
+ }
+
+ it("POSTs to /cp/auth/signout with credentials:include", async () => {
+ stubLocation();
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ ok: true, logout_url: "" }),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ await signOut();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/cp/auth/signout"),
+ expect.objectContaining({ method: "POST", credentials: "include" }),
+ );
+ });
+
+ it("navigates to provider logout_url when the response includes one", async () => {
+ // The hosted-logout path is what actually breaks the SSO re-auth
+ // loop reported on PR #2913. Without this, AuthKit's browser
+ // cookie keeps the user signed in via SSO and any subsequent
+ // /cp/auth/login silently re-auths.
+ stubLocation();
+ const hostedLogout =
+ "https://api.workos.com/user_management/sessions/logout?session_id=cookie&return_to=https%3A%2F%2Fapp.moleculesai.app%2Forgs";
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ ok: true, logout_url: hostedLogout }),
+ }),
+ );
+
+ await signOut();
+
+ const after = (window.location as unknown as { href: string }).href;
+ expect(after).toBe(hostedLogout);
+ });
+
+ it("falls back to /cp/auth/login when logout_url is empty (DisabledProvider / dev)", async () => {
+ // DisabledProvider returns "" — the local /cp/auth/login redirect
+ // works in dev/test where there's no SSO session to escape.
+ stubLocation();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ ok: true, logout_url: "" }),
+ }),
+ );
+
+ await signOut();
+
+ const after = (window.location as unknown as { href: string }).href;
+ // Tenant subdomain (acme.moleculesai.app) → auth origin is app.moleculesai.app.
+ expect(after).toBe("https://app.moleculesai.app/cp/auth/login");
+ });
+
+ it("redirects even when the POST fails so the user isn't stuck on an authed page", async () => {
+ // Critical UX invariant: clicking 'Sign out' MUST navigate away from
+ // the authenticated app, even if the network is down or the cookie
+ // is already invalid. Anything else looks like the button is
+ // broken — the precise complaint that triggered this fix.
+ stubLocation();
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
+
+ await signOut();
+
+ const after = (window.location as unknown as { href: string }).href;
+ expect(after).toBe("https://app.moleculesai.app/cp/auth/login");
+ });
+
+ it("redirects on 401 (session already invalid) just like 200", async () => {
+ // A user with an already-invalid cookie should still see the
+ // logout flow complete — no error, no stuck-on-app dead end.
+ // Note: 401 means res.ok=false → we don't read .json() at all,
+ // so a missing body is fine.
+ stubLocation();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: async () => ({}),
+ }),
+ );
+
+ await signOut();
+
+ const after = (window.location as unknown as { href: string }).href;
+ expect(after).toBe("https://app.moleculesai.app/cp/auth/login");
+ });
+
+ it("falls back to /cp/auth/login when the response body is malformed", async () => {
+ // Defensive parsing: a body that isn't valid JSON, or doesn't
+ // have logout_url, or has logout_url as the wrong type — none of
+ // these should strand the user on the authed page. Fallback path
+ // takes over.
+ stubLocation();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => {
+ throw new Error("not json");
+ },
+ }),
+ );
+
+ await signOut();
+
+ const after = (window.location as unknown as { href: string }).href;
+ expect(after).toBe("https://app.moleculesai.app/cp/auth/login");
+ });
+
+ it("falls back to /cp/auth/login when logout_url is the wrong type", async () => {
+ // Even valid JSON should be type-checked: a non-string logout_url
+ // (e.g. server-side bug, version drift) must not crash or open-
+ // redirect the user.
+ stubLocation();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ ok: true, logout_url: 42 }),
+ }),
+ );
+
+ await signOut();
+
+ const after = (window.location as unknown as { href: string }).href;
+ expect(after).toBe("https://app.moleculesai.app/cp/auth/login");
+ });
+});
diff --git a/canvas/src/lib/auth.ts b/canvas/src/lib/auth.ts
index fe7c71ab..d091c2cb 100644
--- a/canvas/src/lib/auth.ts
+++ b/canvas/src/lib/auth.ts
@@ -67,3 +67,80 @@ export function redirectToLogin(screenHint: "sign-up" | "sign-in" = "sign-in"):
const dest = `${authOrigin}${AUTH_BASE}/${path}?return_to=${encodeURIComponent(returnTo)}`;
window.location.href = dest;
}
+
+/**
+ * signOut posts to /cp/auth/signout to clear the WorkOS session cookie
+ * + revoke at the provider, then navigates the browser to the
+ * provider-supplied hosted logout URL (so the provider's BROWSER-side
+ * SSO cookie is cleared too — without this, AuthKit silently re-auths
+ * via SSO on the next /cp/auth/login and the user is "still signed
+ * in" after pressing Sign out).
+ *
+ * Two-layer flow:
+ * 1. POST /cp/auth/signout → CP clears OUR session cookie + revokes
+ * session_id at the provider API. Response includes
+ * `logout_url` — the AuthKit hosted URL the BROWSER must navigate
+ * to so the provider's own browser cookie is cleared.
+ * 2. window.location.href = → AuthKit clears its
+ * session, then redirects the browser to the configured
+ * return_to (defaults to APP_URL/orgs).
+ *
+ * Best-effort by design: a 5xx, network failure, missing logout_url
+ * (DisabledProvider, dev), or stale cookie still results in the
+ * browser navigating away — leaving the user on a logged-in-looking
+ * page after they clicked "Sign out" is the worst possible UX. The
+ * fallback path navigates to /cp/auth/login on the auth origin, which
+ * works correctly in environments without a hosted logout flow (dev,
+ * tests, DisabledProvider).
+ *
+ * Throws nothing — callers can disable the button optimistically or
+ * await this and trust it returns. On a redirect-blocked test
+ * environment (jsdom under vitest) we still exit cleanly so unit tests
+ * can spy on the fetch call.
+ */
+export async function signOut(): Promise {
+ let logoutURL: string | undefined;
+ // Fire-and-tolerate the POST. credentials:include is mandatory cross-
+ // origin so the SaaS canvas (acme.moleculesai.app) can hit
+ // app.moleculesai.app/cp/auth/signout with the session cookie.
+ try {
+ const res = await fetch(`${getAuthOrigin()}${AUTH_BASE}/signout`, {
+ method: "POST",
+ credentials: "include",
+ });
+ if (res.ok) {
+ // Body shape: {"ok": true, "logout_url": "..."}. logout_url is
+ // empty for DisabledProvider (dev/local) — we fall back to
+ // /cp/auth/login below. Defensive parsing: a malformed body
+ // shouldn't strand the user on the authed page.
+ const body: unknown = await res.json().catch(() => null);
+ if (
+ body &&
+ typeof body === "object" &&
+ "logout_url" in body &&
+ typeof (body as { logout_url: unknown }).logout_url === "string" &&
+ (body as { logout_url: string }).logout_url
+ ) {
+ logoutURL = (body as { logout_url: string }).logout_url;
+ }
+ }
+ } catch {
+ // Ignore — we still redirect below.
+ }
+ if (typeof window === "undefined") return;
+ if (logoutURL) {
+ // Hosted logout: AuthKit clears its SSO cookie + redirects to
+ // return_to (configured server-side). This is the path that
+ // actually breaks the SSO re-auth loop.
+ window.location.href = logoutURL;
+ return;
+ }
+ // Fallback: no hosted logout (dev, DisabledProvider, network
+ // failure). Land on the login screen rather than the current URL:
+ // returning to a tenant URL after signout would just re-redirect
+ // through /cp/auth/login due to AuthGate. Send the user straight
+ // there with no return_to so they don't loop back into the org they
+ // just left.
+ const authOrigin = getAuthOrigin();
+ window.location.href = `${authOrigin}${AUTH_BASE}/login`;
+}
diff --git a/tests/e2e/test_poll_mode_chat_upload_e2e.sh b/tests/e2e/test_poll_mode_chat_upload_e2e.sh
new file mode 100755
index 00000000..fbed604f
--- /dev/null
+++ b/tests/e2e/test_poll_mode_chat_upload_e2e.sh
@@ -0,0 +1,295 @@
+#!/usr/bin/env bash
+# E2E for poll-mode chat upload (RFC #2891 phases 1-5b).
+#
+# Round-trip: register a workspace as poll-mode (no callback URL) → POST a
+# multi-file chat upload → verify each file becomes (a) one
+# `chat_upload_receive` activity row and (b) one /pending-uploads row → fetch
+# the bytes back via the poll endpoint → ack → verify the row 404s on
+# subsequent fetch. Also pins cross-workspace bleed protection: workspace B
+# cannot read workspace A's pending uploads even with its own valid bearer.
+#
+# Why this exists separately from test_chat_upload_e2e.sh: that script
+# covers the PUSH path (the workspace's own /internal/chat/uploads/ingest).
+# This script covers the POLL path: the same canvas-side request lands on
+# the platform's pendinguploads.Storage instead, and the workspace fetches
+# it later. The two paths share zero handler code on the platform side, so
+# both need their own E2E.
+#
+# Requires: platform running on localhost:8080 with migrations applied.
+# bash workspace-server/scripts/dev-start.sh
+# bash workspace-server/scripts/run-migrations.sh
+#
+# Idempotent: each run uses fresh per-script workspace UUIDs so reruns
+# don't collide. Best-effort cleanup on EXIT — does NOT call
+# e2e_cleanup_all_workspaces (see
+# `feedback_never_run_cluster_cleanup_tests_on_live_platform.md`).
+
+set -euo pipefail
+
+source "$(dirname "$0")/_lib.sh"
+
+PASS=0
+FAIL=0
+TIMEOUT="${A2A_TIMEOUT:-30}"
+
+gen_uuid() {
+ if command -v uuidgen >/dev/null 2>&1; then
+ uuidgen | tr '[:upper:]' '[:lower:]'
+ else
+ python3 -c 'import uuid; print(uuid.uuid4())'
+ fi
+}
+WS_A="$(gen_uuid)"
+WS_B="$(gen_uuid)"
+
+# Per-run scratch dir collected under one trap so every assertion-failure
+# path drops the temp files it made (see test_chat_attachments_e2e.sh).
+TMPDIR_E2E=$(mktemp -d -t poll-chat-upload-e2e-XXXXXX)
+
+cleanup() {
+ local rc=$?
+ curl -s -X DELETE "$BASE/workspaces/$WS_A?confirm=true" >/dev/null 2>&1 || true
+ curl -s -X DELETE "$BASE/workspaces/$WS_B?confirm=true" >/dev/null 2>&1 || true
+ rm -rf "$TMPDIR_E2E"
+ exit $rc
+}
+trap cleanup EXIT INT TERM
+
+check() {
+ local desc="$1" expected="$2" actual="$3"
+ if echo "$actual" | grep -qF -- "$expected"; then
+ echo "PASS: $desc"
+ PASS=$((PASS + 1))
+ else
+ echo "FAIL: $desc"
+ echo " expected to contain: $expected"
+ echo " got: $(echo "$actual" | head -10)"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+check_eq() {
+ local desc="$1" expected="$2" actual="$3"
+ if [ "$actual" = "$expected" ]; then
+ echo "PASS: $desc"
+ PASS=$((PASS + 1))
+ else
+ echo "FAIL: $desc"
+ echo " expected: $expected"
+ echo " got: $actual"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+echo "=== Poll-Mode Chat Upload E2E ==="
+echo " base: $BASE"
+echo " workspace A: $WS_A"
+echo " workspace B: $WS_B"
+echo ""
+
+# ---------- Phase 1: register poll-mode workspace ----------
+echo "--- Phase 1: Register poll-mode workspace A ---"
+
+REG_A=$(curl -s -X POST "$BASE/registry/register" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"id\": \"$WS_A\",
+ \"delivery_mode\": \"poll\",
+ \"agent_card\": {\"name\": \"poll-chat-upload-test-a\"}
+ }")
+check "register accepts poll mode without URL" '"status":"registered"' "$REG_A"
+TOK_A=$(echo "$REG_A" | e2e_extract_token || true)
+[ -n "$TOK_A" ] || { echo "FAIL: no auth_token in register response (ws A)"; FAIL=$((FAIL + 1)); exit 1; }
+
+# ---------- Phase 2: multi-file chat upload ----------
+echo ""
+echo "--- Phase 2: POST /chat/uploads with two files ---"
+
+FILE1="$TMPDIR_E2E/alpha.txt"
+FILE2="$TMPDIR_E2E/beta.txt"
+EXPECTED1="alpha-secret-$(openssl rand -hex 4)"
+EXPECTED2="beta-secret-$(openssl rand -hex 4)"
+printf '%s' "$EXPECTED1" > "$FILE1"
+printf '%s' "$EXPECTED2" > "$FILE2"
+
+UPLOAD=$(curl -s -X POST "$BASE/workspaces/$WS_A/chat/uploads" \
+ -H "Authorization: Bearer $TOK_A" \
+ -F "files=@$FILE1;filename=alpha.txt;type=text/plain" \
+ -F "files=@$FILE2;filename=beta.txt;type=text/plain" \
+ -w "\nHTTP_CODE=%{http_code}\n")
+UPLOAD_CODE=$(echo "$UPLOAD" | grep -oE 'HTTP_CODE=[0-9]+' | cut -d= -f2)
+UPLOAD_BODY=$(echo "$UPLOAD" | sed '/^HTTP_CODE=/,$d')
+
+check_eq "upload returns 200" "200" "$UPLOAD_CODE"
+check "upload response has files array" '"files":' "$UPLOAD_BODY"
+
+# Pull file_ids out of the URI in the response. URI shape is
+# `platform-pending:/` — proves the response came from the
+# poll-mode branch, not the push-mode internal-ingest branch.
+URI1=$(echo "$UPLOAD_BODY" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["files"][0]["uri"])')
+URI2=$(echo "$UPLOAD_BODY" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["files"][1]["uri"])')
+check "URI 1 has platform-pending: scheme" "platform-pending:$WS_A/" "$URI1"
+check "URI 2 has platform-pending: scheme" "platform-pending:$WS_A/" "$URI2"
+
+FID1="${URI1##*/}"
+FID2="${URI2##*/}"
+[ -n "$FID1" ] && [ -n "$FID2" ] || { echo "FAIL: could not extract file IDs"; FAIL=$((FAIL + 1)); exit 1; }
+echo " file_id 1: $FID1"
+echo " file_id 2: $FID2"
+
+# ---------- Phase 3: activity rows visible to the workspace ----------
+echo ""
+echo "--- Phase 3: /activity shows two chat_upload_receive rows ---"
+
+# activity_logs INSERTs run in a goroutine — give them a moment.
+sleep 1
+ACT=$(curl -s --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
+ "$BASE/workspaces/$WS_A/activity?type=a2a_receive&limit=20")
+check "activity feed has the alpha file" "$FID1" "$ACT"
+check "activity feed has the beta file" "$FID2" "$ACT"
+check "activity rows tagged chat_upload_receive" '"method":"chat_upload_receive"' "$ACT"
+check "activity rows record alpha mimetype" '"mimeType":"text/plain"' "$ACT"
+
+CHAT_UPLOAD_COUNT=$(echo "$ACT" | python3 -c '
+import json, sys
+rows = json.load(sys.stdin)
+n = sum(1 for r in rows if (r.get("method") or "") == "chat_upload_receive")
+print(n)
+')
+check_eq "exactly two chat_upload_receive rows" "2" "$CHAT_UPLOAD_COUNT"
+
+# ---------- Phase 4: GET /pending-uploads/:file_id/content ----------
+echo ""
+echo "--- Phase 4: Fetch content for each pending upload ---"
+
+GOT1=$(curl -s --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
+ "$BASE/workspaces/$WS_A/pending-uploads/$FID1/content")
+check_eq "alpha bytes round-trip" "$EXPECTED1" "$GOT1"
+
+GOT2=$(curl -s --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
+ "$BASE/workspaces/$WS_A/pending-uploads/$FID2/content")
+check_eq "beta bytes round-trip" "$EXPECTED2" "$GOT2"
+
+# Mimetype + Content-Disposition headers should match what was uploaded.
+HEAD1=$(curl -s -D - -o /dev/null --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
+ "$BASE/workspaces/$WS_A/pending-uploads/$FID1/content")
+check "alpha response carries text/plain Content-Type" "Content-Type: text/plain" "$HEAD1"
+check "alpha response carries Content-Disposition with filename" 'filename="alpha.txt"' "$HEAD1"
+
+# ---------- Phase 5: idempotent re-fetch (until ack) ----------
+echo ""
+echo "--- Phase 5: Re-fetch before ack returns the same bytes ---"
+
+RE_GOT1=$(curl -s --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
+ "$BASE/workspaces/$WS_A/pending-uploads/$FID1/content")
+check_eq "re-fetch returns same alpha bytes" "$EXPECTED1" "$RE_GOT1"
+
+# ---------- Phase 6: ack each row ----------
+echo ""
+echo "--- Phase 6: Ack each pending upload ---"
+
+ACK1=$(curl -s -X POST --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
+ "$BASE/workspaces/$WS_A/pending-uploads/$FID1/ack")
+check "alpha ack returns acked:true" '"acked":true' "$ACK1"
+
+ACK2=$(curl -s -X POST --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
+ "$BASE/workspaces/$WS_A/pending-uploads/$FID2/ack")
+check "beta ack returns acked:true" '"acked":true' "$ACK2"
+
+# Re-ack should still 200 (idempotent — the row's gone but the workspace's
+# at-least-once intent was already honored, and the second ack hits the
+# raced path which also returns 200).
+RE_ACK1=$(curl -s -w '\n%{http_code}' -X POST --max-time "$TIMEOUT" \
+ -H "Authorization: Bearer $TOK_A" \
+ "$BASE/workspaces/$WS_A/pending-uploads/$FID1/ack")
+RE_ACK1_CODE=$(printf '%s' "$RE_ACK1" | tail -n1)
+# Acked rows return 404 on Get-before-Ack (the row's still in the table
+# but Get filters acked_at IS NULL); workspace would not normally re-ack
+# since it already saw the success. Accept both 200 and 404 here so the
+# test pins the contract without being brittle on the inner ordering.
+case "$RE_ACK1_CODE" in
+ 200|404)
+ echo "PASS: re-ack returns 200 or 404 ($RE_ACK1_CODE)"
+ PASS=$((PASS + 1))
+ ;;
+ *)
+ echo "FAIL: re-ack returned unexpected $RE_ACK1_CODE"
+ FAIL=$((FAIL + 1))
+ ;;
+esac
+
+# ---------- Phase 7: GET content after ack returns 404 ----------
+echo ""
+echo "--- Phase 7: Acked file 404s on subsequent fetch ---"
+
+POST_ACK=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
+ "$BASE/workspaces/$WS_A/pending-uploads/$FID1/content")
+POST_ACK_CODE=$(printf '%s' "$POST_ACK" | tail -n1)
+check_eq "acked alpha returns HTTP 404" "404" "$POST_ACK_CODE"
+
+# ---------- Phase 8: cross-workspace bleed protection ----------
+echo ""
+echo "--- Phase 8: Workspace B cannot read workspace A's pending uploads ---"
+
+# Stage a fresh upload on workspace A so we have an UN-acked row to probe.
+PROBE_FILE="$TMPDIR_E2E/probe.txt"
+printf '%s' "probe-bytes-$(openssl rand -hex 4)" > "$PROBE_FILE"
+PROBE_UP=$(curl -s -X POST "$BASE/workspaces/$WS_A/chat/uploads" \
+ -H "Authorization: Bearer $TOK_A" \
+ -F "files=@$PROBE_FILE;filename=probe.txt;type=text/plain")
+PROBE_FID=$(echo "$PROBE_UP" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["files"][0]["uri"].split("/")[-1])')
+[ -n "$PROBE_FID" ] || { echo "FAIL: probe upload returned no file_id"; FAIL=$((FAIL + 1)); exit 1; }
+
+# Register a SECOND poll-mode workspace and capture its bearer.
+REG_B=$(curl -s -X POST "$BASE/registry/register" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"id\": \"$WS_B\",
+ \"delivery_mode\": \"poll\",
+ \"agent_card\": {\"name\": \"poll-chat-upload-test-b\"}
+ }")
+check "second workspace registers" '"status":"registered"' "$REG_B"
+TOK_B=$(echo "$REG_B" | e2e_extract_token || true)
+[ -n "$TOK_B" ] || { echo "FAIL: no auth_token (ws B)"; FAIL=$((FAIL + 1)); exit 1; }
+
+# B's bearer hitting B's URL with A's file_id → 404 (handler checks the row's
+# workspace_id matches the URL :id, not the bearer's workspace).
+CROSS_RESP=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" \
+ -H "Authorization: Bearer $TOK_B" \
+ "$BASE/workspaces/$WS_B/pending-uploads/$PROBE_FID/content")
+CROSS_CODE=$(printf '%s' "$CROSS_RESP" | tail -n1)
+check_eq "B's URL with A's file_id returns 404" "404" "$CROSS_CODE"
+
+# B's bearer hitting A's URL → 401 (wsAuth pins bearer to :id). This is the
+# strictest cross-workspace check: a presented-but-wrong bearer is rejected
+# in EVERY platform posture (dev-mode fail-open only triggers when no bearer
+# is presented at all — invalid tokens always 401).
+WRONG_BEARER=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" \
+ -H "Authorization: Bearer $TOK_B" \
+ "$BASE/workspaces/$WS_A/pending-uploads/$PROBE_FID/content")
+WRONG_CODE=$(printf '%s' "$WRONG_BEARER" | tail -n1)
+check_eq "B's bearer on A's URL returns 401" "401" "$WRONG_CODE"
+
+# NB: a fully bearerless request to /pending-uploads/:fid/content returns
+# 401 ONLY when the platform has MOLECULE_ENV != development (production /
+# staging). On local-dev with MOLECULE_ENV=development the wsauth middleware
+# fail-opens for bearerless requests so the canvas at :3000 can talk to the
+# platform at :8080 without per-call token plumbing — see middleware/
+# devmode.go. The strict bearerless-401 contract is covered by the wsauth
+# unit + middleware tests; we don't reassert it here because the result
+# depends on platform posture, not the poll-mode upload contract.
+
+# ---------- Phase 9: invalid file_id rejected at the URL parser ----------
+echo ""
+echo "--- Phase 9: Invalid file_id returns 400 ---"
+
+BAD_FID=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" \
+ -H "Authorization: Bearer $TOK_A" \
+ "$BASE/workspaces/$WS_A/pending-uploads/not-a-uuid/content")
+BAD_FID_CODE=$(printf '%s' "$BAD_FID" | tail -n1)
+check_eq "invalid file_id UUID returns 400" "400" "$BAD_FID_CODE"
+
+# ---------- Results ----------
+echo ""
+echo "=== Results: $PASS passed, $FAIL failed ==="
+[ "$FAIL" -eq 0 ]
diff --git a/workspace-server/internal/handlers/class1_ast_gate_test.go b/workspace-server/internal/handlers/class1_ast_gate_test.go
new file mode 100644
index 00000000..bb362364
--- /dev/null
+++ b/workspace-server/internal/handlers/class1_ast_gate_test.go
@@ -0,0 +1,468 @@
+package handlers
+
+// class1_ast_gate_test.go — generic Class 1 leak gate per #2867 PR-A.
+//
+// What this gate prevents:
+// The tenant-hongming leak class — a handler iterates a YAML-derived
+// slice (ws.Children, sub_workspaces, etc.) and calls
+// `INSERT INTO workspaces` inside the loop body without first
+// checking whether a workspace with the same (parent_id, name) is
+// already there. Each call to such a handler doubles the tree.
+//
+// Why this is broader than TestCreateWorkspaceTree_CallsLookupBeforeInsert:
+// The existing gate is hard-coded to org_import.go's createWorkspaceTree.
+// That catches the specific function that triggered the original
+// incident — but a future handler written from scratch in a different
+// file would not be covered. This gate walks every production handler
+// .go file and applies a structural rule that does not depend on
+// function or file names.
+//
+// The rule (verbatim from #2867 PR-A):
+//
+// "No handler in handlers/ may iterate a slice (any RangeStmt) AND
+// call INSERT INTO workspaces inside the loop body without a
+// preceding SELECT id FROM workspaces WHERE name=$1 AND parent_id IS
+// NOT DISTINCT FROM $2 in the same function (== a lookupExistingChild
+// call, OR an ON CONFLICT clause baked into the same INSERT, OR an
+// explicit allowlist annotation)."
+//
+// Allowlist mechanism: a function whose body contains the exact comment
+// string `// class1-gate: idempotent-by-design` is treated as safe.
+// Use this only after writing a unit test that pins WHY the function
+// is safe. The annotation is intentionally awkward to type — it should
+// be rare.
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// reINSERTWorkspaces matches the exact statement shape we care about.
+// Tightened (vs bytes.Index "INSERT INTO workspaces") so the audit
+// table `workspaces_audit` literal — or any other lookalike — does not
+// false-positive trigger this gate. The same regex is used in the
+// existing createWorkspaceTree gate (workspaces_insert_allowlist_test.go)
+// — keep them in sync if either changes.
+var reINSERTWorkspaces = regexp.MustCompile(`(?m)^\s*INSERT INTO workspaces\s*\(`)
+
+// reONCONFLICT matches ON CONFLICT clauses anywhere in the same SQL
+// literal. An UPSERT (INSERT ... ON CONFLICT ... DO UPDATE) is
+// idempotent by definition, so the gate exempts it.
+var reONCONFLICT = regexp.MustCompile(`(?i)\bON CONFLICT\b`)
+
+// gateAllowlistComment is the magic comment a function author writes
+// to opt out of this gate. Forces an explicit decision.
+const gateAllowlistComment = "// class1-gate: idempotent-by-design"
+
+// preflightCallNames are function names whose presence in a function
+// body counts as "did a SELECT-by-(parent_id, name) preflight". Add
+// new names here as new preflight helpers are introduced. Keep the
+// list TIGHT — any sloppy addition weakens the gate.
+var preflightCallNames = map[string]bool{
+ "lookupExistingChild": true,
+}
+
+// TestClass1_NoUnpreflightedInsertInsideRange walks every production
+// .go file in this package, parses the AST, and fails the test if any
+// FuncDecl violates the rule above.
+//
+// Failure message must include: file path, function name, line of
+// the offending INSERT, line of the enclosing range, and a hint at
+// the three escape hatches (preflight call, ON CONFLICT, allowlist
+// comment).
+func TestClass1_NoUnpreflightedInsertInsideRange(t *testing.T) {
+ wd, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+
+ entries, err := os.ReadDir(wd)
+ if err != nil {
+ t.Fatalf("readdir %s: %v", wd, err)
+ }
+
+ type violation struct {
+ file string
+ fn string
+ insertLine int
+ rangeLine int
+ }
+ var violations []violation
+ scanned := 0
+
+ for _, e := range entries {
+ name := e.Name()
+ if e.IsDir() || !strings.HasSuffix(name, ".go") {
+ continue
+ }
+ if strings.HasSuffix(name, "_test.go") {
+ continue
+ }
+ path := filepath.Join(wd, name)
+ src, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read %s: %v", path, err)
+ }
+ fset := token.NewFileSet()
+ file, err := parser.ParseFile(fset, name, src, parser.ParseComments)
+ if err != nil {
+ t.Fatalf("parse %s: %v", path, err)
+ }
+ scanned++
+
+ // Walk every function declaration and apply the rule.
+ for _, decl := range file.Decls {
+ fd, ok := decl.(*ast.FuncDecl)
+ if !ok || fd.Body == nil {
+ continue
+ }
+
+ // Allowlist: skip if the function body contains the magic
+ // comment. We check via the source range of the function
+ // — comments inside the body are in file.Comments and
+ // must overlap the function's Pos/End range.
+ if functionHasAllowlistComment(file, fd) {
+ continue
+ }
+
+ // First pass: locate every INSERT INTO workspaces literal
+ // in this function. We treat each such literal as a
+ // candidate violation and try to clear it via the rules.
+ candidates := findInsertWorkspacesLiterals(fd, src, fset)
+ if len(candidates) == 0 {
+ continue
+ }
+
+ // Has the function called a preflight helper? Single
+ // pass — if any preflight name appears, every INSERT in
+ // the function is considered preflighted. This is more
+ // permissive than position-aware (preflight could be
+ // AFTER the INSERT and still satisfy the gate), but the
+ // existing org_import.go gate already pins the position
+ // invariant for createWorkspaceTree, and a function that
+ // preflights AFTER inserting would fail the position
+ // gate in a separate test.
+ hasPreflight := functionCallsAny(fd, preflightCallNames)
+
+ for _, c := range candidates {
+ if c.hasONCONFLICT {
+ continue
+ }
+ if hasPreflight {
+ continue
+ }
+ if c.enclosingRangeLine == 0 {
+ // INSERT not inside any RangeStmt — single-shot,
+ // not the bug pattern.
+ continue
+ }
+ violations = append(violations, violation{
+ file: name,
+ fn: fd.Name.Name,
+ insertLine: c.insertLine,
+ rangeLine: c.enclosingRangeLine,
+ })
+ }
+ }
+ }
+
+ if scanned == 0 {
+ t.Fatal("scanned 0 .go files — wrong working directory? gate would always pass")
+ }
+
+ if len(violations) > 0 {
+ // Stable sort so the failure message is deterministic across
+ // reruns.
+ sort.Slice(violations, func(i, j int) bool {
+ if violations[i].file != violations[j].file {
+ return violations[i].file < violations[j].file
+ }
+ return violations[i].insertLine < violations[j].insertLine
+ })
+ var b strings.Builder
+ b.WriteString("Class 1 leak gate (#2867 PR-A) — these handler functions iterate a slice and INSERT INTO workspaces inside the loop body without a (parent_id, name) preflight.\n\n")
+ b.WriteString("This is the bug shape that triggered the tenant-hongming leak (TeamHandler.Expand re-inserting the entire sub_workspaces tree on every call). To fix any reported violation, choose ONE of:\n")
+ b.WriteString(" 1. Call h.lookupExistingChild(ctx, name, parentID) before the INSERT and skip the INSERT when it returns existing=true. (preferred)\n")
+ b.WriteString(" 2. Use INSERT ... ON CONFLICT ... DO ... (idempotent UPSERT, like registry.go).\n")
+ b.WriteString(" 3. Annotate the function with a `// class1-gate: idempotent-by-design` comment AND a unit test that pins why the function is structurally idempotent. (rare; require code review)\n\n")
+ b.WriteString("Violations:\n")
+ for _, v := range violations {
+ b.WriteString(" - ")
+ b.WriteString(v.file)
+ b.WriteString(":")
+ b.WriteString(itoa(v.insertLine))
+ b.WriteString(" — function ")
+ b.WriteString(v.fn)
+ b.WriteString("() INSERTs inside RangeStmt at line ")
+ b.WriteString(itoa(v.rangeLine))
+ b.WriteString("\n")
+ }
+ t.Fatal(b.String())
+ }
+}
+
+func itoa(n int) string {
+ // Avoid strconv import for one call site — keeps the test focused.
+ if n == 0 {
+ return "0"
+ }
+ neg := n < 0
+ if neg {
+ n = -n
+ }
+ var buf [20]byte
+ i := len(buf)
+ for n > 0 {
+ i--
+ buf[i] = byte('0' + n%10)
+ n /= 10
+ }
+ if neg {
+ i--
+ buf[i] = '-'
+ }
+ return string(buf[i:])
+}
+
+// candidateInsert holds the per-INSERT facts needed to decide whether
+// the gate fires.
+type candidateInsert struct {
+ insertLine int
+ hasONCONFLICT bool
+ enclosingRangeLine int // 0 means not inside any range
+}
+
+// findInsertWorkspacesLiterals walks fd's body and returns one
+// candidateInsert per INSERT INTO workspaces string literal.
+//
+// Position-based detection: collect every RangeStmt's body span first,
+// then for each INSERT literal check if its position is inside any
+// span. ast.Inspect's nil-call ordering does NOT give per-node pop
+// semantics, so a stack-based approach against ast.Inspect would
+// silently miscount. Position spans are deterministic and easy to
+// reason about.
+func findInsertWorkspacesLiterals(fd *ast.FuncDecl, src []byte, fset *token.FileSet) []candidateInsert {
+ var out []candidateInsert
+
+ type span struct{ start, end token.Pos }
+ var ranges []span
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ rs, ok := n.(*ast.RangeStmt)
+ if !ok || rs.Body == nil {
+ return true
+ }
+ ranges = append(ranges, span{rs.Body.Lbrace, rs.Body.Rbrace})
+ return true
+ })
+
+ enclosingRangeLineFor := func(p token.Pos) int {
+ // Pick the innermost enclosing range — i.e., the one with the
+ // largest start that still covers p. Innermost is the one
+ // whose body actually contains the INSERT, which is the line
+ // most useful in a violation message.
+ bestStart := token.NoPos
+ bestLine := 0
+ for _, s := range ranges {
+ if p > s.start && p < s.end && s.start > bestStart {
+ bestStart = s.start
+ bestLine = fset.Position(s.start).Line
+ }
+ }
+ return bestLine
+ }
+
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ bl, ok := n.(*ast.BasicLit)
+ if !ok || bl.Kind != token.STRING {
+ return true
+ }
+ // Strip surrounding backticks/quotes — value includes them.
+ lit := bl.Value
+ if len(lit) >= 2 {
+ lit = lit[1 : len(lit)-1]
+ }
+ if !reINSERTWorkspaces.MatchString(lit) {
+ return true
+ }
+ out = append(out, candidateInsert{
+ insertLine: fset.Position(bl.Pos()).Line,
+ hasONCONFLICT: reONCONFLICT.MatchString(lit),
+ enclosingRangeLine: enclosingRangeLineFor(bl.Pos()),
+ })
+ return true
+ })
+ return out
+}
+
+// functionCallsAny returns true if any CallExpr in fd's body has a
+// function name (either a SelectorExpr Sel.Name or an Ident name)
+// matching a key in names.
+func functionCallsAny(fd *ast.FuncDecl, names map[string]bool) bool {
+ found := false
+ ast.Inspect(fd.Body, func(n ast.Node) bool {
+ if found {
+ return false
+ }
+ ce, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ switch fun := ce.Fun.(type) {
+ case *ast.Ident:
+ if names[fun.Name] {
+ found = true
+ return false
+ }
+ case *ast.SelectorExpr:
+ if names[fun.Sel.Name] {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// functionHasAllowlistComment returns true if the function body
+// (between fd.Body.Lbrace and fd.Body.Rbrace) contains a comment
+// equal to gateAllowlistComment.
+func functionHasAllowlistComment(file *ast.File, fd *ast.FuncDecl) bool {
+ if fd.Body == nil {
+ return false
+ }
+ start := fd.Body.Lbrace
+ end := fd.Body.Rbrace
+ for _, cg := range file.Comments {
+ for _, c := range cg.List {
+ if c.Pos() < start || c.Pos() > end {
+ continue
+ }
+ if strings.TrimSpace(c.Text) == gateAllowlistComment {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// TestClass1_GateFiresOnSyntheticBuggySource — proves the gate actually
+// catches the bug shape it's named after. Without this, a regression
+// to "always pass" would not be noticed until the leak shipped again.
+// Per memory feedback_assert_exact_not_substring.md: tighten the test
+// + verify it FAILS on old-shape source before merging.
+func TestClass1_GateFiresOnSyntheticBuggySource(t *testing.T) {
+ const buggySrc = `package handlers
+
+import "context"
+
+type fakeDB struct{}
+func (fakeDB) ExecContext(ctx context.Context, sql string, args ...interface{}) {}
+
+func buggyExpand(db fakeDB, ctx context.Context, children []string) {
+ for _, child := range children {
+ // Bug shape: INSERT inside the range body, no preflight.
+ db.ExecContext(ctx, ` + "`INSERT INTO workspaces (id, name) VALUES ($1, $2)`" + `, "x", child)
+ }
+}
+`
+ fset := token.NewFileSet()
+ file, err := parser.ParseFile(fset, "buggy.go", buggySrc, parser.ParseComments)
+ if err != nil {
+ t.Fatalf("parse synthetic source: %v", err)
+ }
+ for _, decl := range file.Decls {
+ fd, ok := decl.(*ast.FuncDecl)
+ if !ok || fd.Name.Name != "buggyExpand" {
+ continue
+ }
+ candidates := findInsertWorkspacesLiterals(fd, []byte(buggySrc), fset)
+ if len(candidates) != 1 {
+ t.Fatalf("expected 1 INSERT literal, got %d", len(candidates))
+ }
+ c := candidates[0]
+ if c.enclosingRangeLine == 0 {
+ t.Errorf("synthetic INSERT inside `for _, child := range` should be detected as enclosed by range, got enclosingRangeLine=0 — gate would miss the bug shape")
+ }
+ if c.hasONCONFLICT {
+ t.Errorf("synthetic INSERT has no ON CONFLICT, gate falsely treated it as idempotent")
+ }
+ if functionCallsAny(fd, preflightCallNames) {
+ t.Errorf("synthetic function does not call lookupExistingChild — gate falsely treated it as preflighted")
+ }
+ // All three guards say the gate WOULD fire. Pass.
+ return
+ }
+ t.Fatal("buggyExpand FuncDecl not found in synthetic source")
+}
+
+// TestClass1_GateAllowsONCONFLICT — pins that an INSERT with ON
+// CONFLICT inside a range body is NOT flagged. registry.go's
+// upsert pattern is the prod example.
+func TestClass1_GateAllowsONCONFLICT(t *testing.T) {
+ const safeSrc = `package handlers
+
+import "context"
+
+type fakeDB struct{}
+func (fakeDB) ExecContext(ctx context.Context, sql string, args ...interface{}) {}
+
+func upsertLoop(db fakeDB, ctx context.Context, children []string) {
+ for _, child := range children {
+ db.ExecContext(ctx, ` + "`INSERT INTO workspaces (id, name) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET name = $2`" + `, "x", child)
+ }
+}
+`
+ fset := token.NewFileSet()
+ file, _ := parser.ParseFile(fset, "safe.go", safeSrc, parser.ParseComments)
+ for _, decl := range file.Decls {
+ fd, ok := decl.(*ast.FuncDecl)
+ if !ok || fd.Name.Name != "upsertLoop" {
+ continue
+ }
+ candidates := findInsertWorkspacesLiterals(fd, []byte(safeSrc), fset)
+ if len(candidates) != 1 {
+ t.Fatalf("expected 1 candidate, got %d", len(candidates))
+ }
+ if !candidates[0].hasONCONFLICT {
+ t.Errorf("ON CONFLICT clause should be detected, was missed — gate would falsely flag idempotent UPSERTs")
+ }
+ }
+}
+
+// TestClass1_GateAllowsAllowlistAnnotation — pins the escape hatch
+// works. Annotated functions are skipped at the FuncDecl level.
+func TestClass1_GateAllowsAllowlistAnnotation(t *testing.T) {
+ const annotatedSrc = `package handlers
+
+import "context"
+
+type fakeDB struct{}
+func (fakeDB) ExecContext(ctx context.Context, sql string, args ...interface{}) {}
+
+func intentionallyUnpreflighted(db fakeDB, ctx context.Context, children []string) {
+ // class1-gate: idempotent-by-design
+ for _, child := range children {
+ db.ExecContext(ctx, ` + "`INSERT INTO workspaces (id, name) VALUES ($1, $2)`" + `, "x", child)
+ }
+}
+`
+ fset := token.NewFileSet()
+ file, _ := parser.ParseFile(fset, "annotated.go", annotatedSrc, parser.ParseComments)
+ for _, decl := range file.Decls {
+ fd, ok := decl.(*ast.FuncDecl)
+ if !ok || fd.Name.Name != "intentionallyUnpreflighted" {
+ continue
+ }
+ if !functionHasAllowlistComment(file, fd) {
+ t.Error("allowlist comment should be detected for the intentionallyUnpreflighted function — escape hatch not working")
+ }
+ }
+}