mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 16:35:27 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - The native Runner keeps a live provider process between task turns. > - Managed GitHub access used a token tied to one run. > - A new run forced Paperclip to replace that process to replace its token. > - This PR gives the session a stable credential transport and binds each operation to the active run. > - The agent can keep its process while Paperclip checks current identity and grants. ## Linked Issues or Issue Description Follow-up to #13738. Related credential-rotation work: #11770 and #8208 use process replacement for other adapter credentials; this change applies to managed GitHub access in the native Runner. **What happened?** A configured GitHub connection forced a warm native provider process to close at each new run. The saved conversation survived, but the live process did not. **Expected behavior** Keep the warm provider process. Resolve GitHub access for the current run when each command starts. Deny access while idle or after the run ends. **Steps to reproduce** 1. Configure managed GitHub access for a native Runner agent with a warm session. 2. Complete a turn, then send another message to the same task. 3. Observe the provider process close with the reason `warm native session configuration changed`. **Paperclip version or commit** Reproduced on master `8326e33ad`. Rebased onto `e3d8fb087` before submission. **Deployment mode** Local and remote native execution, including the sandbox callback bridge. ## What Changed - Move configured native GitHub transport and launcher ownership from the run to the provider session. - Bind the broker only after the executor acquires session ownership. Clear that binding when the run exits. - Keep the shared live-run, identity, grant, and trust-policy checks for each credential request. - Reject wrong scopes, idle requests, and credential responses that arrive after their run binding changes. - Retire transport and launcher files with the provider session. Keep anonymous commands available if bridge startup fails. - Add red/green executor tests, real subprocess and callback-bridge tests, and database checks. Update the runtime documentation. ## Verification - Before the fix, both new local and remote warm-session reuse tests failed. - After the fix, 435 targeted tests passed across the executor, broker, launcher, token, and database suites. - A real long-lived test process kept the same PID and original environment across two runs, including through the production callback bridge on local test processes. - Server typecheck and TypeScript compilation passed. - Full workspace typecheck and build passed. Server typecheck passed again after the review fix. - The fallback-logging regression failed before the fix; all 9 broker tests pass afterward. - The exact chat sidebar browser scenario passed locally. The initial CI timeout showed failed Vite module downloads; all eight browser shards pass on the latest commit. - All 53 latest-head checks passed, including the full CI test matrix and security checks (two unrelated conditional checks skipped). - The duplicate full local test run was stopped after CI passed; it is not claimed as a completed local pass. Targeted local tests, workspace typecheck/build, and the browser scenario passed. - Greptile reviewed the latest commit at 5/5 with no unresolved findings. - No fresh paid provider or Daytona campaign has run for this change. ## Risks - The broker now lives as long as the provider session. Tests cover idle denial, late cleanup, late responses, shutdown, and failed startup. - Its in-memory authority does not survive a controller restart. Existing checkpoint and process-recovery rules still apply. - Raw GitHub credentials remain confined to individual command processes. The session transport token cannot select a different task, agent, company, or run. - No database migration or public API change. ## Model Used OpenAI Codex, GPT-6, with reasoning, terminal tools, and code execution. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
913 lines
31 KiB
TypeScript
913 lines
31 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 { 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,
|
|
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 };
|
|
}
|
|
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);
|
|
});
|
|
},
|
|
);
|