fix: show credential access errors in task chat (#15741)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents use AI connections to run tasks for a responsible user.
> - A connection can deny access when its owner has not shared it with
that user.
> - The task conversation hides this cause behind a collapsed failure
marker.
> - People need to see which connection blocked the agent and how to fix
it.
> - This pull request shows an expanded access error in the task
conversation.
> - The error names the agent and connection, and links to the agent's
AI settings.

## Linked Issues or Issue Description

**What happened?**

A task could fail because its responsible user could not use the agent's
AI connection. The user saw a temporary error toast and a collapsed task
failure. The task did not clearly explain the access problem.

**Expected behavior**

The task should show a visible error that names the agent, the
connection, and the person who lacks access. For example: "Codie is
configured to use Dotta's API Key, but you don't have access to that
credential." It should offer a link to change the AI connection.

**Steps to reproduce**

1. Configure an agent with a personal AI connection.
2. Start a task for a user who cannot use that connection.
3. Open the task after the run fails.
4. Check that the access error stays visible and links to AI settings.

Refs #15714. That change classifies expected connection-sharing denials.
This change makes the same failure clear in task chat.

## What Changed

- Keep the connection display name in private error metadata when a
sharing check denies access.
- Store a private grant reference for the name visibility check. Use one
rule for task summaries and full run responses. Show the name to the
affected user and the connection sharing audience. Other task readers
see a generic label. Strip grant IDs from public run responses.
- Show an expanded error card for native and legacy task runs. Use "you"
only when the viewer is the responsible user.
- Link to AI settings and the failed run. Preserve names on resumed
native runs. Hide the old error after a newer successful attempt.
- Add full task-page Storybook examples, focused tests, and design
guidance.

## Verification

- Passed 249 focused tests for task chat, error text, private error
metadata, and the real database run summary. These include resumed
native runs and viewer access rules.
- Passed all UI token gates on the rebased branch.
- Checked the production task page in Storybook. Verified the viewer's
error, another user's error, mobile layout, and a newer successful
attempt.
- Passed `pnpm -r typecheck` and `pnpm build` in a clean checkout with
Node 24 and the pinned pnpm version. Passed the final server typecheck
after the response filter changes.
- Passed the real database route regression for the connection owner,
affected user, and another task reader. Passed the registered secret
redaction tests.
- The local route checks needed `--testTimeout 60000` for cold imports.
A broader local route run hit cold-import timeouts and an old fixture
assertion that is now fixed. The duplicate local `pnpm test:run`
aggregate was stopped to free resources. The full suite passed in CI
with the normal timeouts.
- Passed the full [final-commit CI
workflow](https://github.com/paperclipai/paperclip/actions/runs/37996148342),
including all server, UI, runner, browser, typecheck, build, and canary
packaging gates. All 54 executed checks passed; two Storybook jobs were
skipped. Greptile reviewed the final commit at 5/5 with no remaining
findings, and every review thread is resolved.
- Review the Storybook group `Task / Credential access` for the notice
and full task-page examples.

## Risks

- Only the allowed connection display name enters the compact run
summary. Credential secrets, grant IDs, and arbitrary error details do
not enter it.
- Existing runs can lack the connection name. They show a clear fallback
message.
- The notice uses the existing task-run relevance rules. A newer attempt
or a completed task hides the old failure.
- No schema migration or API permission change is required.

## Model Used

OpenAI GPT-6 through Codex, with tool execution and browser
verification. The exact serving variant and context-window size are not
exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-09 17:06:54 -05:00
1 parent 26d71d8def
commit 417db14e7d
25 files changed
+500 -30

No files matched your search

+9
View File
@@ -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
@@ -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<typeof createDb>;
let temporaryDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
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({});
});
});
+1 -1
View File
@@ -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" }]);
});
@@ -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))
@@ -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` },
+3 -2
View File
@@ -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);
});
+6 -3
View File
@@ -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(
+23 -6
View File
@@ -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<Record<string, unknown> | 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<Record<string, unknown> | 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<string | null>`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,
@@ -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: {
@@ -10,6 +10,8 @@ const reasons = [
] as const;
type AiConnectionConfigurationReason = typeof reasons[number];
const failures = new WeakMap<Error, AiConnectionConfigurationReason>();
type CredentialAccessFailure = { connectionName: string; grantId?: string };
const credentialAccessFailures = new WeakMap<Error, CredentialAccessFailure>();
/** 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;
+1 -1
View File
@@ -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
@@ -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<string | null>`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`;
}
+3 -1
View File
@@ -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)}` },
});
+20 -12
View File
@@ -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<T>(input: T, values: string[]): T {
export function redactRegisteredSecretValues<T>(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<T>(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 <T extends { id: string }>(companyId: string, runs: T[]): Promise<T[]> => {
redactForRuns: async <T extends { id: string }>(companyId: string, runs: T[], viewerUserId: string | null = null): Promise<T[]> => {
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 <T>(companyId: string, runId: string, value: T, viewerUserId: string | null = null): Promise<T> => {
const disclosure = await valuesForRun(companyId, runId, viewerUserId);
return redactRegisteredSecretValues(value, disclosure.values, disclosure.credentialConnectionName);
},
redactForRun: async <T>(companyId: string, runId: string, value: T): Promise<T> =>
redactRegisteredSecretValues(value, await valuesForRun(companyId, runId)),
redactForIssue: async <T>(companyId: string, issueId: string, value: T): Promise<T> =>
redactRegisteredSecretValues(value, await valuesForIssue(companyId, issueId)),
};
+1 -1
View File
@@ -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<string, unknown> | null;
resultJson: Record<string, unknown> | null;
+20
View File
@@ -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(<TaskChatThread {...props} linkedRuns={[denied]} />);
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(<TaskChatThread {...props} linkedRuns={[denied, { ...denied, runId: "new-run", status: "succeeded",
resultJson: null, errorCode: null, createdAt: "2026-08-25T18:01:00Z", startedAt: "2026-08-25T18:01:00Z", finishedAt: "2026-08-25T18:01:02Z" }]} />);
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(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="blocked"
onRetryFailedRun={vi.fn()} interactions={[{
+16 -1
View File
@@ -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
+24 -1
View File
@@ -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 (
<div role="alert" data-testid="task-chat-credential-access-notice"
className="tc-enter-marker flex w-full items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm">
<KeyRound className="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<p className="font-semibold text-destructive">{agentName} couldn’t start</p>
<p className="break-words font-medium text-foreground">
{agentName} is configured to use {credentialName || "an AI credential"}, but {deniedUser === "you" ? "you don’t" : `${deniedUser} doesn’t`} have access to that credential.
</p>
<p className="text-muted-foreground">
Choose an AI connection {deniedUser === "you" ? "you can" : `${deniedUser} can`} use, or ask an administrator to update {agentName}’s connection. Then try again.
</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button asChild variant="outline" size="sm"><Link to={settingsHref}>Change AI connection</Link></Button>
{item.runHref && <Button asChild variant="ghost" size="sm"><Link to={item.runHref}>View run</Link></Button>}
</div>
</div>
</div>
);
}
if (item.collapsible) {
return (
<div
@@ -261,6 +261,13 @@ export interface TaskChatMarkerItem {
createdAtIso?: string;
runHref?: string;
planHref?: string;
/** An actionable credential denial is always expanded in the task conversation. */
credentialAccess?: {
agentName: string;
credentialName?: string;
deniedUser: string;
settingsHref: string;
};
}
/** A second-tier live token/cost readout (ACP UsageUpdate). */
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { credentialAccessNotice } from "./credential-access-notice";
import type { IssueChatLinkedRun } from "./issue-chat-messages";
const run: IssueChatLinkedRun = { runId: "denied", agentId: "codie", agentName: "Codie", status: "failed",
createdAt: "2026-10-09T20:00:00Z", startedAt: null, responsibleUserId: "nicky",
resultJson: { configurationIncomplete: { selectionFailure: "ai_connection_credential_not_shared",
credentialAccess: { connectionName: "Dotta’s API Key" } } },
};
describe("credential access notice", () => {
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();
});
});
+30
View File
@@ -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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : {};
}
/** Presentation only: older runs can carry the original sharing-denial message. */
export function credentialAccessNotice(
run: IssueChatLinkedRun | undefined,
currentUserId: string | null | undefined,
userLabels?: ReadonlyMap<string, string> | 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`,
};
}
+1
View File
@@ -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;
@@ -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 => <div className="w-full max-w-2xl"><Story /></div>],
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<typeof TaskChatMarker>;
export default meta;
type Story = StoryObj<typeof meta>;
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" } };
@@ -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 => <CredentialTaskPage key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof CredentialTaskPage>;
export default meta;
type Story = StoryObj<typeof meta>;
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 } };
@@ -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 <PluginLauncherProvider><Routes>
<Route path="/:companyPrefix" element={<Layout />}>
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="agents/:agentId/*" element={<AgentDetail />} />
</Route>
<Route path="*" element={<Navigate to={`/PAP/issues/${fixture.task.identifier}`} replace />} />
</Routes></PluginLauncherProvider>;
}