feat(media): disclose media servers in AI data sharing

Media tools and card actions send prompts, images, video and audio to the
deployment owner's ComfyUI and transcription servers. Like the embeddings
server behind built-in memory, those self-hosted endpoints are recipients in
the AI data-sharing disclosure: they appear with the model uses ("ComfyUI
(<origin>)", "Transcription (<origin>)", keyed by the full endpoint so a new
server asks again), and starting a card job asks for model consent on clients
that run the foreground check.

Also adds unit tests for the media settings store: URL normalization and
refusals, encryption, the key-free view, one decryption per saved version, key
retention per server and removing empty settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:51:13 +00:00
parent 2796965256
commit 533f82d856
No known key found for this signature in database
8 changed files with 207 additions and 0 deletions

View File

@ -29,6 +29,7 @@ function setup() {
},
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
deploymentSettings: { findUnique: vi.fn(async () => null) },
integrationProviderConfig: { findUnique: vi.fn(async () => null) },
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
$transaction: vi.fn(async (queries) => Promise.all(queries)),
},
@ -134,6 +135,28 @@ describe("consent grants", () => {
expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
});
it("discloses the deployment's media servers before model use, keyed by server", async () => {
const { deps, actor } = setup();
const settings = {
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key", imageModel: "sdxl" },
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
};
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
id: "media",
ciphertext: "fake-media-ciphertext",
} as never);
deps.secrets = {
load: vi.fn(() => JSON.stringify(settings)),
} as unknown as RouterDeps["secrets"];
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
expect(status.recipients.map((recipient) => recipient.name)).toEqual([
"ComfyUI (http://127.0.0.1:8188)",
"Transcription (http://127.0.0.1:8080)",
]);
expect(status.recipients[0]?.detail).toBe("Model: sdxl.");
expect(JSON.stringify(status)).not.toContain("comfy-key");
expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1);
});
it("returns an operator policy without requiring a hosted provider", async () => {
const { deps, actor } = setup();
deps.env.privacyPolicyUrl = "https://example.com/privacy";

View File

@ -3,6 +3,9 @@ import { ORPCError } from "@orpc/server";
import {
aiRecipient,
cloudAgentsEnabled,
loadMediaSettings,
MEDIA_COMFYUI_PROVIDER_ID,
MEDIA_TRANSCRIPTION_PROVIDER_ID,
parseModelSecret,
selectConfiguredModel,
toStringRecord,
@ -210,6 +213,30 @@ export async function aiConsentStatus(
) {
add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" }));
}
if (uses.includes("model")) {
// Media tools and card actions send prompts, images, video and audio to the owner's servers.
const media = await loadMediaSettings(deps);
if (media.comfyui) {
add(
aiRecipient({
provider: MEDIA_COMFYUI_PROVIDER_ID,
use: "model",
baseUrl: media.comfyui.baseUrl,
modelId: media.comfyui.imageModel,
}),
);
}
if (media.transcription) {
add(
aiRecipient({
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
use: "model",
baseUrl: media.transcription.baseUrl,
modelId: media.transcription.model,
}),
);
}
}
return {
scope: createHash("sha256")
.update(JSON.stringify([actor.userId, actor.spaceId]))

View File

@ -41,6 +41,18 @@ describe("mobile AI recipient disclosure", () => {
expect(recipient.name).toBe("Embeddings (http://192.168.1.20:8080)");
expect(recipient.privacyUrl).toBeUndefined();
});
it("names the media servers that media jobs send files to", () => {
expect(
aiRecipient({ provider: "comfyui", use: "model", baseUrl: "http://127.0.0.1:8188" })?.name,
).toBe("ComfyUI (http://127.0.0.1:8188)");
expect(
aiRecipient({
provider: "media-transcription",
use: "model",
baseUrl: "http://127.0.0.1:8080/v1",
})?.name,
).toBe("Transcription (http://127.0.0.1:8080)");
});
it("supports local models without a hosted dependency", () => {
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);

View File

@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import type { AiDataUse, AiRecipient } from "@rakazo/contracts";
import { BUILTIN_MEMORY_PROVIDER_ID } from "./builtin-memory-provider.js";
import { MEDIA_COMFYUI_PROVIDER_ID, MEDIA_TRANSCRIPTION_PROVIDER_ID } from "./media-settings.js";
import { localBaseUrl } from "./pi-local-provider.js";
import { listPiCatalog } from "./pi-models.js";
import { voiceCatalogEntry } from "./voice-factory.js";
@ -59,5 +60,7 @@ function recipientName(provider: string, use: AiDataUse) {
return provider;
}
if (provider === "cursor") return "Cursor";
if (provider === MEDIA_COMFYUI_PROVIDER_ID) return "ComfyUI";
if (provider === MEDIA_TRANSCRIPTION_PROVIDER_ID) return "Transcription";
return listPiCatalog().find((entry) => entry.provider === provider)?.providerName ?? provider;
}

View File

@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import {
loadMediaSettings,
MEDIA_SETTINGS_ID,
MediaSettingsInvalidError,
mediaSettingsView,
normalizeComfyUiBaseUrl,
normalizeTranscriptionBaseUrl,
saveMediaSettings,
} from "./media-settings.js";
import { EncryptedSecretStore } from "./secrets.js";
function store() {
const rows = new Map<string, { id: string; ciphertext: string }>();
const prisma = {
integrationProviderConfig: {
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => rows.get(where.id) ?? null),
upsert: vi.fn(
async ({ where, create }: { where: { id: string }; create: { ciphertext: string } }) => {
const row = { id: where.id, ciphertext: create.ciphertext };
rows.set(where.id, row);
return row;
},
),
deleteMany: vi.fn(async ({ where }: { where: { id: string } }) => {
rows.delete(where.id);
return { count: 1 };
}),
},
};
const secrets = new EncryptedSecretStore("test-encryption-key-for-media-settings");
return { deps: { prisma: prisma as never, secrets }, rows, prisma, secrets };
}
describe("media settings", () => {
it("normalizes server URLs and refuses ones that are not allowed", () => {
expect(normalizeComfyUiBaseUrl(" http://127.0.0.1:8188/ ")).toBe("http://127.0.0.1:8188");
expect(normalizeComfyUiBaseUrl("http://192.168.1.20:8188/comfy/?x=1#y")).toBe(
"http://192.168.1.20:8188/comfy",
);
expect(normalizeTranscriptionBaseUrl("http://127.0.0.1:8080")).toBe("http://127.0.0.1:8080/v1");
expect(normalizeTranscriptionBaseUrl("http://127.0.0.1:8080/v1/")).toBe(
"http://127.0.0.1:8080/v1",
);
for (const url of ["ftp://127.0.0.1", "http://169.254.169.254", "not a url"]) {
expect(() => normalizeComfyUiBaseUrl(url), url).toThrow();
}
expect(() => normalizeComfyUiBaseUrl("http://user:pass@127.0.0.1:8188")).toThrow("credentials");
});
it("stores the settings encrypted and returns a view without keys", async () => {
const { deps, rows } = store();
const view = await saveMediaSettings(deps, {
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key" },
transcription: null,
});
expect(view).toEqual({
comfyui: { baseUrl: "http://127.0.0.1:8188", hasApiKey: true },
transcription: null,
});
expect(rows.get(MEDIA_SETTINGS_ID)?.ciphertext).not.toContain("comfy-key");
expect(await loadMediaSettings(deps)).toEqual({
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key" },
transcription: null,
});
expect(
mediaSettingsView({
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:8080/v1", apiKey: "k", model: "m" },
}),
).toEqual({
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "m", hasApiKey: true },
});
});
it("decrypts once per saved version", async () => {
const { deps, secrets } = store();
await saveMediaSettings(deps, {
comfyui: { baseUrl: "http://127.0.0.1:8188" },
transcription: null,
});
const load = vi.spyOn(secrets, "load");
await loadMediaSettings(deps);
await loadMediaSettings(deps);
expect(load).not.toHaveBeenCalled();
});
it("keeps a saved key only for the same server, removes it when cleared", async () => {
const { deps } = store();
await saveMediaSettings(deps, {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:8080", apiKey: "t-key" },
});
await saveMediaSettings(deps, {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
});
expect((await loadMediaSettings(deps)).transcription?.apiKey).toBe("t-key");
await saveMediaSettings(deps, {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:9090" },
});
expect((await loadMediaSettings(deps)).transcription?.apiKey).toBeUndefined();
await saveMediaSettings(deps, {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:9090", apiKey: "new" },
});
await saveMediaSettings(deps, {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:9090", apiKey: "" },
});
expect((await loadMediaSettings(deps)).transcription?.apiKey).toBeUndefined();
});
it("refuses invalid settings with a message for the owner and removes empty ones", async () => {
const { deps, rows } = store();
await expect(
saveMediaSettings(deps, {
comfyui: { baseUrl: "http://169.254.169.254" },
transcription: null,
}),
).rejects.toBeInstanceOf(MediaSettingsInvalidError);
expect(rows.size).toBe(0);
await saveMediaSettings(deps, {
comfyui: { baseUrl: "http://127.0.0.1:8188" },
transcription: null,
});
expect(await saveMediaSettings(deps, { comfyui: null, transcription: null })).toEqual({
comfyui: null,
transcription: null,
});
expect(rows.size).toBe(0);
expect(await loadMediaSettings(deps)).toEqual({ comfyui: null, transcription: null });
});
});

View File

@ -13,6 +13,10 @@ import {
} from "./openai-compatible-url.js";
import type { EncryptedSecretStore } from "./secrets.js";
/** Recipient ids for AI data-sharing disclosures of the media servers. */
export const MEDIA_COMFYUI_PROVIDER_ID = "comfyui";
export const MEDIA_TRANSCRIPTION_PROVIDER_ID = "media-transcription";
/** The IntegrationProviderConfig row that stores the media backends, encrypted as one record. */
export const MEDIA_SETTINGS_ID = "media";
const RECORD_ID = `integration-provider:${MEDIA_SETTINGS_ID}`;

View File

@ -86,6 +86,7 @@ describe("foreground AI consent", () => {
expect(aiDataUsesForProcedure("threads/get")).toEqual([]);
expect(aiDataUsesForProcedure("threads/send")).toEqual(["model", "memory"]);
expect(aiDataUsesForProcedure("voice/prepare")).toEqual(["voice"]);
expect(aiDataUsesForProcedure("media/jobs/start")).toEqual(["model"]);
expect(
aiDataUsesForProcedure("routines/update", { routineId: "routine", active: false }),
).toEqual([]);

View File

@ -32,6 +32,7 @@ export function aiDataUsesForProcedure(procedure: string, input?: unknown): AiDa
].includes(path)
)
return ["model", "memory"];
if (path === "media/jobs/start") return ["model"];
if (["voice/prepare", "voice/speak", "voice/transcribe"].includes(path)) return ["voice"];
return [];
}