mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Select only the run context used by GitHub operation credential checks. Preserve the default complete identity capture for Muse and other consumers, current trust checks, grants, and saved run evidence. Validation: 72 focused GitHub, identity, Slack, chat-project, and project-route tests; full build and typecheck; exact-head Greptile 5/5 (medium), all CI checks green, and zero unresolved comments. Real PostgreSQL tests prove current revocation and response-size bounds. No live external GitHub push was performed. Co-Authored-By: Paperclip <noreply@paperclip.ing>
981 lines
36 KiB
TypeScript
981 lines
36 KiB
TypeScript
import { createNativeGitHubAccess } from "../services/native-runtime/native-github-access.js";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { runtimeConnectionIntentRoutes } from "../routes/connection-intents.js";
|
|
import { createRuntimeToolsToken } from "../runtime-tools-token.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
agents,
|
|
companies,
|
|
companyMemberships,
|
|
companySecrets,
|
|
connectionGrants,
|
|
createDb,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issues,
|
|
projects,
|
|
runIdentityContexts,
|
|
toolApplications,
|
|
toolConnectionInstalls,
|
|
toolConnections,
|
|
userSecretDefinitions,
|
|
} from "@paperclipai/db";
|
|
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import {
|
|
initializeRunIdentity,
|
|
captureRunIdentity,
|
|
reserveSteeredIdentity,
|
|
acceptSteeredIdentity,
|
|
} from "../services/run-identity.js";
|
|
import { resolveGitHubOperationCredentials } from "../services/github-operation-credentials.js";
|
|
import {
|
|
filterResolvedGitHubConnectionsForRun,
|
|
resolveManagedGitHubIdentitySelection,
|
|
} from "../services/git-credentials.js";
|
|
|
|
const vault = vi.hoisted(() => ({
|
|
resolveUserSecretValue: vi.fn(
|
|
async (_company: string, input: { responsibleUserId: string }) => ({
|
|
value: `test-token-${input.responsibleUserId}`,
|
|
}),
|
|
),
|
|
resolveSecretValue: vi.fn(async () => "test-dedicated-token"),
|
|
}));
|
|
vi.mock("../services/secrets.js", () => ({ secretService: () => vault }));
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)(
|
|
"operation-time GitHub credential resolution",
|
|
() => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>,
|
|
db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => {
|
|
vi.stubEnv(
|
|
"PAPERCLIP_AGENT_JWT_SECRET",
|
|
"test-github-broker-signing-secret",
|
|
);
|
|
database = await startEmbeddedPostgresTestDatabase(
|
|
"paperclip-github-operation-",
|
|
);
|
|
db = createDb(database.connectionString);
|
|
}, 30_000);
|
|
afterAll(async () => {
|
|
await database?.cleanup();
|
|
vi.unstubAllEnvs();
|
|
}, 60_000);
|
|
async function seed() {
|
|
const companyId = randomUUID(),
|
|
agentId = randomUUID(),
|
|
runId = randomUUID(),
|
|
issueId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: companyId,
|
|
issuePrefix: companyId.slice(0, 8),
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Shared",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
});
|
|
await db
|
|
.insert(issues)
|
|
.values({ id: issueId, companyId, title: "Identity test" });
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
contextSnapshot: { issueId },
|
|
});
|
|
await db.insert(companyMemberships).values(
|
|
["A", "B"].map((principalId) => ({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
})),
|
|
);
|
|
await initializeRunIdentity(db, {
|
|
companyId,
|
|
runId,
|
|
responsibleUserId: "A",
|
|
cause: "instruction",
|
|
});
|
|
return { companyId, agentId, runId, issueId };
|
|
}
|
|
async function grant(
|
|
input: Awaited<ReturnType<typeof seed>>,
|
|
user: string,
|
|
dedicated = false,
|
|
) {
|
|
const applicationId = randomUUID(),
|
|
connectionId = randomUUID(),
|
|
secretId = randomUUID(),
|
|
definitionId = randomUUID(),
|
|
id = randomUUID();
|
|
await db.insert(toolApplications).values({
|
|
id: applicationId,
|
|
companyId: input.companyId,
|
|
name: applicationId,
|
|
type: "mcp_http",
|
|
});
|
|
await db.insert(toolConnections).values({
|
|
id: connectionId,
|
|
companyId: input.companyId,
|
|
applicationId,
|
|
name: connectionId,
|
|
uid: connectionId,
|
|
transport: "mcp_remote",
|
|
status: "active",
|
|
enabled: true,
|
|
credentialPolicy: dedicated ? "per_agent" : "per_user",
|
|
config: { sourceTemplateKey: "github" },
|
|
});
|
|
await db.insert(toolConnectionInstalls).values({
|
|
companyId: input.companyId,
|
|
connectionId,
|
|
targetType: "agent",
|
|
targetId: input.agentId,
|
|
});
|
|
if (!dedicated)
|
|
await db.insert(userSecretDefinitions).values({
|
|
id: definitionId,
|
|
companyId: input.companyId,
|
|
key: definitionId,
|
|
name: "Test GitHub",
|
|
});
|
|
await db.insert(companySecrets).values({
|
|
id: secretId,
|
|
companyId: input.companyId,
|
|
key: secretId,
|
|
name: `Test token ${secretId}`,
|
|
scope: dedicated ? "company" : "user",
|
|
ownerUserId: dedicated ? null : user,
|
|
userSecretDefinitionId: dedicated ? null : definitionId,
|
|
});
|
|
await db.insert(connectionGrants).values({
|
|
id,
|
|
companyId: input.companyId,
|
|
connectionId,
|
|
kind: dedicated ? "agent" : "user",
|
|
subjectUserId: dedicated ? null : user,
|
|
subjectAgentId: dedicated ? input.agentId : null,
|
|
status: "active",
|
|
credentialSecretRefs: [
|
|
{
|
|
secretId,
|
|
configPath: "oauth.access_token",
|
|
versionSelector: "latest",
|
|
},
|
|
],
|
|
providerTenant: {
|
|
github: {
|
|
userId: user,
|
|
login: user,
|
|
installationCount: 1,
|
|
repositoryCount: 1,
|
|
repositorySelection: "selected",
|
|
installationIds: ["1"],
|
|
installationOwnerLogins: [user],
|
|
},
|
|
},
|
|
});
|
|
return { id, connectionId, secretId, definitionId };
|
|
}
|
|
it.each(["config", "transportConfig"] as const)("reads only current GitHub identity connections via %s", async location => {
|
|
const input = await seed(), selected = await grant(input, "A");
|
|
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, selected.connectionId));
|
|
await db.update(toolConnections).set({ config: {}, transportConfig: {}, [location]: { sourceTemplateKey: "github" } })
|
|
.where(eq(toolConnections.id, selected.connectionId));
|
|
const payload = "unrelated-connection-config🙂".repeat(10_000);
|
|
const irrelevant = [null, true, 42, ["github"], { name: "github" }, "GitHub", " github ", "other"].map(sourceTemplateKey => {
|
|
const id = randomUUID();
|
|
return { id, companyId: input.companyId, applicationId: connection.applicationId, name: id, uid: id,
|
|
transport: "mcp_remote" as const, config: { sourceTemplateKey, payload }, transportConfig: { sourceTemplateKey } };
|
|
});
|
|
await db.insert(toolConnections).values(irrelevant);
|
|
const observed = createDb(database.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
|
|
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
|
|
installDatabaseResponseCapture(observed, { toolConnections }, capture);
|
|
try {
|
|
expect(await resolveManagedGitHubIdentitySelection(observed, input.companyId, { agentId: input.agentId, responsibleUserId: "A" }))
|
|
.toMatchObject({ configured: true, grant: { id: selected.id } });
|
|
capture.flush();
|
|
const reads = events.filter(event => event.table === "tool_connections" && event.operation === "select");
|
|
expect(reads).toHaveLength(1);
|
|
expect(reads[0].rows).toBe(1);
|
|
expect(reads[0].responseValueBytes).toBeLessThan(8192);
|
|
await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, selected.connectionId));
|
|
expect((await resolveManagedGitHubIdentitySelection(observed, input.companyId, { agentId: input.agentId, responsibleUserId: "A" })).grant)
|
|
.toBeUndefined();
|
|
await db.update(toolConnections).set({ [location]: { sourceTemplateKey: "other" } }).where(eq(toolConnections.id, selected.connectionId));
|
|
expect(await resolveManagedGitHubIdentitySelection(observed, input.companyId, { agentId: input.agentId, responsibleUserId: "A" }))
|
|
.toEqual({ configured: false });
|
|
expect(await resolveManagedGitHubIdentitySelection(observed, randomUUID(), { agentId: input.agentId, responsibleUserId: "A" }))
|
|
.toEqual({ configured: false });
|
|
const saved = await db.select().from(toolConnections).where(eq(toolConnections.companyId, input.companyId));
|
|
for (const row of irrelevant) expect(saved.find(candidate => candidate.id === row.id)?.config).toEqual(row.config);
|
|
} finally { capture.flush(); await observed.$client.end(); }
|
|
});
|
|
|
|
it("resolves current GitHub credentials without transferring execution snapshots", async () => {
|
|
const input = await seed(), selected = await grant(input, "A");
|
|
const payload = "github-credential-saved-input🙂".repeat(25_000);
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: input.issueId, prompt: payload },
|
|
runnerProfileJson: { nativeExecutionInput: { savedInput: payload }, savedInput: payload },
|
|
resultJson: { stdout: payload }, stdoutExcerpt: payload, stderrExcerpt: payload,
|
|
}).where(eq(heartbeatRuns.id, input.runId));
|
|
const [before] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId));
|
|
const observed = createDb(database.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
|
|
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
|
|
installDatabaseResponseCapture(observed, { heartbeatRuns }, capture);
|
|
try {
|
|
expect(await resolveGitHubOperationCredentials(observed, input)).toMatchObject({
|
|
status: "available", login: "A", grantId: selected.id,
|
|
});
|
|
capture.flush();
|
|
const reads = events.filter(event => event.table === "heartbeat_runs" && event.operation === "select");
|
|
expect(reads.length).toBeGreaterThan(0);
|
|
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(8192);
|
|
expect(JSON.stringify(events)).not.toContain("github-credential-saved-input");
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId)))[0]).toEqual(before);
|
|
const full = await captureRunIdentity(observed, input);
|
|
expect(full.run.runnerProfileJson).toEqual(before.runnerProfileJson);
|
|
expect(full.run.resultJson).toEqual(before.resultJson);
|
|
expect(full.run.contextSnapshot).toEqual(before.contextSnapshot);
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, selected.id));
|
|
expect(await resolveGitHubOperationCredentials(observed, input)).toMatchObject({ status: "unavailable" });
|
|
} finally { capture.flush(); await observed.$client.end(); }
|
|
});
|
|
|
|
async function switchTo(
|
|
input: Awaited<ReturnType<typeof seed>>,
|
|
user: string,
|
|
) {
|
|
const id = randomUUID();
|
|
await db.insert(issueComments).values({
|
|
id,
|
|
companyId: input.companyId,
|
|
issueId: input.issueId,
|
|
authorUserId: user,
|
|
body: "Next instruction",
|
|
});
|
|
const context = await reserveSteeredIdentity(db, {
|
|
...input,
|
|
messageId: id,
|
|
});
|
|
await acceptSteeredIdentity(db, context!);
|
|
}
|
|
it("resolves A → B → A without retaining tokens, and records only redacted diagnostics", async () => {
|
|
const input = await seed();
|
|
await grant(input, "A");
|
|
await grant(input, "B");
|
|
for (const user of ["A", "B", "A"]) {
|
|
await switchTo(input, user);
|
|
const result = await resolveGitHubOperationCredentials(db, input);
|
|
expect(result).toMatchObject({
|
|
status: "available",
|
|
login: user,
|
|
source: "personal",
|
|
});
|
|
expect(result.env.GH_TOKEN).toBe(`test-token-${user}`);
|
|
expect(result.env.GIT_AUTHOR_EMAIL).toBe(
|
|
`${user}+${user}@users.noreply.github.com`,
|
|
);
|
|
}
|
|
const history = await db
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(eq(runIdentityContexts.runId, input.runId));
|
|
expect(JSON.stringify(history)).not.toContain("test-token-");
|
|
});
|
|
it("returns no credential for unconnected users, removed membership, or ambiguous personal accounts", async () => {
|
|
const input = await seed();
|
|
await grant(input, "A");
|
|
await switchTo(input, "B");
|
|
expect((await resolveGitHubOperationCredentials(db, input)).env).toEqual(
|
|
{},
|
|
);
|
|
await switchTo(input, "A");
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ status: "inactive" })
|
|
.where(eq(companyMemberships.companyId, input.companyId));
|
|
expect((await resolveGitHubOperationCredentials(db, input)).status).toBe(
|
|
"unavailable",
|
|
);
|
|
await db
|
|
.update(companyMemberships)
|
|
.set({ status: "active" })
|
|
.where(eq(companyMemberships.companyId, input.companyId));
|
|
const differentAccount = await grant(input, "A");
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant: {
|
|
github: {
|
|
userId: "other-github-id",
|
|
login: "A",
|
|
installationCount: 1,
|
|
repositoryCount: 1,
|
|
repositorySelection: "selected",
|
|
installationIds: ["1"],
|
|
installationOwnerLogins: ["A"],
|
|
},
|
|
},
|
|
})
|
|
.where(eq(connectionGrants.id, differentAccount.id));
|
|
expect(
|
|
(await resolveGitHubOperationCredentials(db, input)).reason,
|
|
).toMatch(/More than one/);
|
|
await expect(
|
|
resolveGitHubOperationCredentials(db, {
|
|
...input,
|
|
companyId: randomUUID(),
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
it.each([true, false])(
|
|
"prefers the healthy duplicate regardless of grant age (%s)",
|
|
async (healthyNewer) => {
|
|
const input = await seed();
|
|
const healthy = await grant(input, "A");
|
|
const broken = await grant(input, "A");
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ healthStatus: "ok" })
|
|
.where(eq(toolConnections.id, healthy.connectionId));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
healthStatus: "error",
|
|
healthMessage: "GitHub access changed during refresh. Try again.",
|
|
})
|
|
.where(eq(toolConnections.id, broken.connectionId));
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
createdAt: new Date(healthyNewer ? "2026-02-01" : "2026-01-01"),
|
|
})
|
|
.where(eq(connectionGrants.id, healthy.id));
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
createdAt: new Date(healthyNewer ? "2026-01-01" : "2026-02-01"),
|
|
})
|
|
.where(eq(connectionGrants.id, broken.id));
|
|
expect(
|
|
await resolveGitHubOperationCredentials(db, input),
|
|
).toMatchObject({
|
|
status: "available",
|
|
connectionId: healthy.connectionId,
|
|
grantId: healthy.id,
|
|
authenticationMode: "managed",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("retries credential acquisition once using another grant for the same account", async () => {
|
|
const input = await seed();
|
|
const older = await grant(input, "A");
|
|
const newer = await grant(input, "A");
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ createdAt: new Date("2026-01-01") })
|
|
.where(eq(connectionGrants.id, older.id));
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ createdAt: new Date("2026-02-01") })
|
|
.where(eq(connectionGrants.id, newer.id));
|
|
vault.resolveUserSecretValue.mockRejectedValueOnce(
|
|
new Error("secret provider failed"),
|
|
);
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "available",
|
|
grantId: older.id,
|
|
});
|
|
});
|
|
|
|
it("uses one stable grant when the same person connects the same GitHub account twice", async () => {
|
|
const input = await seed();
|
|
const first = await grant(input, "A");
|
|
const second = await grant(input, "A");
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
createdAt: new Date("2026-01-01"),
|
|
updatedAt: new Date("2027-01-01"),
|
|
})
|
|
.where(eq(connectionGrants.id, first.id));
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ createdAt: new Date("2026-02-01") })
|
|
.where(eq(connectionGrants.id, second.id));
|
|
const context = { ...input, responsibleUserId: "A" };
|
|
expect(
|
|
(
|
|
await resolveManagedGitHubIdentitySelection(
|
|
db,
|
|
input.companyId,
|
|
context,
|
|
)
|
|
).grant?.id,
|
|
).toBe(second.id);
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "available",
|
|
login: "A",
|
|
source: "personal",
|
|
});
|
|
const connections = [first, second].map((row) => ({
|
|
id: row.connectionId,
|
|
config: { sourceTemplateKey: "github" },
|
|
}));
|
|
expect(
|
|
await filterResolvedGitHubConnectionsForRun({
|
|
db,
|
|
...context,
|
|
connections,
|
|
}),
|
|
).toEqual([connections[1]]);
|
|
// A newer webhook on the old connection must not change the selected policy.
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ updatedAt: new Date("2028-01-01") })
|
|
.where(eq(connectionGrants.id, first.id));
|
|
expect(
|
|
(
|
|
await resolveManagedGitHubIdentitySelection(
|
|
db,
|
|
input.companyId,
|
|
context,
|
|
)
|
|
).grant?.id,
|
|
).toBe(second.id);
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ status: "revoked" })
|
|
.where(eq(connectionGrants.id, second.id));
|
|
expect(
|
|
(
|
|
await resolveManagedGitHubIdentitySelection(
|
|
db,
|
|
input.companyId,
|
|
context,
|
|
)
|
|
).grant?.id,
|
|
).toBe(first.id);
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ enabled: false })
|
|
.where(eq(toolConnections.id, first.connectionId));
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "unavailable",
|
|
env: {},
|
|
});
|
|
});
|
|
it("does not conflate missing GitHub account IDs or another agent's connection audience", async () => {
|
|
const input = await seed();
|
|
const first = await grant(input, "A");
|
|
const duplicate = await grant(input, "A");
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ providerTenant: null })
|
|
.where(eq(connectionGrants.id, duplicate.id));
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "unavailable",
|
|
env: {},
|
|
});
|
|
await db
|
|
.update(toolConnectionInstalls)
|
|
.set({ targetId: randomUUID() })
|
|
.where(eq(toolConnectionInstalls.connectionId, duplicate.connectionId));
|
|
expect(
|
|
(
|
|
await resolveManagedGitHubIdentitySelection(db, input.companyId, {
|
|
...input,
|
|
responsibleUserId: "A",
|
|
})
|
|
).grant?.id,
|
|
).toBe(first.id);
|
|
await switchTo(input, "B");
|
|
expect((await resolveGitHubOperationCredentials(db, input)).env).toEqual(
|
|
{},
|
|
);
|
|
});
|
|
it.each([
|
|
"missing-ref",
|
|
"disabled-secret",
|
|
"missing-secret",
|
|
"wrong-owner",
|
|
"disabled-definition",
|
|
"no-repositories",
|
|
])(
|
|
"ignores an incomplete newer duplicate when the same account has an eligible grant (%s)",
|
|
async (problem) => {
|
|
const input = await seed();
|
|
const first = await grant(input, "A");
|
|
const second = await grant(input, "A");
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ createdAt: new Date("2026-01-01") })
|
|
.where(eq(connectionGrants.id, first.id));
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ createdAt: new Date("2026-02-01") })
|
|
.where(eq(connectionGrants.id, second.id));
|
|
if (problem === "missing-ref")
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ credentialSecretRefs: [] })
|
|
.where(eq(connectionGrants.id, second.id));
|
|
if (problem === "disabled-secret")
|
|
await db
|
|
.update(companySecrets)
|
|
.set({ status: "disabled" })
|
|
.where(eq(companySecrets.id, second.secretId));
|
|
if (problem === "disabled-definition")
|
|
await db
|
|
.update(userSecretDefinitions)
|
|
.set({ status: "disabled" })
|
|
.where(eq(userSecretDefinitions.id, second.definitionId));
|
|
if (problem === "missing-secret")
|
|
await db
|
|
.delete(companySecrets)
|
|
.where(eq(companySecrets.id, second.secretId));
|
|
if (problem === "wrong-owner")
|
|
await db
|
|
.update(companySecrets)
|
|
.set({ ownerUserId: "B" })
|
|
.where(eq(companySecrets.id, second.secretId));
|
|
if (problem === "no-repositories")
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant: {
|
|
github: {
|
|
userId: "A",
|
|
login: "A",
|
|
installationCount: 0,
|
|
repositoryCount: 0,
|
|
repositorySelection: "none",
|
|
installationIds: [],
|
|
installationOwnerLogins: [],
|
|
},
|
|
},
|
|
})
|
|
.where(eq(connectionGrants.id, second.id));
|
|
expect(
|
|
(
|
|
await resolveManagedGitHubIdentitySelection(db, input.companyId, {
|
|
...input,
|
|
responsibleUserId: "A",
|
|
})
|
|
).grant?.id,
|
|
).toBe(first.id);
|
|
expect(
|
|
await resolveGitHubOperationCredentials(db, input),
|
|
).toMatchObject({ status: "available", login: "A" });
|
|
const connections = [first, second].map((row) => ({
|
|
id: row.connectionId,
|
|
config: { sourceTemplateKey: "github" },
|
|
}));
|
|
expect(
|
|
await filterResolvedGitHubConnectionsForRun({
|
|
db,
|
|
...input,
|
|
responsibleUserId: "A",
|
|
connections,
|
|
}),
|
|
).toEqual([connections[0]]);
|
|
},
|
|
);
|
|
it("retains dedicated override semantics when the dedicated account has duplicate grants", async () => {
|
|
const input = await seed();
|
|
await grant(input, "A");
|
|
const first = await grant(input, "robot", true);
|
|
const second = await grant(input, "robot", true);
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "available",
|
|
source: "dedicated",
|
|
login: "robot",
|
|
});
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ status: "revoked" })
|
|
.where(eq(connectionGrants.id, first.id));
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "available",
|
|
source: "dedicated",
|
|
login: "robot",
|
|
});
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ status: "revoked" })
|
|
.where(eq(connectionGrants.id, second.id));
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "unavailable",
|
|
source: "dedicated",
|
|
env: {},
|
|
});
|
|
});
|
|
it("honors dedicated overrides and never substitutes personal credentials when revoked or disabled", async () => {
|
|
const input = await seed();
|
|
await grant(input, "A");
|
|
const dedicated = await grant(input, "robot", true);
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "available",
|
|
source: "dedicated",
|
|
login: "robot",
|
|
});
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ status: "revoked" })
|
|
.where(eq(connectionGrants.id, dedicated.id));
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "unavailable",
|
|
source: "dedicated",
|
|
env: {},
|
|
});
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ status: "active" })
|
|
.where(eq(connectionGrants.id, dedicated.id));
|
|
await db
|
|
.update(toolConnections)
|
|
.set({ enabled: false })
|
|
.where(eq(toolConnections.id, dedicated.connectionId));
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "unavailable",
|
|
source: "dedicated",
|
|
env: {},
|
|
});
|
|
});
|
|
it("does not resolve the company default person's GitHub", async () => {
|
|
const input = await seed();
|
|
await grant(input, "A");
|
|
await db
|
|
.update(runIdentityContexts)
|
|
.set({ cause: "company_default" })
|
|
.where(eq(runIdentityContexts.runId, input.runId));
|
|
expect((await resolveGitHubOperationCredentials(db, input)).env).toEqual(
|
|
{},
|
|
);
|
|
});
|
|
it.each([false, true])(
|
|
"withholds sponsor and dedicated credentials from every low-trust policy source (dedicated=%s)",
|
|
async (dedicated) => {
|
|
for (const source of [
|
|
"issue",
|
|
"run",
|
|
"agent",
|
|
"project",
|
|
"quarantined",
|
|
"invalid",
|
|
"missing_issue",
|
|
] as const) {
|
|
const input = await seed();
|
|
await grant(input, "A");
|
|
if (dedicated) await grant(input, "robot", true);
|
|
// Sponsor attribution is deliberately retained: it must not become a
|
|
// credential grant just because the shared agent was dispatched for them.
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
originKind: "chat_channel",
|
|
responsibleUserId: "A",
|
|
createdByUserId: "A",
|
|
})
|
|
.where(eq(issues.id, input.issueId));
|
|
const policy = {
|
|
authorizationPolicy: {
|
|
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
|
trustBoundary: {
|
|
mode: LOW_TRUST_REVIEW_PRESET,
|
|
companyId: input.companyId,
|
|
rootIssueId: input.issueId,
|
|
issueIds: [input.issueId],
|
|
allowedAgentIds: [input.agentId],
|
|
allowedToolClasses: ["git.read", "github.pr.read"],
|
|
},
|
|
},
|
|
};
|
|
if (source === "issue")
|
|
await db
|
|
.update(issues)
|
|
.set({ executionPolicy: policy })
|
|
.where(eq(issues.id, input.issueId));
|
|
if (source === "run")
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
contextSnapshot: {
|
|
issueId: input.issueId,
|
|
executionPolicy: policy,
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, input.runId));
|
|
if (source === "agent")
|
|
await db
|
|
.update(agents)
|
|
.set({ permissions: policy })
|
|
.where(eq(agents.id, input.agentId));
|
|
if (source === "project") {
|
|
const projectId = randomUUID();
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId: input.companyId,
|
|
name: "Restricted",
|
|
executionWorkspacePolicy: policy,
|
|
});
|
|
await db
|
|
.update(issues)
|
|
.set({ projectId })
|
|
.where(eq(issues.id, input.issueId));
|
|
}
|
|
if (source === "quarantined")
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
sourceTrust: {
|
|
preset: LOW_TRUST_REVIEW_PRESET,
|
|
disposition: "quarantined",
|
|
sourceIssueId: input.issueId,
|
|
},
|
|
})
|
|
.where(eq(issues.id, input.issueId));
|
|
if (source === "invalid")
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
contextSnapshot: {
|
|
issueId: input.issueId,
|
|
executionPolicy: {
|
|
authorizationPolicy: { trustPreset: "unknown" },
|
|
},
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, input.runId));
|
|
if (source === "missing_issue")
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ contextSnapshot: { issueId: randomUUID() } })
|
|
.where(eq(heartbeatRuns.id, input.runId));
|
|
vault.resolveSecretValue.mockClear();
|
|
vault.resolveUserSecretValue.mockClear();
|
|
expect(
|
|
await resolveGitHubOperationCredentials(db, input),
|
|
source,
|
|
).toMatchObject({
|
|
status: "unavailable",
|
|
env: {},
|
|
reason: expect.stringContaining("low-trust"),
|
|
});
|
|
expect(vault.resolveSecretValue, source).not.toHaveBeenCalled();
|
|
expect(vault.resolveUserSecretValue, source).not.toHaveBeenCalled();
|
|
const [history] = await db
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(eq(runIdentityContexts.runId, input.runId));
|
|
expect(history.github, source).toMatchObject({
|
|
status: "unavailable",
|
|
});
|
|
expect(JSON.stringify(history), source).not.toContain("test-token-");
|
|
}
|
|
},
|
|
);
|
|
it("rechecks a taskless run's current project policy before exporting credentials", async () => {
|
|
const input = await seed();
|
|
await grant(input, "A");
|
|
const projectId = randomUUID();
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId: input.companyId,
|
|
name: "Taskless project",
|
|
});
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ contextSnapshot: { projectId } })
|
|
.where(eq(heartbeatRuns.id, input.runId));
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "available",
|
|
login: "A",
|
|
});
|
|
await db
|
|
.update(projects)
|
|
.set({
|
|
executionWorkspacePolicy: {
|
|
authorizationPolicy: {
|
|
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
|
trustBoundary: {
|
|
mode: LOW_TRUST_REVIEW_PRESET,
|
|
companyId: input.companyId,
|
|
projectIds: [projectId],
|
|
allowedAgentIds: [input.agentId],
|
|
},
|
|
},
|
|
},
|
|
})
|
|
.where(eq(projects.id, projectId));
|
|
vault.resolveSecretValue.mockClear();
|
|
vault.resolveUserSecretValue.mockClear();
|
|
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
|
|
status: "unavailable",
|
|
env: {},
|
|
});
|
|
expect(vault.resolveSecretValue).not.toHaveBeenCalled();
|
|
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed for missing or malformed bound task/project references", async () => {
|
|
const input = await seed();
|
|
await grant(input, "A");
|
|
for (const contextSnapshot of [
|
|
{ projectId: randomUUID() },
|
|
{ projectId: "" },
|
|
{ projectId: false },
|
|
{ issueId: "" },
|
|
{ issueId: false },
|
|
]) {
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ contextSnapshot })
|
|
.where(eq(heartbeatRuns.id, input.runId));
|
|
vault.resolveUserSecretValue.mockClear();
|
|
if (Object.hasOwn(contextSnapshot, "issueId")) {
|
|
await expect(
|
|
resolveGitHubOperationCredentials(db, input),
|
|
).rejects.toMatchObject({
|
|
status: 403,
|
|
message: "Run task identity is invalid",
|
|
});
|
|
} else {
|
|
expect(
|
|
await resolveGitHubOperationCredentials(db, input),
|
|
).toMatchObject({ status: "unavailable", env: {} });
|
|
}
|
|
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("reuses a session broker across runs while rechecking live-run identity and revocation", async () => {
|
|
const input = await seed();
|
|
await grant(input, "A");
|
|
await grant(input, "B");
|
|
const broker = await createNativeGitHubAccess({
|
|
scope: input, target: null, cwd: process.cwd(), env: { PATH: process.env.PATH },
|
|
resolveCredentials: (binding) => resolveGitHubOperationCredentials(db, binding),
|
|
});
|
|
const post = () => fetch(`${broker.env.PAPERCLIP_GITHUB_BROKER_URL}/runtime-tools/github/credentials`, {
|
|
method: "POST", headers: { authorization: `Bearer ${broker.env.PAPERCLIP_GITHUB_BRIDGE_TOKEN}` },
|
|
});
|
|
try {
|
|
const releaseA = broker.activate(input);
|
|
const a = await post();
|
|
expect(a.status).toBe(200);
|
|
expect((await a.json()).login).toBe("A");
|
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, input.runId));
|
|
// Even a delayed controller release cannot authorize a finished DB run.
|
|
expect((await post()).status).toBe(403);
|
|
releaseA();
|
|
const next = { ...input, runId: randomUUID() };
|
|
await db.insert(heartbeatRuns).values({ id: next.runId, companyId: next.companyId, agentId: next.agentId, status: "running", contextSnapshot: { issueId: input.issueId } });
|
|
await initializeRunIdentity(db, { companyId: input.companyId, runId: next.runId, responsibleUserId: "B", cause: "instruction" });
|
|
broker.activate(next);
|
|
const b = await post();
|
|
expect(b.status).toBe(200);
|
|
expect((await b.json()).login).toBe("B");
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.companyId, input.companyId));
|
|
const revoked = await post();
|
|
expect((await revoked.json()).env).toEqual({});
|
|
} finally { await broker.stop(); }
|
|
});
|
|
|
|
it("requires a run-scoped runtime capability and never accepts browser authentication or supplied identities", async () => {
|
|
const input = await seed();
|
|
await grant(input, "A");
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(runtimeConnectionIntentRoutes(db));
|
|
app.use(errorHandler);
|
|
const tokenInput = {
|
|
...input,
|
|
responsibleUserId: "A",
|
|
scope: "github_credentials" as const,
|
|
};
|
|
const token = createRuntimeToolsToken(tokenInput)!.token;
|
|
const post = () => request(app).post("/runtime-tools/github/credentials");
|
|
const a = await post()
|
|
.set("Authorization", `Bearer ${token}`)
|
|
.send({ responsibleUserId: "B" });
|
|
expect(
|
|
(
|
|
await post()
|
|
.set("Authorization", `Bearer ${token}`)
|
|
.set("Sec-Fetch-Mode", "cors")
|
|
).status,
|
|
).toBe(200);
|
|
expect(a.status).toBe(200);
|
|
expect(a.body.login).toBe("A");
|
|
expect(a.headers["cache-control"]).toBe("no-store");
|
|
for (const [header, value] of [
|
|
["Origin", "http://127.0.0.1"],
|
|
["Cookie", "session=test"],
|
|
["Sec-Fetch-Site", "same-origin"],
|
|
]) {
|
|
expect(
|
|
(
|
|
await post()
|
|
.set("Authorization", `Bearer ${token}`)
|
|
.set(header!, value!)
|
|
).status,
|
|
).toBe(403);
|
|
}
|
|
const wrongScope = createRuntimeToolsToken({
|
|
...tokenInput,
|
|
scope: "connection_intents",
|
|
})!.token;
|
|
expect(
|
|
(await post().set("Authorization", `Bearer ${wrongScope}`)).status,
|
|
).toBe(401);
|
|
const wrongAgent = createRuntimeToolsToken({
|
|
...tokenInput,
|
|
agentId: randomUUID(),
|
|
})!.token;
|
|
expect(
|
|
(await post().set("Authorization", `Bearer ${wrongAgent}`)).status,
|
|
).toBe(403);
|
|
// The runner bridge replaces Authorization, but forwards the separate run capability.
|
|
expect(
|
|
(
|
|
await post()
|
|
.set("Authorization", "Bearer bridge-host-token")
|
|
.set("x-paperclip-github-capability", token)
|
|
).status,
|
|
).toBe(200);
|
|
await switchTo(input, "B");
|
|
const b = await post().set("Authorization", `Bearer ${token}`);
|
|
expect(b.status).toBe(200);
|
|
expect(b.body.env).toEqual({});
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "succeeded" })
|
|
.where(eq(heartbeatRuns.id, input.runId));
|
|
expect(
|
|
(await post().set("Authorization", `Bearer ${token}`)).status,
|
|
).toBe(403);
|
|
});
|
|
},
|
|
);
|