diff --git a/DESIGN.md b/DESIGN.md index 9a23d4446b8..0a1e5b5adf6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -86,6 +86,15 @@ in the conversation. Time passing or a new human comment alone does not resolve an error. Stored notices need run or recovery provenance before they can be hidden; child-task relays and other unrelated system updates stay visible. +Credential-sharing denials stay expanded as an error card in the task conversation. +Name the agent and selected AI connection when known, and say which person lacks +access. Use “you” only for that person’s own run. Link to the agent’s AI connection +settings; retrying before changing the connection cannot repair a sharing denial. +Show the connection name to the affected user so they can identify what blocked +their task, and to viewers in the connection's human sharing audience. Other task +readers see a generic credential label. Never show credential secrets or grant IDs. +The same run-relevance rules hide the card after a newer attempt or terminal task. + Do not show a toast for task or run state already visible on the current screen. This includes descendant runs represented by the open subtree. Show local action results in place; keep failures actionable inline. Notifications for other work diff --git a/server/src/__tests__/activity-credential-access.test.ts b/server/src/__tests__/activity-credential-access.test.ts new file mode 100644 index 00000000000..ccb6d64bb6b --- /dev/null +++ b/server/src/__tests__/activity-credential-access.test.ts @@ -0,0 +1,64 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, connectionGrants, connectionGrantMembers, createDb, heartbeatRuns, issues, toolApplications, toolConnections } from "@paperclipai/db"; +import { activityService } from "../services/activity.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const suite = support.supported ? describe : describe.skip; +suite("task credential-access run summary", () => { + let db: ReturnType; + let temporaryDb: Awaited>; + beforeAll(async () => { + temporaryDb = await startEmbeddedPostgresTestDatabase("credential-access-summary-"); + db = createDb(temporaryDb.connectionString); + }, 60_000); + afterAll(async () => { await temporaryDb?.cleanup(); }); + it("projects only bounded credential-access display metadata in task runs", async () => { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Credential fixture", issuePrefix: `C${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Codie", role: "engineer", status: "idle", adapterType: "codex_local" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Credential denial", status: "blocked", assigneeAgentId: agentId }); + const applicationId = randomUUID(), connectionId = randomUUID(), grantId = randomUUID(); + await db.insert(toolApplications).values({ id: applicationId, companyId, name: "AI fixture", type: "rest_api" }); + await db.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: "Dotta’s API Key", uid: "credential-fixture", connectionPurpose: "ai", transport: "runtime_auth" }); + await db.insert(connectionGrants).values({ id: grantId, companyId, connectionId, kind: "user", subjectUserId: "dotta" }); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, scopeKind: "issue", issueId, + responsibleUserId: "nicky", + status: "failed", errorCode: "configuration_incomplete", error: "This credential is not shared with the responsible user", + resultJson: { configurationIncomplete: { selectionFailure: "ai_connection_credential_not_shared", + credentialAccess: { connectionName: "Dotta’s API Key".repeat(30), grantId, secretValue: "must-not-leak" }, + fingerprint: "must-not-leak", unrelated: "must-not-leak" } }, + }); + const service = activityService(db); + const runs = await service.runsForIssue(companyId, issueId, "nicky"); + expect(runs[0]?.error).toBe("This credential is not shared with the responsible user"); + expect(runs[0]?.resultJson).toEqual({ configurationIncomplete: { selectionFailure: "ai_connection_credential_not_shared", + credentialAccess: { connectionName: "Dotta’s API Key".repeat(30).slice(0, 240) } } }); + expect((await service.runsForIssue(companyId, issueId, "dotta"))[0]?.resultJson).toEqual(runs[0]?.resultJson); + const hidden = { configurationIncomplete: { selectionFailure: "ai_connection_credential_not_shared", credentialAccess: {} } }; + expect((await service.runsForIssue(companyId, issueId, "another-reader"))[0]?.resultJson).toEqual(hidden); + expect((await service.runsForIssue(companyId, issueId))[0]?.resultJson).toEqual(hidden); + + // Organization credentials follow the same audience rules as Connections. + await db.update(connectionGrants).set({ kind: "organization", subjectUserId: null }).where(eq(connectionGrants.id, grantId)); + expect((await service.runsForIssue(companyId, issueId, "another-reader"))[0]?.resultJson).toEqual(runs[0]?.resultJson); + await db.insert(connectionGrantMembers).values({ companyId, grantId, subjectType: "user", subjectId: "dotta" }); + expect((await service.runsForIssue(companyId, issueId, "dotta"))[0]?.resultJson).toEqual(runs[0]?.resultJson); + expect((await service.runsForIssue(companyId, issueId, "another-reader"))[0]?.resultJson).toEqual(hidden); + + // A resumed native execution keeps its diagnostic name without acquiring + // the fresh-execution selection-failure marker used by recovery. + await db.update(heartbeatRuns).set({ resultJson: { configurationIncomplete: { + credentialAccess: { connectionName: "Dotta’s API Key", grantId }, + } } }).where(eq(heartbeatRuns.id, runId)); + expect((await service.runsForIssue(companyId, issueId, "nicky"))[0]?.resultJson).toEqual({ + configurationIncomplete: { credentialAccess: { connectionName: "Dotta’s API Key" } }, + }); + await db.update(heartbeatRuns).set({ error: null, resultJson: { configurationIncomplete: { selectionFailure: "ai_connection_unavailable", credentialAccess: { connectionName: "hidden" } } } }).where(eq(heartbeatRuns.id, runId)); + expect((await service.runsForIssue(companyId, issueId, "nicky"))[0]?.resultJson).toEqual({}); + }); + +}); diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 796815389c3..76671447903 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -268,7 +268,7 @@ describe("activity routes", () => { expect(res.status).toBe(200); expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-475"); expect(mockIssueService.getById).not.toHaveBeenCalled(); - expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); + expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1", "user-1"); expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); }); diff --git a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts index 94dbd7e0929..32df5430679 100644 --- a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts +++ b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts @@ -10,11 +10,14 @@ import { authUsers, companies, companyMemberships, + connectionGrants, createDb, heartbeatRunEvents, heartbeatRuns, issueAccessGrants, issues, + toolApplications, + toolConnections, workspaceOperations, } from "@paperclipai/db"; import { @@ -316,6 +319,35 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false }, } }); + it("filters credential diagnostics in full-run and history responses by viewer", async () => { + const fixture = await seedFixture(); + const readerId = `reader-${randomUUID()}`; + await db.insert(authUsers).values({ id: readerId, name: "Task reader", email: `${readerId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "user", principalId: readerId, status: "active", membershipRole: "operator" }); + await db.update(issues).set({ visibility: "open", privacyRootIssueId: null }).where(eq(issues.id, fixture.issueId)); + const applicationId = randomUUID(), connectionId = randomUUID(), grantId = randomUUID(); + await db.insert(toolApplications).values({ id: applicationId, companyId: fixture.companyId, name: "AI fixture", type: "rest_api" }); + await db.insert(toolConnections).values({ id: connectionId, companyId: fixture.companyId, applicationId, name: "Owner's API key", uid: "credential-fixture", connectionPurpose: "ai", transport: "runtime_auth" }); + await db.insert(connectionGrants).values({ id: grantId, companyId: fixture.companyId, connectionId, kind: "user", subjectUserId: fixture.ownerUserId }); + await db.update(heartbeatRuns).set({ status: "failed", responsibleUserId: fixture.otherUserId, + errorCode: "configuration_incomplete", error: "This credential is not shared with the responsible user", + resultJson: { configurationIncomplete: { selectionFailure: "ai_connection_credential_not_shared", + credentialAccess: { connectionName: "Owner's API key", grantId } } }, + }).where(eq(heartbeatRuns.id, fixture.privateRunId)); + for (const userId of [fixture.ownerUserId, fixture.otherUserId, readerId]) { + const app = createBoardApp(fixture.companyId, userId); + const detail = await request(app).get(`/api/heartbeat-runs/${fixture.privateRunId}`); + const history = await request(app).get(`/api/companies/${fixture.companyId}/heartbeat-runs`); + expect(detail.status).toBe(200); + expect(history.status).toBe(200); + const expected = userId === readerId ? {} : { connectionName: "Owner's API key" }; + expect(detail.body.resultJson.configurationIncomplete.credentialAccess).toEqual(expected); + expect(JSON.stringify(detail.body)).not.toContain(grantId); + expect(JSON.stringify(history.body)).not.toContain(grantId); + if (userId === readerId) expect(JSON.stringify(history.body)).not.toContain("Owner's API key"); + } + }); + it("returns no linked issue metadata to a non-member", async () => { const fixture = await seedFixture(); const response = await request(createApp(fixture.companyId, fixture.otherAgentId)) diff --git a/server/src/__tests__/run-secret-redaction.test.ts b/server/src/__tests__/run-secret-redaction.test.ts index a4f73bf15ca..8391b09574c 100644 --- a/server/src/__tests__/run-secret-redaction.test.ts +++ b/server/src/__tests__/run-secret-redaction.test.ts @@ -7,6 +7,14 @@ import { createRunSecretRedactionRegistry, redactRegisteredSecretValues } from " const secret = "q2a-exact-secret-value"; describe("registered run secret redaction", () => { + it("strips private credential fields and denies names unless the caller supplies an authorized projection", () => { + const input = { resultJson: { configurationIncomplete: { + credentialAccess: { connectionName: "private name", grantId: "private grant", secret: "private value" }, + } } }; + expect(redactRegisteredSecretValues(input, []).resultJson.configurationIncomplete.credentialAccess).toEqual({}); + expect(redactRegisteredSecretValues(input, [], "Visible name").resultJson.configurationIncomplete.credentialAccess).toEqual({ connectionName: "Visible name" }); + expect(redactRegisteredSecretValues(input, ["Visible name"], "Visible name").resultJson.configurationIncomplete.credentialAccess).toEqual({ connectionName: REDACTED_EVENT_VALUE }); + }); it("redacts exact values across comment and heartbeat/wake projections", () => { const result = redactRegisteredSecretValues({ comment: { body: `agent pasted ${secret} in a comment` }, diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 2dae3d31b9e..f76311d0586 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db"; import { normalizeIssueIdentifier } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { activityService, normalizeActivityLimit } from "../services/activity.js"; -import { assertAuthenticated, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js"; +import { assertAuthenticated, assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; import { accessService, heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; import { badRequest, forbidden } from "../errors.js"; @@ -355,7 +355,8 @@ export function activityRoutes(db: Db) { const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found"); if (!issue) return; if (!(await assertIssueReadAllowed(req, res, issue))) return; - const result = await svc.runsForIssue(issue.companyId, issue.id); + const result = await svc.runsForIssue(issue.companyId, issue.id, + req.actor.type === "board" ? getActorInfo(req).actorId : null); res.json(result); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 23a29360729..e481c661e27 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -6614,7 +6614,8 @@ export function agentRoutes( const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined; const summary = req.query.summary === "true" || req.query.summary === "1"; const runs = await heartbeat.list(companyId, agentId, limit, { summary }); - res.json(await runRedactions.redactForRuns(companyId, await Promise.all(runs.map(run => serializeRunListRow(req, run))))); + res.json(await runRedactions.redactForRuns(companyId, await Promise.all(runs.map(run => serializeRunListRow(req, run))), + req.actor.type === "board" ? getActorInfo(req).actorId : null)); }); router.get("/companies/:companyId/provider-traces", async (req, res) => { @@ -6756,7 +6757,7 @@ export function agentRoutes( avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), execution: projections.get(run.id) ?? null, outputSilence: await heartbeat.buildRunOutputSilence(run), - }))))); + }))), req.actor.type === "board" ? getActorInfo(req).actorId : null)); }); function readHeartbeatRunId(req: Request): string { @@ -6783,6 +6784,7 @@ export function agentRoutes( { ...decoratedRun, execution: await executionProjectionForRun(db, run.companyId, run.id), identityHistory: await listRunIdentityContexts(db, run.companyId, run.id), retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) }, await getCurrentUserRedactionOptions(), ), + req.actor.type === "board" ? getActorInfo(req).actorId : null, )); }); @@ -6816,7 +6818,8 @@ export function agentRoutes( }); } - res.json(run); + res.json(run ? await runRedactions.redactForRun(run.companyId, run.id, run, + getActorInfo(req).actorId) : null); }); router.post( diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index 4130ed2718c..81c2ebdc1a6 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -1,3 +1,4 @@ +import { credentialAccessConnectionNameSql } from "./credential-access-visibility.js"; import { executionProjectionsForRuns } from "./execution-projection.js"; import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -84,13 +85,26 @@ export function activityService(db: Db) { )) end `.as("usageJson"); - const summarizedResultJson = sql | null>` + // A denied user needs the selected connection's name to repair their task. + // Other readers need the connection's existing human sharing permission. + const summarizedResultJson = (viewerUserId: string | null) => sql | null>` case when ${heartbeatRuns.resultJson} is null then null else jsonb_strip_nulls(jsonb_build_object( 'conversationReset', ${heartbeatRuns.resultJson} -> 'conversationReset', - 'configurationIncomplete', case when ${heartbeatRuns.resultJson} -> 'configurationIncomplete' ->> 'reason' = 'workspace_base_ref_unresolved' - then jsonb_build_object('reason', 'workspace_base_ref_unresolved') end, + 'configurationIncomplete', case + when ${heartbeatRuns.resultJson} -> 'configurationIncomplete' ->> 'reason' = 'workspace_base_ref_unresolved' + then jsonb_build_object('reason', 'workspace_base_ref_unresolved') + when ${heartbeatRuns.errorCode} = 'configuration_incomplete' + and (${heartbeatRuns.resultJson} #>> '{configurationIncomplete,selectionFailure}' = 'ai_connection_credential_not_shared' + or ${heartbeatRuns.error} = 'This credential is not shared with the responsible user') + then jsonb_build_object( + 'selectionFailure', case when ${heartbeatRuns.resultJson} #>> '{configurationIncomplete,selectionFailure}' = 'ai_connection_credential_not_shared' + then 'ai_connection_credential_not_shared' end, + 'credentialAccess', jsonb_build_object( + 'connectionName', ${credentialAccessConnectionNameSql(viewerUserId)} + ) + ) end, 'workspaceRestoreFailure', case when ${heartbeatRuns.resultJson} ->> 'workspaceRestoreFailure' in ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed') then ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure' end, @@ -391,7 +405,7 @@ export function activityService(db: Db) { ) .orderBy(desc(activityLog.createdAt)), - runsForIssue: async (companyId: string, issueId: string) => { + runsForIssue: async (companyId: string, issueId: string, viewerUserId: string | null = null) => { scheduleRunLivenessBackfill(companyId, issueId); const runs = await db .select({ @@ -408,9 +422,12 @@ export function activityService(db: Db) { errorCode: heartbeatRuns.errorCode, error: sql`case when ${heartbeatRuns.status} = 'failed' and ${heartbeatRuns.errorCode} = 'native_provider_model_rejected' - then left(${heartbeatRuns.error}, 2000) else null end`, + then left(${heartbeatRuns.error}, 2000) + when ${heartbeatRuns.status} = 'failed' + and ${heartbeatRuns.error} = 'This credential is not shared with the responsible user' + then 'This credential is not shared with the responsible user' else null end`, usageJson: summarizedUsageJson, - resultJson: summarizedResultJson, + resultJson: summarizedResultJson(viewerUserId), logBytes: heartbeatRuns.logBytes, retryOfRunId: heartbeatRuns.retryOfRunId, scheduledRetryAt: heartbeatRuns.scheduledRetryAt, diff --git a/server/src/services/ai-connection-configuration-failure.test.ts b/server/src/services/ai-connection-configuration-failure.test.ts index 11ec55fb26b..95bba3f42b7 100644 --- a/server/src/services/ai-connection-configuration-failure.test.ts +++ b/server/src/services/ai-connection-configuration-failure.test.ts @@ -5,9 +5,16 @@ import { aiConnectionCredentialNotSharedFailure, isAiConnectionConfigurationReason, readAiConnectionConfigurationFailure, + readAiCredentialAccessFailure, } from "./ai-connection-configuration-failure.js"; describe("AI selection failure provenance", () => { + it("keeps the credential display name out of the public 403 while retaining run diagnostics", () => { + const error = aiConnectionCredentialNotSharedFailure({ connectionName: "Dotta’s API Key", grantId: "private-grant" }); + expect(error.details).toBeUndefined(); + expect(readAiCredentialAccessFailure(error)).toEqual({ connectionName: "Dotta’s API Key", grantId: "private-grant" }); + expect(readAiCredentialAccessFailure(forbidden(error.message))).toBeNull(); + }); it("preserves HTTP behavior while retaining only an owned bounded reason", () => { const error = aiConnectionConfigurationFailure("ai_connection_default_missing", "Choose an account", { connectionId: "synthetic-connection" }); expect(error).toMatchObject({ status: 422, message: "Choose an account", details: { diff --git a/server/src/services/ai-connection-configuration-failure.ts b/server/src/services/ai-connection-configuration-failure.ts index 095aa81dd7c..06c76a17f3f 100644 --- a/server/src/services/ai-connection-configuration-failure.ts +++ b/server/src/services/ai-connection-configuration-failure.ts @@ -10,6 +10,8 @@ const reasons = [ ] as const; type AiConnectionConfigurationReason = typeof reasons[number]; const failures = new WeakMap(); +type CredentialAccessFailure = { connectionName: string; grantId?: string }; +const credentialAccessFailures = new WeakMap(); /** Preserve the existing HTTP error while recording an explicit selection rejection. */ export function aiConnectionConfigurationFailure( @@ -23,12 +25,18 @@ export function aiConnectionConfigurationFailure( } /** A verified active user lacks this credential's human sharing permission. */ -export function aiConnectionCredentialNotSharedFailure() { +export function aiConnectionCredentialNotSharedFailure(context?: CredentialAccessFailure) { const error = forbidden("This credential is not shared with the responsible user"); failures.set(error, "ai_connection_credential_not_shared"); + if (context) credentialAccessFailures.set(error, { ...context }); return error; } +/** Display metadata only, kept off the public 403 response and free of secret material. */ +export function readAiCredentialAccessFailure(error: unknown): CredentialAccessFailure | null { + return error instanceof Error ? credentialAccessFailures.get(error) ?? null : null; +} + /** Only owned producers supply this evidence; matching names or HTTP codes do not. */ export function readAiConnectionConfigurationFailure(error: unknown): AiConnectionConfigurationReason | null { return error instanceof Error ? failures.get(error) ?? null : null; diff --git a/server/src/services/ai-connections.ts b/server/src/services/ai-connections.ts index 2af7a48b4f6..d2d94aa4457 100644 --- a/server/src/services/ai-connections.ts +++ b/server/src/services/ai-connections.ts @@ -352,7 +352,7 @@ export function aiConnectionService(db: Db) { if (!canUseCredential(grant, userId, audience)) { // Membership was checked above. A known user's sharing choice is an // operator action; absent identity must remain an unclassified denial. - if (userId) throw aiConnectionCredentialNotSharedFailure(); + if (userId) throw aiConnectionCredentialNotSharedFailure({ connectionName: connection.name, grantId: grant.id }); throw forbidden("This credential is not shared with the responsible user"); } const installs = await db diff --git a/server/src/services/credential-access-visibility.ts b/server/src/services/credential-access-visibility.ts new file mode 100644 index 00000000000..220315d189e --- /dev/null +++ b/server/src/services/credential-access-visibility.ts @@ -0,0 +1,28 @@ +import { sql } from "drizzle-orm"; +import { connectionGrants, connectionGrantMembers, heartbeatRuns } from "@paperclipai/db"; + +/** Diagnostic names are visible to the affected user and the human grant audience. + * Callers obtain viewer identity from authentication, never request parameters. + * The grant reference stays in storage and never enters the response. */ +export function credentialAccessConnectionNameSql(viewerUserId: string | null) { + return sql`case when jsonb_typeof(${heartbeatRuns.resultJson} #> '{configurationIncomplete,credentialAccess,connectionName}') = 'string' + and ${viewerUserId}::text is not null + and (${heartbeatRuns.responsibleUserId} = ${viewerUserId} + or exists ( + select 1 from ${connectionGrants} + where ${connectionGrants.companyId} = ${heartbeatRuns.companyId} + and ${connectionGrants.id}::text = ${heartbeatRuns.resultJson} #>> '{configurationIncomplete,credentialAccess,grantId}' + and ((${connectionGrants.kind} = 'user' and ${connectionGrants.subjectUserId} = ${viewerUserId}) + or (${connectionGrants.kind} = 'organization' and ( + not exists (select 1 from ${connectionGrantMembers} + where ${connectionGrantMembers.companyId} = ${connectionGrants.companyId} + and ${connectionGrantMembers.grantId} = ${connectionGrants.id}) + or exists (select 1 from ${connectionGrantMembers} + where ${connectionGrantMembers.companyId} = ${connectionGrants.companyId} + and ${connectionGrantMembers.grantId} = ${connectionGrants.id} + and ${connectionGrantMembers.subjectType} = 'user' + and ${connectionGrantMembers.subjectId} = ${viewerUserId}) + ))) + )) + then left(${heartbeatRuns.resultJson} #>> '{configurationIncomplete,credentialAccess,connectionName}', 240) end`; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index bbe185fb526..615dd950638 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -526,7 +526,7 @@ import { emitAgentTaskRun, emitAgentTaskRunById, } from "./agent-task-run-telemetry.js"; -import { readAiConnectionConfigurationFailure } from "./ai-connection-configuration-failure.js"; +import { readAiConnectionConfigurationFailure, readAiCredentialAccessFailure } from "./ai-connection-configuration-failure.js"; import { reportRunFailure } from "./run-failure-report.js"; import { collectRunFailureSecretValues, type RunFailureReportOptions } from "./run-failure-diagnostics.js"; @@ -3650,6 +3650,8 @@ export function heartbeatService( configurationIncomplete: { reason: "ai_connection_unavailable", companyId: agent.companyId, agentId: agent.id, responsibleUserId, ...(!persistedNativeExecutionInput && readAiConnectionConfigurationFailure(error) ? { selectionFailure: readAiConnectionConfigurationFailure(error) } : {}), + ...(readAiCredentialAccessFailure(error) + ? { credentialAccess: readAiCredentialAccessFailure(error) } : {}), provider: aiBinding.provider, method: aiBinding.method, actionUrl: `/agents/${agent.id}/runtime`, fingerprint: `ai:${agent.id}:${responsibleUserId}:${JSON.stringify(aiBinding)}` }, }); diff --git a/server/src/services/run-secret-redaction.ts b/server/src/services/run-secret-redaction.ts index 1dfbdbedc61..5982aad5445 100644 --- a/server/src/services/run-secret-redaction.ts +++ b/server/src/services/run-secret-redaction.ts @@ -5,6 +5,7 @@ import { heartbeatRuns } from "@paperclipai/db"; import { REDACTED_EVENT_VALUE } from "../redaction.js"; import { getSecretProvider } from "../secrets/provider-registry.js"; import type { StoredSecretVersionMaterial } from "../secrets/types.js"; +import { credentialAccessConnectionNameSql } from "./credential-access-visibility.js"; const REGISTRY_KEY = "paperclipSecretRedactions"; // Project only the registry: run contexts can contain megabytes of prompt data. @@ -41,9 +42,9 @@ function redactText(input: string, values: string[]) { ); } -export function redactRegisteredSecretValues(input: T, values: string[]): T { +export function redactRegisteredSecretValues(input: T, values: string[], credentialConnectionName: string | null = null): T { if (typeof input === "string") return redactText(input, values) as T; - if (Array.isArray(input)) return input.map((value) => redactRegisteredSecretValues(value, values)) as T; + if (Array.isArray(input)) return input.map((value) => redactRegisteredSecretValues(value, values, credentialConnectionName)) as T; // Dates carry no redactable text; rebuilding them via Object.entries would // collapse them to `{}` and break every timestamp in redacted responses. if (input instanceof Date) return input; @@ -52,7 +53,9 @@ export function redactRegisteredSecretValues(input: T, values: string[]): T { return Object.fromEntries( Object.entries(record) .filter(([key]) => key !== REGISTRY_KEY) - .map(([key, value]) => [key, redactRegisteredSecretValues(value, values)]), + .map(([key, value]) => [key, key === "credentialAccess" + ? (credentialConnectionName ? { connectionName: redactText(credentialConnectionName, values) } : {}) + : redactRegisteredSecretValues(value, values, credentialConnectionName)]), ) as T; } @@ -71,11 +74,11 @@ export function createRunSecretRedactionRegistry(db: Db) { return values.sort((left, right) => right.length - left.length); } - async function valuesForRun(companyId: string, runId: string) { - const rows = await db.select({ contextSnapshot: registrySnapshot }) + async function valuesForRun(companyId: string, runId: string, viewerUserId: string | null) { + const rows = await db.select({ contextSnapshot: registrySnapshot, credentialConnectionName: credentialAccessConnectionNameSql(viewerUserId) }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId))); - return valuesForRuns(rows); + return { values: await valuesForRuns(rows), credentialConnectionName: rows[0]?.credentialConnectionName ?? null }; } async function valuesForIssue(companyId: string, issueId: string) { @@ -118,9 +121,9 @@ export function createRunSecretRedactionRegistry(db: Db) { .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId))); }); }, - redactForRuns: async (companyId: string, runs: T[]): Promise => { + redactForRuns: async (companyId: string, runs: T[], viewerUserId: string | null = null): Promise => { if (runs.length === 0) return []; - const rows = await db.select({ id: heartbeatRuns.id, contextSnapshot: registrySnapshot }) + const rows = await db.select({ id: heartbeatRuns.id, contextSnapshot: registrySnapshot, credentialConnectionName: credentialAccessConnectionNameSql(viewerUserId) }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runs.map((run) => run.id)))); // Resolve each encrypted value once per request, but apply only each run's @@ -135,12 +138,17 @@ export function createRunSecretRedactionRegistry(db: Db) { } return value; })); - return [row.id, values.sort((a, b) => b.length - a.length)] as const; + return [row.id, { values: values.sort((a, b) => b.length - a.length), credentialConnectionName: row.credentialConnectionName ?? null }] as const; }))); - return runs.map((run) => redactRegisteredSecretValues(run, valuesByRun.get(run.id) ?? [])); + return runs.map((run) => { + const disclosure = valuesByRun.get(run.id); + return redactRegisteredSecretValues(run, disclosure?.values ?? [], disclosure?.credentialConnectionName ?? null); + }); + }, + redactForRun: async (companyId: string, runId: string, value: T, viewerUserId: string | null = null): Promise => { + const disclosure = await valuesForRun(companyId, runId, viewerUserId); + return redactRegisteredSecretValues(value, disclosure.values, disclosure.credentialConnectionName); }, - redactForRun: async (companyId: string, runId: string, value: T): Promise => - redactRegisteredSecretValues(value, await valuesForRun(companyId, runId)), redactForIssue: async (companyId: string, issueId: string, value: T): Promise => redactRegisteredSecretValues(value, await valuesForIssue(companyId, issueId)), }; diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index 5daa3701ccb..3deac415b69 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -16,7 +16,7 @@ export interface RunForIssue { invocationSource: string; responsibleUserId?: string | null; errorCode?: string | null; - /** Bounded provider message for a failed model rejection. */ + /** Bounded model rejection or the fixed credential-sharing denial message. */ error?: string | null; usageJson: Record | null; resultJson: Record | null; diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 7da26219cf1..b05b403b431 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -1023,6 +1023,26 @@ describe("TaskChatThread runtime transcript selection", () => { expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull(); }); + it.each(["legacy", "native"] as const)("keeps a %s credential denial expanded and replaces it after a successful attempt", (runtimeMode) => { + const denied = { + runId: "credential-run", runtimeMode, status: "failed", errorCode: "configuration_incomplete", + agentId: "agent-1", agentName: "Codie", adapterType: runtimeMode === "native" ? "paperclip_runner" : "codex_local", + responsibleUserId: "nicky", createdAt: "2026-08-25T18:00:00Z", startedAt: null, finishedAt: "2026-08-25T18:00:02Z", + resultJson: { configurationIncomplete: { selectionFailure: "ai_connection_credential_not_shared", credentialAccess: { connectionName: "Dotta’s API Key" } } }, + }; + const props = { comments: [], onAdd: async () => {}, issueStatus: "blocked", currentUserId: "nicky", onRetryFailedRun: vi.fn() }; + render(); + const alert = container.querySelector('[data-testid="task-chat-credential-access-notice"]'); + expect(alert?.getAttribute("role")).toBe("alert"); + expect(alert?.textContent).toContain("Codie is configured to use Dotta’s API Key, but you don’t have access"); + expect(alert?.querySelector('a[href="/agents/agent-1/runtime"]')).not.toBeNull(); + expect(alert?.querySelector('a[href*="/runs/credential-run"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull(); + render(); + expect(container.querySelector('[data-testid="task-chat-credential-access-notice"]')).toBeNull(); + }); + it("directs a missing personal AI credential to its card without offering a premature retry", () => { render( {}} issueStatus="blocked" onRetryFailedRun={vi.fn()} interactions={[{ diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index c87ce08478f..df74a690777 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -5,6 +5,7 @@ import { } from "@/lib/issue-detail-performance"; import { hasWorkspaceRestoreFailure } from "@paperclipai/shared"; import { workspaceRestoreMarkerDetail } from "@/lib/workspace-restore-marker"; +import { credentialAccessNotice } from "@/lib/credential-access-notice"; import type { ActivityEvent, IssueQueuedCommentQueue, TaskBrowser } from "@paperclipai/shared"; import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems"; import { skillCreatedItems } from "@/components/task-chat/skill-created-items"; @@ -1510,6 +1511,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { if (liveRun && source.id === liveRun.id) continue; const entries = transcriptByRun.get(source.id) ?? []; const meta = linkedRunMetaById.get(source.id); + const credentialAccess = credentialAccessNotice(meta ? { + ...meta, + agentName: meta.agentName || agentMap?.get(meta.agentId)?.name, + } : undefined, currentUserId, userLabelMap); const historical = statusRelevance.isHistoricalRun(source.id); // A workspace admission attempt never started provider work. Its live // successor owns the waiting indicator; retain this attempt in the run log. @@ -1692,6 +1697,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { label, detail, retryable: code !== "native_session_cleanup_quarantined", + ...(credentialAccess ? { credentialAccess, retryable: false } : {}), collapsible: true, runId: source.id, createdAtIso: finishedAt @@ -1699,7 +1705,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { : undefined, runHref: runAgent ? `/agents/${encodeURIComponent(runAgent.urlKey)}/runs/${encodeURIComponent(source.id)}` - : undefined, + : credentialAccess && meta?.agentId + ? `/agents/${encodeURIComponent(meta.agentId)}/runs/${encodeURIComponent(source.id)}` + : undefined, }, }); } @@ -1758,6 +1766,11 @@ export function TaskChatThread(props: TaskChatThreadProps) { runHref: meta?.agentId ? `/agents/${encodeURIComponent(agentMap?.get(meta.agentId)?.urlKey ?? meta.agentId)}/runs/${encodeURIComponent(source.id)}` : undefined, planHref: savedPlan ? "#document-plan" : undefined, } : {}), + ...(credentialAccess ? { + credentialAccess, + retryable: false, + runHref: `/agents/${encodeURIComponent(meta?.agentId ?? "")}/runs/${encodeURIComponent(source.id)}`, + } : {}), tone: source.status === "cancelled" || aiRequest?.status === "pending" ? "neutral" : "error", detail, }, @@ -2157,6 +2170,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { planDocumentSourceRunId, planTurnItem, agentMap, + currentUserId, + userLabelMap, ]); // Hand off once the settled turn or its reply comment is in the thread; a diff --git a/ui/src/components/task-chat/TaskChatMarker.tsx b/ui/src/components/task-chat/TaskChatMarker.tsx index 29ccd9f149f..234d9f48e54 100644 --- a/ui/src/components/task-chat/TaskChatMarker.tsx +++ b/ui/src/components/task-chat/TaskChatMarker.tsx @@ -1,6 +1,6 @@ import { useId, useState } from "react"; import { cn } from "@/lib/utils"; -import { ChevronDown, CircleDot, OctagonX, Square, Flag } from "lucide-react"; +import { ChevronDown, CircleDot, OctagonX, Square, Flag, KeyRound } from "lucide-react"; import { useStreamlinedTaskChatPresentation } from "./presentation-mode"; import type { TaskChatMarkerItem } from "./task-chat-model"; import { Button } from "@/components/ui/button"; @@ -40,6 +40,29 @@ export function TaskChatMarker({ .catch(() => undefined); }; + if (item.credentialAccess) { + const { agentName, credentialName, deniedUser, settingsHref } = item.credentialAccess; + return ( +
+ +
+

