Files
openwork/evals/specs/google-workspace-direct-uploads.e2e.test.ts

337 lines
15 KiB
TypeScript

import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect } from "vitest";
import { denFetch } from "@openwork/behaviors";
import { startMockGoogle } from "@openwork/labs";
import { localMysqlIsRunning, needs, server, test } from "@openwork/testkit";
import type { DenSession } from "@openwork/behaviors";
import type { MockGoogleHandle } from "@openwork/labs";
/**
* CLAIMS:
* - The registered cloud-upload actions accept workspace paths, never inline
* bytes/base64, and the model-callable Gmail JSON capability has no
* attachments field.
* - A real binary workspace file crosses the local extension, authenticated
* Den multipart route, and mock Drive/Gmail APIs byte-for-byte intact.
* - Gmail returns only attachment metadata to the model-visible caller.
* - Aggregate input above 4 MiB is rejected in the local extension before its
* fetch dependency is called.
*
* This is a stack spec because it cold-boots a real Den. It deliberately does
* not drive Electron: the observable product boundary for this transport is
* the extension action call plus the provider witness, not UI presentation.
*/
const localPlacement = process.env.OPENWORK_EVAL_DAYTONA !== "1"
&& !process.env.OPENWORK_EVAL_DEN_API_URL?.trim();
const mysqlOpen = await localMysqlIsRunning();
const title = !localPlacement
? "google workspace direct uploads skipped — needs: local placement without OPENWORK_EVAL_DEN_API_URL"
: !mysqlOpen
? "google workspace direct uploads skipped — needs: MySQL on 127.0.0.1:3306"
: "workspace paths upload directly to Google without model-visible bytes";
const cloudUploadRunner = fileURLToPath(new URL("../packages/labs/bin/cloud-upload-runner.mjs", import.meta.url));
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${label} was not an object: ${JSON.stringify(value).slice(0, 500)}`);
return value;
}
function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || !value) throw new Error(`${label} was not a non-empty string.`);
return value;
}
function topLevelPropertyNames(schema: unknown): string[] {
const record = requireRecord(schema, "JSON schema");
const properties = requireRecord(record.properties, "JSON schema properties");
return Object.keys(properties).sort();
}
function resolveLocalSchema(document: unknown, schema: unknown): unknown {
let current = schema;
const visited = new Set<string>();
while (isRecord(current) && typeof current.$ref === "string" && current.$ref.startsWith("#/")) {
if (visited.has(current.$ref)) throw new Error(`OpenAPI schema reference loop: ${current.$ref}`);
visited.add(current.$ref);
let resolved: unknown = document;
for (const segment of current.$ref.slice(2).split("/")) {
const key = segment.replaceAll("~1", "/").replaceAll("~0", "~");
resolved = isRecord(resolved) ? resolved[key] : undefined;
}
current = resolved;
}
return current;
}
function openApiRequestSchema(document: unknown, path: string): unknown {
const root = requireRecord(document, "OpenAPI document");
const paths = requireRecord(root.paths, "OpenAPI paths");
const operation = requireRecord(requireRecord(paths[path], `${path} path`).post, `${path} POST operation`);
const requestBody = requireRecord(operation.requestBody, `${path} request body`);
const content = requireRecord(requestBody.content, `${path} request content`);
return requireRecord(content["application/json"], `${path} application/json content`).schema;
}
function payloadFieldNames(value: unknown): string[] {
if (Array.isArray(value)) return value.flatMap(payloadFieldNames);
if (!isRecord(value)) return [];
return [
...Object.keys(value),
...Object.values(value).flatMap(payloadFieldNames),
];
}
function googleEnvironment(google: MockGoogleHandle): Record<string, string> {
return {
DEN_GOOGLE_API_BASE_URL: google.apiUrl,
DEN_GOOGLE_OAUTH_AUTHORIZE_URL: google.authorizeUrl,
DEN_GOOGLE_OAUTH_TOKEN_URL: google.tokenUrl,
DEN_GOOGLE_OAUTH_USERINFO_URL: google.userinfoUrl,
};
}
function installEnvironment(values: Record<string, string>): () => void {
const previous = new Map<string, string | undefined>();
for (const [name, value] of Object.entries(values)) {
previous.set(name, process.env[name]);
process.env[name] = value;
}
return () => {
for (const [name, value] of previous) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
};
}
async function connectGoogle(session: DenSession, google: MockGoogleHandle, email: string): Promise<void> {
const configured = await denFetch(session, "/v1/oauth-providers/google-workspace/client", {
method: "POST",
headers: { authorization: `Bearer ${session.token}` },
body: JSON.stringify({
clientId: "direct-upload-spec-client",
clientSecret: "direct-upload-spec-secret",
features: ["gmailDraft", "driveFile"],
}),
});
if (!configured.response.ok) {
throw new Error(`Google client setup failed: HTTP ${configured.response.status} ${configured.text.slice(0, 500)}`);
}
const started = await denFetch(session, "/v1/oauth-providers/google-workspace/connect/start", {
headers: { authorization: `Bearer ${session.token}` },
});
const authorizeUrl = isRecord(started.body) ? requireString(started.body.authorizeUrl, "Google authorize URL") : "";
if (!started.response.ok) {
throw new Error(`Google connect start failed: HTTP ${started.response.status} ${started.text.slice(0, 500)}`);
}
const chooser = await fetch(authorizeUrl, { redirect: "manual", signal: AbortSignal.timeout(10_000) });
expect(chooser.status).toBe(200);
await google.chooseAccount(email, { timeoutMs: 10_000 });
const status = await denFetch(session, "/v1/oauth-providers/google-workspace/status", {
headers: { authorization: `Bearer ${session.token}` },
});
expect(status.response.status).toBe(200);
expect(isRecord(status.body) && status.body.connected === true).toBe(true);
expect(isRecord(status.body) ? status.body.externalAccountId : null).toBe(email);
}
async function mintWriteToken(session: DenSession): Promise<string> {
const result = await denFetch(session, "/v1/mcp/token", {
method: "POST",
headers: { authorization: `Bearer ${session.token}` },
body: JSON.stringify({ scopes: ["mcp:write"] }),
});
const token = isRecord(result.body) ? requireString(result.body.token, "MCP write token") : "";
if (!result.response.ok) throw new Error(`MCP token mint failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`);
return token;
}
function runCloudUpload(input: Record<string, unknown>): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const child = spawn("bun", [cloudUploadRunner], {
cwd: fileURLToPath(new URL("../..", import.meta.url)),
stdio: ["pipe", "pipe", "pipe"],
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("Cloud upload Bun runner timed out after 30 seconds."));
}, 30_000);
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
child.once("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.once("close", (code) => {
clearTimeout(timeout);
const output = Buffer.concat(stdout).toString("utf8");
if (code !== 0) {
reject(new Error(`Cloud upload Bun runner exited ${code}: ${Buffer.concat(stderr).toString("utf8").slice(0, 1_000)}`));
return;
}
try {
const parsed: unknown = JSON.parse(output);
resolve(requireRecord(parsed, "Cloud upload Bun runner result"));
} catch (error) {
reject(new Error(`Cloud upload Bun runner returned invalid JSON: ${output.slice(0, 1_000)}; ${String(error)}`));
}
});
child.stdin.end(JSON.stringify(input));
});
}
test.skipIf(!localPlacement || !mysqlOpen)(title, async ({ evidence, place }) => {
needs({});
const account = "uploader@openwork.test";
const root = await mkdtemp(join(tmpdir(), "openwork-direct-upload-spec-"));
const fixture = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff, 0x80, 0x13]);
await writeFile(join(root, "witness.png"), fixture);
await using google = await startMockGoogle({ accounts: [account], port: 0, autoApprove: false });
const restoreEnvironment = installEnvironment(googleEnvironment(google));
try {
await using den = await server({ place });
await connectGoogle(den.admin, google, account);
const mcpToken = await mintWriteToken(den.admin);
const inspection = await runCloudUpload({ mode: "inspect" });
const actions = Array.isArray(inspection.result) ? inspection.result : [];
const cloudActions = actions.filter((action): action is Record<string, unknown> => (
isRecord(action) && action.extensionId === "openwork-cloud-uploads"
));
expect(cloudActions.map((action) => action.action).sort()).toEqual([
"drive_upload_file",
"gmail_create_draft_with_attachments",
]);
const driveAction = cloudActions.find((action) => action.action === "drive_upload_file");
const gmailAction = cloudActions.find((action) => action.action === "gmail_create_draft_with_attachments");
const driveInputFields = topLevelPropertyNames(driveAction?.inputSchema);
const gmailInputFields = topLevelPropertyNames(gmailAction?.inputSchema);
const extensionByteFields = [...driveInputFields, ...gmailInputFields].filter((field) => /base64|bytes/i.test(field));
expect(driveInputFields).toEqual(["folderId", "path"]);
expect(gmailInputFields).toEqual(["bcc", "body", "cc", "paths", "subject", "threadId", "to"]);
expect(extensionByteFields).toEqual([]);
const openApiResponse = await fetch(`${den.ref.apiUrl}/openapi.json`, { signal: AbortSignal.timeout(10_000) });
expect(openApiResponse.status).toBe(200);
const openApiDocument: unknown = await openApiResponse.json();
const draftSchema = resolveLocalSchema(
openApiDocument,
openApiRequestSchema(openApiDocument, "/v1/capabilities/google-workspace/gmail-drafts"),
);
const jsonDraftFields = topLevelPropertyNames(draftSchema);
expect(jsonDraftFields).not.toContain("attachments");
evidence.recordAssertionEvidence(
"Cloud upload inputs expose paths, not bytes",
"Both registered extension schemas contain only path and message metadata fields; the Den Gmail JSON schema has no attachments field.",
extensionByteFields.length === 0 && !jsonDraftFields.includes("attachments"),
);
const driveSince = new Date().toISOString();
const driveRun = await runCloudUpload({
mode: "call",
root,
action: "drive_upload_file",
args: { path: "witness.png" },
context: { directory: root },
cloudUrl: `${den.ref.apiUrl}/mcp/agent`,
mcpToken,
});
expect(driveRun.ok).toBe(true);
expect(driveRun.networkCalls).toBe(1);
const driveResult = driveRun.result;
const driveUploads = await google.driveUploadsFor(account, { since: driveSince, atLeast: 1, timeoutMs: 10_000 });
const driveUpload = driveUploads[0];
expect(driveUpload?.filename).toBe("witness.png");
expect(driveUpload?.mimeType).toBe("image/png");
expect(driveUpload?.content).toEqual(fixture);
expect(requireRecord(requireRecord(driveResult, "Drive action result").file, "Drive result file")).toMatchObject({
name: "witness.png",
mimeType: "image/png",
size: String(fixture.byteLength),
});
evidence.recordAssertionEvidence(
"Drive receives the exact authorized workspace file",
`Mock Drive observed witness.png as image/png with ${driveUpload?.content.toString("hex")} bytes.`,
driveUpload?.content.equals(fixture) === true,
);
const draftSince = new Date().toISOString();
const draftRun = await runCloudUpload({
mode: "call",
root,
action: "gmail_create_draft_with_attachments",
args: {
to: "reviewer@openwork.test",
subject: "Binary attachment witness",
body: "Please review the attached file.",
paths: ["witness.png"],
},
context: { directory: root },
cloudUrl: `${den.ref.apiUrl}/mcp/agent`,
mcpToken,
});
expect(draftRun.ok).toBe(true);
expect(draftRun.networkCalls).toBe(1);
const draftResult = draftRun.result;
const drafts = await google.draftsFor(account, { since: draftSince, atLeast: 1, timeoutMs: 10_000 });
const attachment = drafts[0]?.attachments?.[0];
expect(attachment?.filename).toBe("witness.png");
expect(attachment?.mimeType).toBe("image/png");
expect(attachment?.content).toEqual(fixture);
const resultAttachments = requireRecord(draftResult, "Gmail action result").attachments;
expect(resultAttachments).toEqual([{
filename: "witness.png",
mimeType: "image/png",
size: fixture.byteLength,
}]);
const forbiddenResultFields = payloadFieldNames(draftResult).filter((name) => /base64|bytes|content|raw/i.test(name));
expect(forbiddenResultFields).toEqual([]);
expect(JSON.stringify(draftResult)).not.toContain(fixture.toString("base64"));
evidence.recordAssertionEvidence(
"Gmail receives exact attachment bytes while the action returns metadata only",
`Mock Gmail observed ${attachment?.filename}/${attachment?.mimeType}/${attachment?.size}; result fields were ${payloadFieldNames(draftResult).join(", ")}.`,
attachment?.content.equals(fixture) === true && forbiddenResultFields.length === 0,
);
await writeFile(join(root, "part-a.bin"), Buffer.alloc((2 * 1024 * 1024) + 1, 0xa5));
await writeFile(join(root, "part-b.bin"), Buffer.alloc(2 * 1024 * 1024, 0x5a));
const oversizeRun = await runCloudUpload({
mode: "call",
root,
action: "gmail_create_draft_with_attachments",
args: {
to: "reviewer@openwork.test",
subject: "Too large",
body: "This must fail locally.",
paths: ["part-a.bin", "part-b.bin"],
},
context: { directory: root },
cloudUrl: `${den.ref.apiUrl}/mcp/agent`,
mcpToken,
});
expect(oversizeRun).toMatchObject({ ok: false, status: 413, code: "files_too_large", networkCalls: 0 });
evidence.recordAssertionEvidence(
"Oversize aggregate input is rejected before network I/O",
`The extension fetch call count was ${oversizeRun.networkCalls}.`,
oversizeRun.networkCalls === 0,
);
} finally {
restoreEnvironment();
await rm(root, { recursive: true, force: true });
}
});