mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## 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>
879 lines
35 KiB
TypeScript
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);
|
|
});
|
|
},
|
|
);
|