{agentName} couldn’t start

+

+ {agentName} is configured to use {credentialName || "an AI credential"}, but {deniedUser === "you" ? "you don’t" : `${deniedUser} doesn’t`} have access to that credential. +

+

+ Choose an AI connection {deniedUser === "you" ? "you can" : `${deniedUser} can`} use, or ask an administrator to update {agentName}’s connection. Then try again. +

+
+ + {item.runHref && } +
+
+
+ ); + } + if (item.collapsible) { return (
{ + it("names the selected credential and addresses only the denied person as you", () => { + expect(credentialAccessNotice(run, "nicky")).toMatchObject({ agentName: "Codie", credentialName: "Dotta’s API Key", deniedUser: "you" }); + expect(credentialAccessNotice(run, "dotta", new Map([["nicky", "Nicky"]]))?.deniedUser).toBe("Nicky"); + expect(credentialAccessNotice(run, undefined)?.deniedUser).toBe("the person this task runs for"); + }); + it("recognizes the fixed historical denial without guessing credential identity", () => { + expect(credentialAccessNotice({ ...run, resultJson: null, error: "This credential is not shared with the responsible user" }, "nicky")) + .toMatchObject({ deniedUser: "you", credentialName: undefined }); + expect(credentialAccessNotice({ ...run, error: "This credential is not shared with the responsible user", + resultJson: { configurationIncomplete: { credentialAccess: { connectionName: "Dotta’s API Key" } } } }, "nicky")) + .toMatchObject({ deniedUser: "you", credentialName: "Dotta’s API Key" }); + }); + it("does not relabel unrelated configuration failures or successful attempts", () => { + expect(credentialAccessNotice({ ...run, status: "succeeded" }, "nicky")).toBeUndefined(); + expect(credentialAccessNotice({ ...run, resultJson: { configurationIncomplete: { selectionFailure: "ai_connection_unavailable" } } }, "nicky")).toBeUndefined(); + expect(credentialAccessNotice({ ...run, resultJson: null, error: "Provider returned a credential problem" }, "nicky")).toBeUndefined(); + }); +}); diff --git a/ui/src/lib/credential-access-notice.ts b/ui/src/lib/credential-access-notice.ts new file mode 100644 index 00000000000..401befdc322 --- /dev/null +++ b/ui/src/lib/credential-access-notice.ts @@ -0,0 +1,30 @@ +import type { IssueChatLinkedRun } from "./issue-chat-messages"; +import type { TaskChatMarkerItem } from "../components/task-chat/task-chat-model"; + +function object(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : {}; +} + +/** Presentation only: older runs can carry the original sharing-denial message. */ +export function credentialAccessNotice( + run: IssueChatLinkedRun | undefined, + currentUserId: string | null | undefined, + userLabels?: ReadonlyMap | null, +): TaskChatMarkerItem["credentialAccess"] | undefined { + if (!run || run.status !== "failed") return undefined; + const configuration = object(run.resultJson?.configurationIncomplete); + if (configuration.selectionFailure !== "ai_connection_credential_not_shared" && + run.error !== "This credential is not shared with the responsible user") return undefined; + const credential = object(configuration.credentialAccess); + const responsibleUserId = typeof configuration.responsibleUserId === "string" + ? configuration.responsibleUserId : run.responsibleUserId; + return { + agentName: run.agentName?.trim() || "This agent", + credentialName: typeof credential.connectionName === "string" && credential.connectionName.trim() + ? credential.connectionName : undefined, + deniedUser: responsibleUserId && responsibleUserId === currentUserId + ? "you" : responsibleUserId ? userLabels?.get(responsibleUserId) || "the person this task runs for" : "the person this task runs for", + settingsHref: `/agents/${encodeURIComponent(run.agentId)}/runtime`, + }; +} diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index cd184c00425..e3b4a43ec24 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -53,6 +53,7 @@ export interface IssueChatLinkedRun { agentId: string; adapterType?: string; agentName?: string; + responsibleUserId?: string | null; createdAt: Date | string; startedAt: Date | string | null; finishedAt?: Date | string | null; diff --git a/ui/storybook/stories/credential-access/01-notice.stories.tsx b/ui/storybook/stories/credential-access/01-notice.stories.tsx new file mode 100644 index 00000000000..91ea2eb8a6d --- /dev/null +++ b/ui/storybook/stories/credential-access/01-notice.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker"; + +const meta = { + title: "Task credential access/01 Notice", + component: TaskChatMarker, + parameters: { layout: "centered" }, + decorators: [Story =>
], + args: { + item: { + id: "credential-failure", kind: "marker", variant: "interrupted", label: "Run failed", + retryable: false, runHref: "/agents/agent-codex/runs/credential-run", + credentialAccess: { agentName: "Codie", credentialName: "Dotta’s API Key", deniedUser: "you", settingsHref: "/agents/agent-codex/runtime" }, + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const YourAccess: Story = {}; +export const AnotherPersonsTask: Story = { + args: { item: { ...meta.args.item, credentialAccess: { ...meta.args.item.credentialAccess, deniedUser: "Nicky" } } }, +}; +export const OlderRunWithoutCredentialName: Story = { + args: { item: { ...meta.args.item, credentialAccess: { ...meta.args.item.credentialAccess, credentialName: undefined } } }, +}; +export const LongNames: Story = { + args: { item: { ...meta.args.item, credentialAccess: { ...meta.args.item.credentialAccess, + agentName: "Codie — product engineering", credentialName: "Dotta’s OpenAI API Key for staging product engineering" } } }, +}; +export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/stories/credential-access/02-task-page.stories.tsx b/ui/storybook/stories/credential-access/02-task-page.stories.tsx new file mode 100644 index 00000000000..e60aa8f9773 --- /dev/null +++ b/ui/storybook/stories/credential-access/02-task-page.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CredentialTaskPage } from "./CredentialTaskPage"; + +const meta = { + title: "Task credential access/02 Task page", + component: CredentialTaskPage, + parameters: { layout: "fullscreen", waitForViewport: true, + docs: { description: { component: "Production task detail and app shell. The latest credential failure stays expanded in the conversation and names the selected credential. The settings link uses the existing agent runtime route. Example data is local to Storybook." } }, + }, + args: { otherPerson: false, runtimeMode: "native", newerAttempt: false }, + render: args => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const YourAccess: Story = {}; +export const NickyCannotUseDottasKey: Story = { args: { otherPerson: true } }; +export const LegacyRunner: Story = { args: { runtimeMode: "legacy" } }; +export const Mobile: Story = { globals: { viewport: { value: "mobile", isRotated: false } } }; +export const Light: Story = { globals: { theme: "light" } }; +export const NewerSuccessfulAttempt: Story = { args: { newerAttempt: true } }; diff --git a/ui/storybook/stories/credential-access/CredentialTaskPage.tsx b/ui/storybook/stories/credential-access/CredentialTaskPage.tsx new file mode 100644 index 00000000000..02097366d94 --- /dev/null +++ b/ui/storybook/stories/credential-access/CredentialTaskPage.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Layout } from "@/components/Layout"; +import { IssueDetail } from "@/pages/IssueDetail"; +import { AgentDetail } from "@/pages/AgentDetail"; +import { PluginLauncherProvider } from "@/plugins/launchers"; +import { Navigate, Route, Routes } from "@/lib/router"; +import { queryKeys } from "@/lib/queryKeys"; +import { seedIssueDetailCache } from "@/lib/issueDetailCache"; +import { storybookAgents, storybookIssues, storybookAuthSession } from "../../fixtures/paperclipData"; +import type { IssueChatComment, IssueChatLinkedRun } from "@/lib/issue-chat-messages"; + +export function CredentialTaskPage({ otherPerson = false, runtimeMode = "native", newerAttempt = false }: { + otherPerson?: boolean; runtimeMode?: "legacy" | "native"; newerAttempt?: boolean; +}) { + const client = useQueryClient(); + const [fixture] = useState(() => { + const agent = { ...storybookAgents[0]!, name: "Codie", urlKey: "codie", status: "idle" as const, + adapterType: runtimeMode === "native" ? "paperclip_runner" : "codex_local" }; + const task = { ...storybookIssues[0]!, id: "credential-task-demo", identifier: "PAP-389", + title: "Use task names in Slack notifications", description: null, status: "blocked" as const, + assigneeAgentId: agent.id, assigneeUserId: null, projectId: null, parentId: null, + executionRunId: null, checkoutRunId: null, executionLockedAt: null, + labels: [], labelIds: [], + blockedBy: [], blocks: [], ancestors: [], documentSummaries: [], activeRecoveryAction: null, + planDocument: null, successfulRunHandoff: null, currentExecutionWorkspace: null, + }; + const ago = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString(); + const comments: IssueChatComment[] = [{ + id: "credential-comment", companyId: task.companyId, issueId: task.id, + authorType: "user", authorUserId: otherPerson ? "nicky" : "user-board", authorAgentId: null, + body: "I just saw a similar bug where we use the task ID rather than the human-friendly task name. Confirm whether your earlier fix addresses this bug. If not, implement a fix and open a PR.", + presentation: null, metadata: null, createdAt: new Date(ago(4)), updatedAt: new Date(ago(4)), + }, { + id: "credential-retry-comment", companyId: task.companyId, issueId: task.id, + authorType: "user", authorUserId: otherPerson ? "nicky" : "user-board", authorAgentId: null, + body: "I tried reconnecting my OpenAI subscription but Codie still won’t run for me.", + presentation: null, metadata: null, createdAt: new Date(ago(2)), updatedAt: new Date(ago(2)), + }]; + const run: IssueChatLinkedRun = { + runId: "credential-run", agentId: agent.id, agentName: agent.name, adapterType: agent.adapterType, + responsibleUserId: otherPerson ? "nicky" : "user-board", runtimeMode, status: "failed", + errorCode: "configuration_incomplete", error: "This credential is not shared with the responsible user", + createdAt: ago(1), startedAt: null, finishedAt: ago(1), hasStoredOutput: false, + resultJson: { configurationIncomplete: { selectionFailure: "ai_connection_credential_not_shared", + credentialAccess: { connectionName: "Dotta’s API Key" } } }, + }; + const runs = newerAttempt ? [run, { ...run, runId: "newer-run", status: "succeeded", errorCode: null, error: null, + createdAt: ago(0.5), startedAt: ago(0.5), finishedAt: ago(0.1), resultJson: null }] : [run]; + if (newerAttempt) comments.push({ ...comments[0]!, id: "credential-success-comment", authorType: "agent", + authorUserId: null, authorAgentId: agent.id, body: "The notification now uses the task’s title. I checked the task link and opened the fix for review.", + runId: "newer-run", createdAt: new Date(ago(0.1)), updatedAt: new Date(ago(0.1)) }); + seedIssueDetailCache(client, task); + client.setQueryData(queryKeys.auth.session, { ...storybookAuthSession, + user: { ...storybookAuthSession.user, name: otherPerson ? "Dotta" : "Nicky" } }); + client.setQueryData(queryKeys.agents.list(task.companyId), [agent, ...storybookAgents.slice(1)]); + client.setQueryData(queryKeys.liveRuns(task.companyId), []); + client.setQueryData(["email-thread", task.companyId, task.id], null); + for (const ref of [task.id, task.identifier]) { + client.setQueryData(["email-thread", task.companyId, ref], null); + client.setQueryData(queryKeys.issues.comments(ref), { pages: [[...comments].reverse()], pageParams: [null] }); + client.setQueryData(queryKeys.issues.runs(ref), runs); + client.setQueryData(queryKeys.issues.documents(ref), []); + client.setQueryData([...queryKeys.issues.documents(ref), "plan"], null); + client.setQueryData(queryKeys.issues.liveRuns(ref), []); + client.setQueryData(queryKeys.issues.activeRun(ref), null); + client.setQueryData(["issues", "tree-control-state", ref], { activePauseHold: null }); + } + const originalFetch = window.fetch; + const fetchFixture: typeof fetch = async (input, init) => { + const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const url = new URL(raw, window.location.origin); + if (url.pathname === `/api/companies/${task.companyId}/agents`) return Response.json([agent, ...storybookAgents.slice(1)]); + if (url.pathname === `/api/companies/${task.companyId}/live-runs`) return Response.json([]); + if ([task.id, task.identifier].some(ref => url.pathname === `/api/companies/${task.companyId}/email/tasks/${ref}`)) return Response.json(null); + if (url.pathname === `/api/agents/${agent.id}` || url.pathname === "/api/agents/codie") return Response.json(agent); + const runResource = url.pathname.match(/^\/api\/heartbeat-runs\/(credential-run|newer-run)\/(events|log)$/); + if (runResource?.[2] === "events") return Response.json([]); + if (runResource?.[2] === "log") return Response.json({ runId: runResource[1], store: "local", logRef: "storybook", content: "", nextOffset: 0 }); + if (url.pathname === `/api/companies/${task.companyId}/user-directory`) return Response.json({ users: [ + { principalId: "user-board", status: "active", user: { id: "user-board", name: otherPerson ? "Dotta" : "Nicky", email: "board@example.test", image: null } }, + { principalId: "nicky", status: "active", user: { id: "nicky", name: "Nicky", email: "nicky@example.test", image: null } }, + ] }); + const match = url.pathname.match(/^\/api\/issues\/([^/]+)(?:\/(.*))?$/); + if (match && [task.id, task.identifier].includes(match[1]!)) { + const resource = match[2] ?? ""; + if (!resource) return Response.json(task); + if (resource === "comments") return Response.json([...comments].reverse()); + if (resource === "runs") return Response.json(runs); + if (resource === "active-run") return Response.json(null); + if (resource === "documents/plan") return Response.json({ error: "No plan" }, { status: 404 }); + if (resource === "tree-control/state") return Response.json({ activePauseHold: null }); + if (resource === "read") return Response.json({ ok: true }); + return Response.json([]); + } + return originalFetch(input, init); + }; + window.fetch = fetchFixture; + return { task, restore: () => { if (window.fetch === fetchFixture) window.fetch = originalFetch; } }; + }); + useEffect(() => fixture.restore, [fixture]); + return + }> + } /> + } /> + + } /> + ; +}