Add personal Muse operator setup and composer controls (#16450)

Add Muse invitation, connection readiness and scoped operator controls.

Keep connection state tied to the current company, person, agent and binding. Surface failed reads and invalidate stale cleanup observations; require current authority and exact readback. Preserve Dot/Grok setup, source-trust limits and existing design tokens.

Carry actual merged backend e6dcc6b095 through a normal forward merge. Preserve its project-write authority fix, existing migration bytes and upstream idle-serving behavior; no new UI behavior is authored by this source carry. Current-head CI and substantive review remain merge gates; final canonical and deployed-provider qualification remain separate.

Refs #16450.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-17 23:01:42 -05:00
1 parent 7c7d2d8d2c
commit 896f3c7f3b
45 files changed
+2470 -116

No files matched your search

+20
View File
@@ -5,6 +5,7 @@ import {
} from "@/lib/tenant-session-recovery";
import { api, detachInflightGet } from "./client";
import { toolsApi } from "./tools";
import { museInvitationsApi } from "./museInvitations";
interface Deferred<T> {
promise: Promise<T>;
@@ -237,3 +238,22 @@ describe("managed-account request isolation", () => {
expect(await previousUser).toEqual({ apps: ["previous-user-account"] });
});
});
describe("Muse operator request isolation", () => {
it.each([
["invitation resume", () => museInvitationsApi.resume("company")],
["connection evidence", () => museInvitationsApi.connection("company", "agent")],
] as const)("does not join the previous operator's %s request", async (_name, read) => {
const previous = deferred<Response>();
const current = deferred<Response>();
fetchMock.mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise);
const previousOperator = read();
const currentOperator = read();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls.every(([, options]) => options.cache === "no-store")).toBe(true);
current.resolve(jsonResponse({ operator: "current" }));
previous.resolve(jsonResponse({ operator: "previous" }));
expect(await currentOperator).toEqual({ operator: "current" });
expect(await previousOperator).toEqual({ operator: "previous" });
});
});
+23
View File
@@ -0,0 +1,23 @@
import { api, detachInflightGet } from "./client";
import type {
MuseInvitation, MuseInvitationInput, MuseConnection, MusePairing, MusePairingInput,
MuseVerifyInput, MuseVerifyResult, MuseRevokeInput, MuseDetectorCleanupAttestationInput, MuseAttestStopInput,
} from "@paperclipai/shared";
const path = (companyId: string, agentId: string) => `/companies/${companyId}/agents/${agentId}/muse-binding`;
function accountScopedRead<T>(path: string, signal?: AbortSignal) {
// Query keys carry the operator. Path-only GET coalescing must not join a
// request issued under the previous account; React Query handles same-key reads.
detachInflightGet(path);
return api.get<T>(path, { signal, cache: "no-store" });
}
export const museInvitationsApi = {
resume: (companyId: string, signal?: AbortSignal) => accountScopedRead<MuseInvitation | null>(`/companies/${companyId}/muse-invitations`, signal),
create: (companyId: string, input: MuseInvitationInput) => api.post<MuseInvitation>(`/companies/${companyId}/muse-invitations`, input),
connection: (companyId: string, agentId: string, signal?: AbortSignal) => accountScopedRead<MuseConnection>(path(companyId, agentId), signal),
pair: (companyId: string, agentId: string, input: MusePairingInput) => api.post<MusePairing>(path(companyId, agentId), input),
verify: (companyId: string, agentId: string, input: MuseVerifyInput) => api.post<MuseVerifyResult>(path(companyId, agentId) + "/verify", input),
revoke: (companyId: string, agentId: string, input: MuseRevokeInput) => api.post<void>(path(companyId, agentId) + "/revoke", input),
attestDetectorCleanup: (companyId: string, agentId: string, input: MuseDetectorCleanupAttestationInput) => api.post<void>(path(companyId, agentId) + "/attest-detector-cleanup", input),
attestStop: (companyId: string, agentId: string, input: MuseAttestStopInput) => api.post<void>(path(companyId, agentId) + "/attest-stop", input),
};