mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
Implement authorized GitHub repository project eval journeys
This commit is contained in:
1 parent
ad031f1c59
commit
f4ef7e81b9
6 files changed
+243
-4
No files matched your search
@@ -49,6 +49,18 @@ receipts contain only scenario, provider, configured boolean and missing field
|
||||
names. Never persist the parsed private file, credential values, session state or
|
||||
raw provider receipts into tracked files/public evidence.
|
||||
|
||||
The two project repository scenarios use a `github` provider binding with an
|
||||
explicitly authorized token environment reference `tokenEnv` (also present in
|
||||
`credentialEnv`). Add-repository requires `siteRepositoryId`; replacement requires
|
||||
`oldRepositoryId` and `newRepositoryId`. Each numeric ID must occur in
|
||||
`resourceIds`. The scoped disposable credential must independently expose the
|
||||
canonical synthetic names `acorn/site`, `acorn/site-old`, and `acorn/site-new`
|
||||
needed by the selected query through the production GitHub connection. Different
|
||||
real names require a reviewed query identity binding; they are never silently
|
||||
substituted. The fixture creates a company-owned encrypted secret and public
|
||||
connection, verifies current repository discovery, and deletes both on cleanup.
|
||||
Ambient `gh` authentication and personal credentials are never adopted.
|
||||
|
||||
Synthetic notes apps, MCP gateways, local plugins, team packages, local encrypted
|
||||
secrets and test endpoints do not require this file. They use real public
|
||||
Paperclip APIs and controlled fixture implementations; they cannot count as live
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileSync, statSync } from "node:fs";
|
||||
/** Private file contains only explicit disposable-resource allowlists and env
|
||||
* names. Credential values belong in the coordinator's child environment. */
|
||||
export const EXTERNAL_CONFIG_ENV = "PAPERCLIP_CHAT_CATALOG_EXTERNAL_CONFIG";
|
||||
export type ExternalProvider = "notion" | "slack" | "agentmail" | "vault";
|
||||
export type ExternalProvider = "notion" | "slack" | "agentmail" | "vault" | "github";
|
||||
export interface ProviderBinding {
|
||||
disposable: true;
|
||||
resourceIds: string[];
|
||||
@@ -12,6 +12,8 @@ export interface ProviderBinding {
|
||||
}
|
||||
export interface ExternalFixtureConfig { schemaVersion: 1; providers: Partial<Record<ExternalProvider, ProviderBinding>> }
|
||||
export const externalRequirements: Record<string, { provider: ExternalProvider; fields: readonly string[] }> = {
|
||||
"projects.add-repository": { provider: "github", fields: ["siteRepositoryId", "tokenEnv"] },
|
||||
"projects.replace-repository": { provider: "github", fields: ["oldRepositoryId", "newRepositoryId", "tokenEnv"] },
|
||||
"connection-setup.notion-connected": { provider: "notion", fields: ["workspaceId", "recentPageId", "olderPageId", "mcpUrl"] },
|
||||
"connection-setup.notion-missing": { provider: "notion", fields: ["workspaceId", "recentPageId", "oauthBrowserSession"] },
|
||||
"connection-setup.notion-wrong-account": { provider: "notion", fields: ["sharedWorkspaceId", "personalWorkspaceId", "personalPageId", "oauthBrowserSession"] },
|
||||
@@ -34,7 +36,7 @@ export function validateExternalConfig(value: unknown): ExternalFixtureConfig {
|
||||
const config = value as ExternalFixtureConfig;
|
||||
if (!config || config.schemaVersion !== 1 || !config.providers || typeof config.providers !== "object" || Array.isArray(config.providers)) throw new Error("Invalid external fixture configuration; contents withheld");
|
||||
for (const [provider, binding] of Object.entries(config.providers)) {
|
||||
if (!["notion", "slack", "agentmail", "vault"].includes(provider) || !binding || binding.disposable !== true
|
||||
if (!["notion", "slack", "agentmail", "vault", "github"].includes(provider) || !binding || binding.disposable !== true
|
||||
|| !Array.isArray(binding.resourceIds) || !binding.resourceIds.length || binding.resourceIds.some((id: unknown) => typeof id !== "string" || !id.trim())
|
||||
|| !Array.isArray(binding.credentialEnv) || !binding.credentialEnv.length || binding.credentialEnv.some((name: unknown) => typeof name !== "string" || !/^PAPERCLIP_CHAT_EVAL_[A-Z0-9_]+$/.test(name))
|
||||
|| !binding.prerequisites || typeof binding.prerequisites !== "object" || Array.isArray(binding.prerequisites)
|
||||
|
||||
@@ -84,7 +84,11 @@ describe("external domain independent oracles", () => {
|
||||
it("a plausible reply alone never passes", () => { for (const definition of definitions) expect(definition.grade({ reply: "Done, everything is connected and ready." }).passed).toBe(false); });
|
||||
});
|
||||
describe("private disposable provider prerequisite contract", () => {
|
||||
it("enumerates only the 16 named-provider journeys", () => { expect(Object.keys(externalRequirements)).toHaveLength(16); });
|
||||
it("enumerates the 16 named-provider journeys and two authorized repository journeys", () => {
|
||||
expect(Object.keys(externalRequirements)).toHaveLength(18);
|
||||
expect(Object.entries(externalRequirements).filter(([, requirement]) => requirement.provider === "github").map(([id]) => id))
|
||||
.toEqual(["projects.add-repository", "projects.replace-repository"]);
|
||||
});
|
||||
it("missing input produces field-only receipts, never a configured pass", () => {
|
||||
for (const id of Object.keys(externalRequirements)) { const receipt = externalPrerequisiteReceipt(id, undefined, {}); expect(receipt.configured).toBe(false); expect(receipt.missing.length).toBeGreaterThan(1); }
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { projectPrivacyCases } from "./internal-privacy.js";
|
||||
import { repositoryProjectCases } from "./internal-repositories.js";
|
||||
import { projectWorkCases } from "./internal-task-creation.js";
|
||||
import type { ChatCatalogCase } from "../chat-catalog-contract.js";
|
||||
import { project, task, goal, putDocument, entity, observation, checks, hasLink, sameTask, type Entity, type InternalObservation } from "./internal-support.js";
|
||||
@@ -41,6 +42,7 @@ export function grade_projects(value: unknown) {
|
||||
const define = (id: string, setup: ChatCatalogCase["setup"]) => makeDefinition(id, setup, grade_projects);
|
||||
export const projectsCases: readonly ChatCatalogCase[] = [
|
||||
...projectPrivacyCases,
|
||||
...repositoryProjectCases,
|
||||
...projectWorkCases,
|
||||
retryDefinition("projects.create-project", grade_projects, initialProjectCreation),
|
||||
define("projects.archive-project", async ctx => {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
import { pollUntil } from "../api.js";
|
||||
import { makeDefinition } from "./internal-definition.js";
|
||||
import { externalPrerequisiteReceipt, readExternalConfig, requireExternalPrerequisites } from "./external-fixtures.js";
|
||||
import { checks, entity, hasLink, observation, observeInternal, project, sameTask, task, type Entity, type InternalObservation } from "./internal-support.js";
|
||||
|
||||
interface Repository { id: string; fullName: string; url: string; connectionIds?: string[] }
|
||||
interface Workspace extends Entity { repoUrl?: string | null; cwd?: string | null; metadata?: { githubRepositoryId?: string } }
|
||||
interface RepositoryProject extends Entity { workspaces: Workspace[] }
|
||||
interface RepositoryObservation extends InternalObservation {
|
||||
repositoryProject?: RepositoryProject; repositories: Repository[]; connection: { id: string; status: string; enabled: boolean };
|
||||
}
|
||||
function repositoriesOf(p: RepositoryProject | undefined) { return p?.workspaces.filter(row => row.repoUrl) ?? []; }
|
||||
async function connectRepositoryFixture(ctx: ChatCatalogContext) {
|
||||
const config = readExternalConfig(); const receipt = externalPrerequisiteReceipt(ctx.scenario.id, config);
|
||||
await ctx.input.evidence("chat-catalog-github-prerequisite.json", receipt);
|
||||
const binding = requireExternalPrerequisites(ctx.scenario.id, config);
|
||||
const envName = binding.prerequisites.tokenEnv!;
|
||||
if (!binding.credentialEnv.includes(envName) || !/^PAPERCLIP_CHAT_EVAL_[A-Z0-9_]+$/.test(envName))
|
||||
throw new Error("Setup blocker: GitHub tokenEnv must name an explicitly authorized scoped credentialEnv binding");
|
||||
const token = process.env[envName]?.trim();
|
||||
if (!token || !ctx.input.privateSecrets) throw new Error("Setup blocker: disposable GitHub credential and attempt-private redaction sink are required");
|
||||
ctx.input.privateSecrets.push(token);
|
||||
const requested = ctx.scenario.id === "projects.add-repository"
|
||||
? [{ id: binding.prerequisites.siteRepositoryId!, fullName: "acorn/site" }]
|
||||
: [{ id: binding.prerequisites.oldRepositoryId!, fullName: "acorn/site-old" }, { id: binding.prerequisites.newRepositoryId!, fullName: "acorn/site-new" }];
|
||||
if (requested.some(row => !/^\d+$/.test(row.id) || !binding.resourceIds.includes(row.id)))
|
||||
throw new Error("Setup blocker: each GitHub repository ID must be numeric and explicitly allowlisted as disposable");
|
||||
const root = `/api/companies/${ctx.input.fixtures.company.id}`;
|
||||
const secret = await ctx.input.api.postSensitive<{ id: string }>(`${root}/secrets`, { name: "Disposable repository GitHub token",
|
||||
key: `CATALOG_GITHUB_${ctx.input.nonce.replaceAll("-", "_").toUpperCase()}`, provider: "local_encrypted", value: token });
|
||||
let connectionId: string | undefined;
|
||||
try {
|
||||
const connection = await ctx.input.api.postSensitive<{ id: string }>(`${root}/tools/connections`, {
|
||||
applicationName: "Disposable Repository GitHub", name: "Authorized synthetic repositories", transport: "rest_api", authKind: "api_key",
|
||||
credentialPolicy: "shared", status: "active", enabled: true, config: { sourceTemplateKey: "github" },
|
||||
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token", required: true }],
|
||||
});
|
||||
connectionId = connection.id;
|
||||
const options = await ctx.input.api.get<{ repositories: Repository[]; failedConnectionCount: number }>(`${root}/project-repositories`);
|
||||
if (options.failedConnectionCount || !requested.every(expected => options.repositories.some(row => row.id === expected.id
|
||||
&& row.fullName === expected.fullName && row.url === `https://github.com/${expected.fullName}` && row.connectionIds?.includes(connection.id))))
|
||||
throw new Error("Setup blocker: the current authorized GitHub connection must independently expose the allowlisted canonical synthetic repository names and IDs; aliases require an audited binding extension");
|
||||
return { connectionId: connection.id, secretId: secret.id, requested };
|
||||
} catch (error) {
|
||||
if (connectionId) await ctx.input.api.delete(`/api/tool-connections/${connectionId}`, { allowNotFound: true });
|
||||
await ctx.input.api.delete(`/api/secrets/${secret.id}`, { allowNotFound: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function agentReplacementStatus(ctx: ChatCatalogContext, projectId: string, repositoryId: string) {
|
||||
const agentId = ctx.input.fixtures.agent.id;
|
||||
const key = await ctx.input.api.postSensitive<{ id: string; token: string }>(`/api/agents/${agentId}/keys`, { name: "repository-replacement-boundary-control" });
|
||||
ctx.input.privateSecrets?.push(key.token);
|
||||
try {
|
||||
const response = await fetch(new URL(`/api/projects/${projectId}/repositories`, ctx.input.api.baseURL), {
|
||||
method: "PUT", redirect: "error", signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${key.token}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ repositoryIds: [repositoryId] }),
|
||||
});
|
||||
await response.body?.cancel(); return response.status;
|
||||
} finally { await ctx.input.api.delete(`/api/agents/${agentId}/keys/${key.id}`); }
|
||||
}
|
||||
function affirmativeCheckoutClaim(reply: string) {
|
||||
return reply.split(/[.\n;]/).some(part => !/\b(?:not|no|without|yet|only|metadata)\b/i.test(part)
|
||||
&& /\b(?:cloned|checked out|checkout is ready|repository is ready|website is ready|deployed|workspaces? (?:have|has) (?:moved|switched))\b/i.test(part));
|
||||
}
|
||||
export function gradeRepositories(value: unknown) {
|
||||
const c = checks(); const o = observation(value) as RepositoryObservation | undefined;
|
||||
c.add("independent-current-repository-evidence", !!o && Array.isArray(o.repositories) && !!o.connection,
|
||||
"Actual current-connection repository discovery and saved project workspaces are required.");
|
||||
if (!o || !Array.isArray(o.repositories) || !o.connection) return c.result();
|
||||
const requested = o.state.requested as Array<{ id: string; fullName: string }>;
|
||||
c.add("authorized-current-connection", o.connection.id === o.state.connectionId && o.connection.enabled && o.connection.status === "active"
|
||||
&& Array.isArray(requested) && requested.every(expected => o.repositories.some(row => row.id === expected.id && row.fullName === expected.fullName
|
||||
&& row.connectionIds?.includes(o.connection.id))), "The actual active fixture connection still authorizes each intended repository.");
|
||||
const p = o.repositoryProject; const selected = repositoriesOf(p);
|
||||
const desired = requested?.[o.scenarioId === "projects.add-repository" ? 0 : 1];
|
||||
c.add("exact-supported-repository-selection", !!p && p.companyId === o.companyId && p.name === "Launch" && selected.length === 1 && !!desired
|
||||
&& selected[0]?.repoUrl === `https://github.com/${desired.fullName}`
|
||||
&& (!selected[0]?.metadata?.githubRepositoryId || selected[0].metadata.githubRepositoryId === desired.id)
|
||||
&& p.workspaces.every(row => !row.cwd && !/\b(?:token|password)\b|https:\/\/[^/]*@/i.test(row.repoUrl ?? "")),
|
||||
"The exact authorized repository persists without operator filesystem paths or embedded credentials.");
|
||||
if (o.scenarioId === "projects.add-repository") {
|
||||
c.add("agent-created-intended-project", !!p && o.projects.filter(row => row.name === "Launch").length === 1 && o.tasks.length === 0
|
||||
&& o.activity.some(row => row.entityId === p.id && row.action === "project.created" && row.agentId === o.leadId),
|
||||
"One intended Launch project is created by the CEO through the supported path.");
|
||||
c.add("metadata-is-not-ready-checkout", !affirmativeCheckoutClaim(o.reply), "Chat distinguishes recorded repository metadata from actual checkout or deployment readiness.");
|
||||
} else {
|
||||
const before = o.state.beforeOwnerReplacement as { project: RepositoryProject; reply: string; agentStatus: number } | undefined;
|
||||
c.add("agent-cannot-replace-existing-selection", !!before && before.agentStatus === 403 && repositoriesOf(before.project).length === 1
|
||||
&& repositoriesOf(before.project)[0]?.repoUrl === "https://github.com/acorn/site-old"
|
||||
&& /acorn\/site-old/.test(before.reply) && /acorn\/site-new/.test(before.reply)
|
||||
&& /\b(?:human|owner|you|board|administrator)\b/i.test(before.reply) && /\b(?:future|new work|next|existing|current workspaces)\b/i.test(before.reply)
|
||||
&& !/\bI(?:'ve| have)?\s+(?:already\s+)?(?:switched|replaced|updated)\b|\bnow (?:uses?|points? to)\b[^.\n]*acorn\/site-new/i.test(before.reply)
|
||||
&& !affirmativeCheckoutClaim(before.reply), "The first agent phase retains the old selection, explains the real human replacement path and its future-work scope.");
|
||||
c.add("real-owner-replacement", !!p && o.activity.some(row => row.action === "project.repositories_updated" && row.entityId === p.id
|
||||
&& row.actorType === "user" && row.actorId === o.state.ownerId)
|
||||
&& !o.activity.some(row => row.action === "project.repositories_updated" && row.agentId === o.leadId),
|
||||
"A real human UI action saves the new repository; ordinary agent mutation never bypasses the board boundary.");
|
||||
c.add("existing-work-retained", sameTask(entity(o.state, "task"), o.tasks.find(row => row.id === entity(o.state, "task").id)),
|
||||
"Repository selection does not rewrite the existing task or claim its execution has moved.");
|
||||
}
|
||||
return c.result();
|
||||
}
|
||||
async function openProject(ctx: ChatCatalogContext, o: RepositoryObservation) {
|
||||
const p = o.repositoryProject; if (!p) throw new Error("No saved repository project");
|
||||
const href = o.links.find(link => hasLink({ ...o, links: [link] }, "projects", p));
|
||||
if (href) await ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(href)}]`).first().click();
|
||||
else await ctx.input.page.goto(new URL(`/${ctx.input.fixtures.company.issuePrefix}/projects/${p.urlKey ?? p.id}`, o.origin).href, { waitUntil: "domcontentloaded" });
|
||||
await expect(ctx.input.page.getByRole("heading", { name: p.name!, exact: true }).first()).toBeVisible();
|
||||
await ctx.input.page.getByText("Configuration", { exact: true }).last().click();
|
||||
const section = ctx.input.page.getByRole("region", { name: "Repositories", exact: true }); await expect(section).toBeVisible();
|
||||
return section;
|
||||
}
|
||||
function repositoryCase(scenarioId: string): ChatCatalogCase {
|
||||
const base = makeDefinition(scenarioId, async ctx => {
|
||||
const fixture = await connectRepositoryFixture(ctx);
|
||||
try {
|
||||
if (scenarioId === "projects.add-repository") return fixture;
|
||||
const session = await ctx.input.api.get<{ user?: { id: string } }>("/api/auth/get-session");
|
||||
if (!session.user?.id) throw new Error("Repository replacement requires a real owner browser session");
|
||||
const p = await project(ctx, "Launch", { repositoryIds: [fixture.requested[0]!.id] });
|
||||
const existing = await task(ctx, "Existing Launch work", p.id);
|
||||
return { ...fixture, project: p, task: existing, ownerId: session.user.id };
|
||||
} catch (error) {
|
||||
await ctx.input.api.delete(`/api/tool-connections/${fixture.connectionId}`, { allowNotFound: true });
|
||||
await ctx.input.api.delete(`/api/secrets/${fixture.secretId}`, { allowNotFound: true }); throw error;
|
||||
}
|
||||
}, gradeRepositories);
|
||||
return { ...base, authentication: "human-session", sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-repositories.ts", "chat-catalog-domains/external-fixtures.ts"],
|
||||
async observe(ctx, state): Promise<RepositoryObservation> {
|
||||
const o = await observeInternal(ctx, state); const p = o.projects.find(row => row.id === (state.project as Entity | undefined)?.id)
|
||||
?? o.projects.find(row => row.name === "Launch");
|
||||
const [options, connection] = await Promise.all([
|
||||
ctx.input.api.get<{ repositories: Repository[] }>(`/api/companies/${o.companyId}/project-repositories`),
|
||||
ctx.input.api.get<RepositoryObservation["connection"]>(`/api/tool-connections/${state.connectionId}`),
|
||||
]);
|
||||
return { ...o, repositoryProject: p ? await ctx.input.api.get<RepositoryProject>(`/api/projects/${p.id}`) : undefined,
|
||||
repositories: options.repositories, connection };
|
||||
},
|
||||
...(scenarioId === "projects.replace-repository" ? { async act(ctx: ChatCatalogContext, state: Record<string, unknown>) {
|
||||
const first = await pollUntil({ label: "Repository owner handoff", deadlineAt: Date.now() + 150_000,
|
||||
load: () => observeInternal(ctx, state),
|
||||
accept: value => { const o = value as InternalObservation; return o.runs.length === 1 && o.runs.every(row => row.status === "succeeded") && ctx.issue()?.conversationState === "waiting"; } }) as InternalObservation;
|
||||
const p = entity(state, "project"); const requested = state.requested as Array<{ id: string }>;
|
||||
const saved = await ctx.input.api.get<RepositoryProject>(`/api/projects/${p.id}`);
|
||||
const status = await agentReplacementStatus(ctx, p.id, requested[1]!.id);
|
||||
state.beforeOwnerReplacement = { project: saved, reply: first.reply, agentStatus: status };
|
||||
await ctx.input.evidence("chat-catalog-before-owner-repository-replacement.json", state.beforeOwnerReplacement);
|
||||
if (status !== 403 || repositoriesOf(saved)[0]?.repoUrl !== "https://github.com/acorn/site-old") throw new Error("Existing repository changed before an authorized owner action");
|
||||
const source = ctx.input.page.url();
|
||||
const section = await openProject(ctx, { ...first, repositoryProject: saved } as RepositoryObservation);
|
||||
await section.getByRole("button", { name: "Remove acorn/site-old", exact: true }).click();
|
||||
await section.getByRole("button", { name: "Add GitHub repo", exact: true }).click();
|
||||
await section.getByRole("combobox").click();
|
||||
await ctx.input.page.getByPlaceholder("Search GitHub repos…", { exact: true }).fill("acorn/site-new");
|
||||
await ctx.input.page.getByRole("option", { name: /acorn\/site-new/ }).click();
|
||||
await section.getByRole("button", { name: "Save changes", exact: true }).click();
|
||||
await pollUntil({ label: "Saved owner repository replacement", deadlineAt: Date.now() + 30_000,
|
||||
load: () => ctx.input.api.get<RepositoryProject>(`/api/projects/${p.id}`), accept: value => repositoriesOf(value).length === 1 && repositoriesOf(value)[0]?.repoUrl === "https://github.com/acorn/site-new" });
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||||
} } : {}),
|
||||
async verifyResult(ctx, _state, value) {
|
||||
const o = value as RepositoryObservation; const source = ctx.input.page.url(); const section = await openProject(ctx, o);
|
||||
const desired = (o.state.requested as Array<{ fullName: string }>)[scenarioId === "projects.add-repository" ? 0 : 1]!;
|
||||
await expect(section.getByText(desired.fullName, { exact: true }).first()).toBeVisible();
|
||||
await ctx.input.evidence("chat-catalog-repository-project-opened.json", { projectId: o.repositoryProject!.id, repository: desired.fullName, url: ctx.input.page.url() });
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||||
},
|
||||
async cleanup(ctx, state) {
|
||||
if (state.connectionId) await ctx.input.api.delete(`/api/tool-connections/${state.connectionId}`, { allowNotFound: true });
|
||||
if (state.secretId) await ctx.input.api.delete(`/api/secrets/${state.secretId}`, { allowNotFound: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
export const repositoryProjectCases: readonly ChatCatalogCase[] = [repositoryCase("projects.add-repository"), repositoryCase("projects.replace-repository")];
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gradeInternal, internalChatCatalogCases } from "./internal.js";
|
||||
import type { Entity, InternalObservation } from "./internal-support.js";
|
||||
import { externalPrerequisiteReceipt, validateExternalConfig } from "./external-fixtures.js";
|
||||
|
||||
function base(scenarioId: string): InternalObservation {
|
||||
const p = { id: "p", companyId: "company", name: "Launch" };
|
||||
@@ -14,6 +15,22 @@ function event(action: string, entityId: string, details?: Record<string, unknow
|
||||
function valid(scenario: string): InternalObservation {
|
||||
const o = base(scenario);
|
||||
switch (scenario) {
|
||||
case "projects.add-repository":
|
||||
case "projects.replace-repository": {
|
||||
const adding = scenario === "projects.add-repository";
|
||||
const requested = adding ? [{ id: "101", fullName: "acorn/site" }] : [{ id: "102", fullName: "acorn/site-old" }, { id: "103", fullName: "acorn/site-new" }];
|
||||
const desired = requested.at(-1)!;
|
||||
o.state.requested = requested; o.state.connectionId = "connection"; o.state.ownerId = "owner";
|
||||
Object.assign(o, { repositories: requested.map(row => ({ ...row, url: `https://github.com/${row.fullName}`, connectionIds: ["connection"] })),
|
||||
connection: { id: "connection", enabled: true, status: "active" }, repositoryProject: { ...o.projects[0], workspaces: [{ id: "workspace", repoUrl: `https://github.com/${desired.fullName}`, metadata: { githubRepositoryId: desired.id } }] } });
|
||||
if (adding) { o.tasks = []; o.activity = [event("project.created", "p")]; }
|
||||
else {
|
||||
o.state.beforeOwnerReplacement = { project: { ...o.projects[0], workspaces: [{ id: "old-workspace", repoUrl: "https://github.com/acorn/site-old" }] }, agentStatus: 403,
|
||||
reply: "Switching acorn/site-old to acorn/site-new requires you to use the human repository replacement control; future work uses the new repository, existing workspaces do not move." };
|
||||
o.activity = [{ ...event("project.repositories_updated", "p"), agentId: null, actorType: "user", actorId: "owner" }];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tasks.organize-open-work": {
|
||||
o.tasks = Array.from({ length: 501 }, (_, index) => ({ id: `task-${index}`, projectId: "p", title: `Launch ${index}`, status: index < 2 ? "blocked" : "todo", assigneeAgentId: null }));
|
||||
o.state.originalTasks = structuredClone(o.tasks); o.state.ownerLabels = { unassigned: "Unassigned" };
|
||||
@@ -147,6 +164,8 @@ function valid(scenario: string): InternalObservation {
|
||||
return o;
|
||||
}
|
||||
const corrupt: Record<string, (o: InternalObservation) => void> = {
|
||||
"projects.add-repository": o => { Object.assign(o, { repositoryProject: { ...o.projects[0], workspaces: [{ id: "wrong", repoUrl: "https://github.com/acorn/unapproved" }] } }); },
|
||||
"projects.replace-repository": o => { (o.state.beforeOwnerReplacement as { agentStatus: number }).agentStatus = 200; },
|
||||
"tasks.organize-open-work": o => { o.reply = o.reply.replace("## Unassigned", "## Morgan"); },
|
||||
"inbox-search.show-my-work": o => { o.reply = "You have 7 unread inbox items; I reviewed all of them."; },
|
||||
"inbox-search.archive-without-grant": o => { o.activity.push(event("issue.inbox_archived", "task")); },
|
||||
@@ -179,7 +198,7 @@ describe("internal chat scenario independent oracles", () => {
|
||||
expect(gradeInternal(wrong).passed).toBe(false);
|
||||
expect(gradeInternal(undefined).passed).toBe(false);
|
||||
const noLinks = structuredClone(good); noLinks.links = [];
|
||||
expect(gradeInternal(noLinks).passed).toBe(definition.scenarioId === "goals.align-existing-work");
|
||||
expect(gradeInternal(noLinks).passed).toBe(["goals.align-existing-work", "projects.add-repository", "projects.replace-repository"].includes(definition.scenarioId));
|
||||
});
|
||||
}
|
||||
it("never treats an unimplemented scenario as passing", () => expect(gradeInternal(base("inbox-search.archive-done")).passed).toBe(false));
|
||||
@@ -402,3 +421,26 @@ it("rejects a limitation-only reply when the responsible-user records are actual
|
||||
o.links = ["/ACORN/inbox/mine"];
|
||||
expect(gradeInternal(o).passed).toBe(false);
|
||||
});
|
||||
|
||||
describe("repository fixture prerequisites and readback", () => {
|
||||
it("requires explicit disposable GitHub configuration rather than adopting ambient auth", () => {
|
||||
expect(externalPrerequisiteReceipt("projects.add-repository", undefined, {})).toMatchObject({ configured: false, provider: "github" });
|
||||
const config = validateExternalConfig({ schemaVersion: 1, providers: { github: { disposable: true, resourceIds: ["101"],
|
||||
credentialEnv: ["PAPERCLIP_CHAT_EVAL_GITHUB_TOKEN"], prerequisites: { siteRepositoryId: "101", tokenEnv: "PAPERCLIP_CHAT_EVAL_GITHUB_TOKEN" } } } });
|
||||
expect(externalPrerequisiteReceipt("projects.add-repository", config, {}).configured).toBe(false);
|
||||
expect(externalPrerequisiteReceipt("projects.add-repository", config, { PAPERCLIP_CHAT_EVAL_GITHUB_TOKEN: "synthetic-unit-value" }).configured).toBe(true);
|
||||
});
|
||||
it("rejects a plausible authorized ID paired with a different current repository name", () => {
|
||||
const o = valid("projects.add-repository"); Object.assign(o, { repositories: [{ id: "101", fullName: "acorn/another", connectionIds: ["connection"] }] });
|
||||
expect(gradeInternal(o).passed).toBe(false);
|
||||
});
|
||||
it("rejects an invented ready checkout when only repository metadata was saved", () => {
|
||||
const o = valid("projects.add-repository"); o.reply = "The repository is ready and cloned.";
|
||||
expect(gradeInternal(o).passed).toBe(false);
|
||||
});
|
||||
it("rejects a replacement success claim before the human has changed the selection", () => {
|
||||
const o = valid("projects.replace-repository");
|
||||
(o.state.beforeOwnerReplacement as { reply: string }).reply += " I have switched the repository now.";
|
||||
expect(gradeInternal(o).passed).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user