Implement authorized GitHub repository project eval journeys

This commit is contained in:
Dotta committed 2026-10-26 17:48:12 -05:00
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);
});
});