261 lines
12 KiB
TypeScript
261 lines
12 KiB
TypeScript
import { expect, onTestFinished, test } from "vitest";
|
|
import { denFetch, ensureMemberSession, signIn } from "@openwork/behaviors";
|
|
import type { DenSession } from "@openwork/behaviors";
|
|
|
|
const apiUrl = process.env.OPENWORK_EVAL_DEN_API_URL?.trim().replace(/\/+$/, "") ?? "";
|
|
const title = apiUrl
|
|
? "grant-native cloud skills can be shared by their creator without recipient re-sharing"
|
|
: "skill grant access skipped: set OPENWORK_EVAL_DEN_API_URL";
|
|
let requestId = 0;
|
|
|
|
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 toolText(result: unknown): string {
|
|
const record = requireRecord(result, "MCP tool result");
|
|
const first = Array.isArray(record.content) ? record.content[0] : null;
|
|
if (!isRecord(first) || typeof first.text !== "string") {
|
|
throw new Error(`MCP tool result had no text content: ${JSON.stringify(result).slice(0, 500)}`);
|
|
}
|
|
return first.text;
|
|
}
|
|
|
|
function toolJson(result: unknown): unknown {
|
|
return JSON.parse(toolText(result));
|
|
}
|
|
|
|
function searchMatches(result: unknown): Record<string, unknown>[] {
|
|
const payload = requireRecord(toolJson(result), "search_capabilities payload");
|
|
return Array.isArray(payload.matches) ? payload.matches.filter(isRecord) : [];
|
|
}
|
|
|
|
async function organizationId(session: DenSession): Promise<string> {
|
|
const result = await denFetch(session, "/v1/me/orgs", {
|
|
headers: { authorization: `Bearer ${session.token}` },
|
|
});
|
|
const orgs = isRecord(result.body) && Array.isArray(result.body.orgs)
|
|
? result.body.orgs.filter(isRecord)
|
|
: [];
|
|
const organization = orgs.find((entry) => entry.slug === "acme-robotics-demo")
|
|
?? orgs.find((entry) => entry.name === "Acme Robotics")
|
|
?? orgs[0];
|
|
const id = organization && typeof organization.id === "string" ? organization.id : "";
|
|
if (!result.response.ok || !id) {
|
|
throw new Error(`Finding Acme Robotics failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
async function selectOrganization(session: DenSession, orgId: string): Promise<void> {
|
|
const result = await denFetch(session, "/v1/me/active-organization", {
|
|
method: "POST",
|
|
headers: { authorization: `Bearer ${session.token}` },
|
|
body: JSON.stringify({ organizationId: orgId }),
|
|
});
|
|
if (!result.response.ok) throw new Error(`Selecting Acme Robotics failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`);
|
|
}
|
|
|
|
async function mintMcpToken(session: DenSession, orgId: string): Promise<string> {
|
|
const result = await denFetch(session, "/v1/mcp/token", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${session.token}`,
|
|
"x-openwork-org-id": orgId,
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
const token = isRecord(result.body) && typeof result.body.token === "string" ? result.body.token : "";
|
|
if (!result.response.ok || !token.startsWith("ow_mcp_at_")) {
|
|
throw new Error(`Minting MCP token failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`);
|
|
}
|
|
return token;
|
|
}
|
|
|
|
async function organizationMemberIdByEmail(session: DenSession, orgId: string, email: string): Promise<string> {
|
|
const result = await denFetch(session, "/v1/org", {
|
|
headers: {
|
|
authorization: `Bearer ${session.token}`,
|
|
"x-openwork-org-id": orgId,
|
|
},
|
|
});
|
|
const members = isRecord(result.body) && Array.isArray(result.body.members)
|
|
? result.body.members.filter(isRecord)
|
|
: [];
|
|
const member = members.find((entry) => isRecord(entry.user) && entry.user.email === email);
|
|
const memberId = member && typeof member.id === "string" ? member.id : "";
|
|
if (!result.response.ok || !memberId.startsWith("om_")) {
|
|
throw new Error(`Resolving ${email} in the active organization failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`);
|
|
}
|
|
return memberId;
|
|
}
|
|
|
|
async function callTool(
|
|
mcpToken: string,
|
|
name: "search_capabilities" | "execute_capability",
|
|
args: Record<string, unknown>,
|
|
): Promise<unknown> {
|
|
const response = await fetch(`${apiUrl}/mcp/agent`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${mcpToken}`,
|
|
"content-type": "application/json",
|
|
accept: "application/json, text/event-stream",
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: ++requestId,
|
|
method: "tools/call",
|
|
params: { name, arguments: args },
|
|
}),
|
|
});
|
|
const raw = await response.text();
|
|
if (!response.ok) throw new Error(`MCP tools/call failed: HTTP ${response.status} ${raw.slice(0, 500)}`);
|
|
const dataLine = raw.split("\n").find((line) => line.startsWith("data:"));
|
|
if (!dataLine) throw new Error(`MCP tools/call returned no SSE data frame: ${raw.slice(0, 500)}`);
|
|
const payload: unknown = JSON.parse(dataLine.slice(5));
|
|
const record = requireRecord(payload, "MCP JSON-RPC payload");
|
|
if (record.error) throw new Error(`MCP tools/call returned JSON-RPC error: ${JSON.stringify(record.error)}`);
|
|
return record.result;
|
|
}
|
|
|
|
function matchNamed(result: unknown, capabilityName: string): Record<string, unknown> | undefined {
|
|
return searchMatches(result).find((entry) => entry.name === capabilityName);
|
|
}
|
|
|
|
test.skipIf(!apiUrl)(title, async () => {
|
|
const den = {
|
|
apiUrl,
|
|
webUrl: (process.env.OPENWORK_EVAL_DEN_WEB_URL?.trim() || apiUrl.replace("127.0.0.1", "localhost")).replace(/\/+$/, ""),
|
|
};
|
|
const admin = await signIn(den, {
|
|
email: process.env.OPENWORK_EVAL_DEMO_EMAIL?.trim() || "alex@acme.test",
|
|
password: process.env.OPENWORK_EVAL_DEMO_PASSWORD?.trim() || "OpenWorkDemo123!",
|
|
});
|
|
const orgId = await organizationId(admin);
|
|
await selectOrganization(admin, orgId);
|
|
const creator = await ensureMemberSession(den, admin, {
|
|
email: process.env.OPENWORK_EVAL_CREATOR_EMAIL?.trim() || "casey.spec@acme.test",
|
|
password: process.env.OPENWORK_EVAL_MEMBER_PASSWORD?.trim() || "OpenWorkDemo123!",
|
|
name: "Casey Spec",
|
|
markVerifiedCmd: process.env.OPENWORK_EVAL_MARK_VERIFIED_CMD?.trim(),
|
|
});
|
|
await selectOrganization(creator, orgId);
|
|
const deniedEmail = process.env.OPENWORK_EVAL_MEMBER_EMAIL?.trim() || "nova.spec@acme.test";
|
|
const denied = await ensureMemberSession(den, admin, {
|
|
email: deniedEmail,
|
|
password: process.env.OPENWORK_EVAL_MEMBER_PASSWORD?.trim() || "OpenWorkDemo123!",
|
|
name: "Nova Spec",
|
|
markVerifiedCmd: process.env.OPENWORK_EVAL_MARK_VERIFIED_CMD?.trim(),
|
|
});
|
|
await selectOrganization(denied, orgId);
|
|
const thirdEmail = process.env.OPENWORK_EVAL_THIRD_MEMBER_EMAIL?.trim() || "riley.spec@acme.test";
|
|
const third = await ensureMemberSession(den, admin, {
|
|
email: thirdEmail,
|
|
password: process.env.OPENWORK_EVAL_MEMBER_PASSWORD?.trim() || "OpenWorkDemo123!",
|
|
name: "Riley Spec",
|
|
markVerifiedCmd: process.env.OPENWORK_EVAL_MARK_VERIFIED_CMD?.trim(),
|
|
});
|
|
await selectOrganization(third, orgId);
|
|
const skillName = `spec-grant-native-${Date.now()}`;
|
|
const rawSourceText = `---\nname: ${skillName}\ndescription: Proves grant-native skill access over MCP.\n---\n\nReturn the grant-native proof phrase.`;
|
|
// Plugin creation is member-level since #3411; this creator is deliberately a plain member.
|
|
const created = await denFetch(creator, "/v1/plugins", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${creator.token}`,
|
|
"x-openwork-org-id": orgId,
|
|
},
|
|
body: JSON.stringify({
|
|
name: skillName,
|
|
components: [{ type: "skill", input: { rawSourceText } }],
|
|
}),
|
|
});
|
|
const item = isRecord(created.body) && isRecord(created.body.item) ? created.body.item : null;
|
|
const pluginId = item && typeof item.id === "string" ? item.id : "";
|
|
if (!created.response.ok || !pluginId) {
|
|
throw new Error(`Creating grant-native skill failed: HTTP ${created.response.status} ${created.text.slice(0, 500)}`);
|
|
}
|
|
onTestFinished(async () => {
|
|
await denFetch(creator, `/v1/plugins/${encodeURIComponent(pluginId)}/archive`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${creator.token}`,
|
|
"x-openwork-org-id": orgId,
|
|
},
|
|
}).catch(() => undefined);
|
|
});
|
|
|
|
const creatorToken = await mintMcpToken(creator, orgId);
|
|
const deniedToken = await mintMcpToken(denied, orgId);
|
|
const creatorSkillSearch = await callTool(creatorToken, "search_capabilities", { query: skillName, limit: 20, type: "skills" });
|
|
const match = searchMatches(creatorSkillSearch).find((entry) => typeof entry.name === "string" && entry.name.startsWith(`plugin:${pluginId}:`));
|
|
if (!match || typeof match.name !== "string") throw new Error(`Creator did not discover ${skillName}.`);
|
|
const capabilityName = match.name;
|
|
expect(capabilityName.length).toBeGreaterThan(`plugin:${pluginId}:`.length);
|
|
expect(match.kind).toBe("skill");
|
|
expect(match).not.toHaveProperty("marketplace");
|
|
|
|
const creatorAllSearch = await callTool(creatorToken, "search_capabilities", { query: skillName, limit: 20 });
|
|
const allMatch = matchNamed(creatorAllSearch, capabilityName);
|
|
expect(allMatch).toBeDefined();
|
|
expect(allMatch).not.toHaveProperty("marketplace");
|
|
|
|
const creatorExecution = await callTool(creatorToken, "execute_capability", { name: capabilityName });
|
|
expect(isRecord(creatorExecution) && creatorExecution.isError === true).toBe(false);
|
|
expect(toolJson(creatorExecution)).toMatchObject({ kind: "skill", content: rawSourceText, marketplace: null });
|
|
|
|
const deniedSkillSearch = await callTool(deniedToken, "search_capabilities", { query: skillName, limit: 20, type: "skills" });
|
|
const deniedAllSearch = await callTool(deniedToken, "search_capabilities", { query: skillName, limit: 20 });
|
|
expect(matchNamed(deniedSkillSearch, capabilityName)).toBeUndefined();
|
|
expect(matchNamed(deniedAllSearch, capabilityName)).toBeUndefined();
|
|
|
|
const deniedExecution = requireRecord(
|
|
await callTool(deniedToken, "execute_capability", { name: capabilityName }),
|
|
"denied execute_capability result",
|
|
);
|
|
expect(deniedExecution.isError).toBe(true);
|
|
expect(toolJson(deniedExecution)).toEqual({
|
|
error: "forbidden",
|
|
message: "You have not been granted access to this plugin capability.",
|
|
});
|
|
|
|
const deniedMemberId = await organizationMemberIdByEmail(creator, orgId, deniedEmail);
|
|
const shared = await denFetch(creator, `/v1/plugins/${encodeURIComponent(pluginId)}/access`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${creator.token}`,
|
|
"x-openwork-org-id": orgId,
|
|
},
|
|
body: JSON.stringify({ orgMembershipId: deniedMemberId, role: "viewer" }),
|
|
});
|
|
if (!shared.response.ok) {
|
|
throw new Error(`Sharing grant-native skill failed: HTTP ${shared.response.status} ${shared.text.slice(0, 500)}`);
|
|
}
|
|
|
|
const sharedSkillSearch = await callTool(deniedToken, "search_capabilities", { query: skillName, limit: 20, type: "skills" });
|
|
const sharedAllSearch = await callTool(deniedToken, "search_capabilities", { query: skillName, limit: 20 });
|
|
expect(matchNamed(sharedSkillSearch, capabilityName)).toBeDefined();
|
|
expect(matchNamed(sharedAllSearch, capabilityName)).toBeDefined();
|
|
|
|
const sharedExecution = await callTool(deniedToken, "execute_capability", { name: capabilityName });
|
|
expect(isRecord(sharedExecution) && sharedExecution.isError === true).toBe(false);
|
|
expect(toolJson(sharedExecution)).toMatchObject({ kind: "skill", content: rawSourceText, marketplace: null });
|
|
|
|
const thirdMemberId = await organizationMemberIdByEmail(creator, orgId, thirdEmail);
|
|
const reshared = await denFetch(denied, `/v1/plugins/${encodeURIComponent(pluginId)}/access`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${denied.token}`,
|
|
"x-openwork-org-id": orgId,
|
|
},
|
|
body: JSON.stringify({ orgMembershipId: thirdMemberId, role: "viewer" }),
|
|
});
|
|
expect(reshared.response.status).toBe(403);
|
|
});
|