Files
PaperClipAI/server/src/__tests__/github-operation-credentials.test.ts
T
DottaandPaperclip 5f0da562db fix(server): wake chat lifecycle milestones from committed changes (#17606)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connected chats show safe notices when a run starts, waits,
finishes, or fails.
> - A recurring scan projected these notices even when no eligible work
existed.
> - Eligibility depends on committed run state, conversation lineage,
and current authority.
> - Existing database rows can reconstruct that work after restart.
> - This PR wakes a scoped worker from those writes and removes
lifecycle polling.
> - Native progress events keep their separate existing owner until the
next migration.

## Linked Issues or Issue Description

**What happened?**

The chat maintenance timer repeatedly scanned run lifecycle state. Empty
and settled instances still queried the database. A run-status event
alone cannot replace the scan: prompt receipts, continuation receipts,
identity changes, and restored authority can make a run eligible later.

**What did you expect?**

Committed changes wake projection. Startup reconstructs eligible work. A
completed pass stops until another relevant change arrives. Failed work
retains recovery without scanning settled history forever.

**Steps to reproduce**

Configure an automatic chat endpoint. Finish its work, or leave it
unused. Observe recurring lifecycle projection queries from the chat
maintenance callback.

Stacked on #17592. Related foundation: #15625. This PR owns lifecycle
milestones only.

## What Changed

- Preserve upstream coalesced Cloud membership writes together with
locked membership restoration and its committed chat notification.

- Signal milestone eligibility from the real Slack account-link
transaction.

- Register the lifecycle worker with the existing delivery coordinator.
Remove lifecycle projection from the periodic chat callback. Keep native
progress in a separately named lane and preserve the explicit combined
API.
- Reconstruct company/issue scopes from the existing selector. Drain
bounded batches with company fairness. Retry failures, retain writes
through drain/resume, and disarm settled work.
- Signal from supported run, presentation, interaction, receipt,
lineage, conversation, recovery, and authority writers. Preserve their
transaction ownership, including nested savepoints and uncertain
settlement. No new table is added.
- Preserve provider authorization, authored-final suppression,
responsible-user continuity, deduplication, the 24-hour selection
window, and raw-output restrictions. Keep provider interpretation in its
owner.
- Preserve PostgreSQL timestamp precision in lifecycle pagination.
Rejected submillisecond candidates must not repeat the same page and
starve later notices.
- Document producer coverage and alternate-writer dispositions in
`doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md`.

## Verification

Published head: `1d056f68c5b1005933b9e12de8745bf1c4cbeeca`, targeting
master after #17592 merged at
`7d75ae0569e41786640cf09647ae8849411d2f22`. A normal merge integrates
the actual landed parent. The entire 77-path milestone patch is
binary-identical to the locally verified preparation across that
ancestry reconciliation; both unrelated master paths are preserved. The
four earlier producer/test integration conflicts retain scoped milestone
and voice notices, current onboarding completion admission, and expanded
identity query/read-scope coverage.

- **330 distinct selected cases pass on the verified unchanged patch:**
305 across 14 complete suites (all five milestone worker suites, run
identity, board claim, Cloud provisioning, auth session routes, Cloud
actor authentication, stale-lock recovery, GitHub credentials, project
Git authorization, and app admission wiring), plus 25 selected real chat
producer cases. The remaining 1,590 chat integration cases were skipped,
not passed.
- The real producer cases cover saved milestone discovery, GitHub and
Slack identity changes, account-link rollback/reconfirmation, ordinary
ingress reactivation, post-close action/delivery receipt settlement, all
ten authority-restoration paths including Cloud synchronization, and
changed native review metadata.
- Worker coverage verifies outer commit, rollback, uncertain-write
recovery, restart, bounded batching/fairness, timestamp precision,
retries, drain/resume, and settled SQL quiet. Authorization and existing
publication deduplication remain with their owning services.
- Server production typechecking, build, and module-boundary checks
pass. The tests above were executed separately. Production typechecking
excludes `src/__tests__` and does not replace those runs. Each database
owner uses its own newly created and app-migrated PostgreSQL fixture.
- [Current-parent acceptance and preserved failure
ledger](https://github.com/paperclipai/paperclip/blob/1d056f68c5b1005933b9e12de8745bf1c4cbeeca/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md#current-parent-integration-and-acceptance-october-28)
records commands, counts, producer scope, conflict dispositions, and
limits. Earlier combined-stack counts and failures keep their original
revision scope; local acceptance does not erase old CI failures.
- No new migration, queue table, dependency, workflow, or shared
scheduler contract is introduced. Parent migration 0397, bounded consent
recovery, voice cleanup/retention repairs, and runner checkpoint
restoration remain present.
- **Exact-head Greptile is 5/5 and all 55 current checks are clean.** No
Ponytail review is present, which is an explicitly authorized
nonblocker; no failing review is waived. Older reviews and parent checks
are not current passes. Actual failing Ponytail findings must be fixed;
an absent/stopped/paused turn without a verdict is an authorized
nonblocker. Active unfinished Ponytail uses its original request/start
timer, with a 25-minute monitor cutoff respecting the user's 30-minute
maximum.
- No complete local repository matrix, live provider tenant, external
alarm, physical host/database suspension, or fleet savings claim is
made. Native progress remains a separate following slice.

## Risks

- A future eligibility writer must notify the existing projection
boundary. Independent SQL writers or another active server process are
outside this process-local signal contract.
- Authority and lineage remain sensitive. The worker uses the existing
selectors and publication policy; it does not grant execution or
disclosure authority.
- Native progress and other publication maintenance still have recurring
work. This PR removes one reason, not the entire chat maintenance timer.
- Worker quiet does not prove database suspension, host sleep, or
external alarm coverage. Cloud sleep blockers remain unchanged.
- Merge eligibility requires passing current-head CI and Greptile 5/5
after this actual-master reconciliation. Subsequent stack slices retain
their own gates.

## Model Used

OpenAI Codex, GPT-6 family. The exact serving model ID and
context-window size are not exposed. Used repository inspection,
reasoning, shell execution, PostgreSQL tests, browser E2Es and GitHub
tools.

## 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 available in this session)
- [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 the documented focused 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>
2026-10-28 11:07:54 -05:00

879 lines
35 KiB
TypeScript

import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { githubCredentialsFixture } from "./helpers/github-credentials-fixture.js";
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,
} 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 external = resolveExternalTestDatabaseUrl("PAPERCLIP_GITHUB_CREDENTIAL_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)(
"operation-time GitHub credential resolution",
() => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined,
db: ReturnType<typeof createDb>;
beforeAll(async () => {
vi.stubEnv(
"PAPERCLIP_AGENT_JWT_SECRET",
"test-github-broker-signing-secret",
);
if (!external) database = await startEmbeddedPostgresTestDatabase(
"paperclip-github-operation-",
);
db = createDb(external ?? database!.connectionString);
}, 30_000);
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await database?.cleanup();
vi.unstubAllEnvs();
}, 60_000);
const { seed, grant, switchTo } = githubCredentialsFixture(() => db);
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(external ?? 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(external ?? 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(); }
});
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("writes the GitHub summary only when it changes", async () => {
const input = await seed();
const selected = await grant(input, "A");
const update = vi.spyOn(db, "update");
const summaryWrites = () => update.mock.calls.filter(([table]) => table === runIdentityContexts).length;
const stored = async () => (await db.select().from(runIdentityContexts)
.where(eq(runIdentityContexts.runId, input.runId)))[0].github;
try {
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({ status: "available", grantId: selected.id });
expect(summaryWrites()).toBe(1);
expect(await stored()).toMatchObject({ status: "available", login: "A", grantId: selected.id });
for (let call = 0; call < 3; call++) {
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({ status: "available", grantId: selected.id });
}
expect(summaryWrites()).toBe(1);
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, selected.id));
for (let call = 0; call < 2; call++) {
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({ status: "unavailable" });
}
expect(summaryWrites()).toBe(2);
expect(await stored()).toMatchObject({ status: "unavailable" });
await db.update(issues).set({ executionPolicy: { 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"] } } } })
.where(eq(issues.id, input.issueId));
for (let call = 0; call < 2; call++) {
expect(await resolveGitHubOperationCredentials(db, input)).toMatchObject({
status: "unavailable", reason: expect.stringContaining("low-trust"),
});
}
expect(summaryWrites()).toBe(3);
expect(await stored()).toMatchObject({ status: "unavailable", reason: expect.stringContaining("low-trust") });
} finally {
update.mockRestore();
}
});
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);
});
},
);